Compare commits
No commits in common. "master" and "1.3.0" have entirely different histories.
360 changed files with 4062 additions and 16280 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
exec "$APPDIR/usr/bin/{{BINARY_NAME}}" "$@"
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
[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;
|
|
||||||
|
|
@ -1,36 +1,23 @@
|
||||||
{
|
{
|
||||||
"app-id": "com.simeonradivoev.gameflow-deck",
|
"app-id": "com.simeonradivoev.gameflow-deck",
|
||||||
"runtime": "org.freedesktop.Platform",
|
"runtime": "org.kde.Platform",
|
||||||
"runtime-version": "25.08",
|
"runtime-version": "6.10",
|
||||||
"sdk": "org.freedesktop.Sdk",
|
"sdk": "org.kde.Sdk",
|
||||||
"command": "/app/bin/gameflow",
|
"command": "/app/bin/gameflow",
|
||||||
|
"base": "io.qt.qtwebengine.BaseApp",
|
||||||
|
"base-version": "6.10",
|
||||||
"finish-args": [
|
"finish-args": [
|
||||||
"--share=ipc",
|
"--share=ipc",
|
||||||
"--share=network",
|
"--share=network",
|
||||||
"--socket=pulseaudio",
|
"--socket=pulseaudio",
|
||||||
"--socket=wayland",
|
"--socket=wayland",
|
||||||
"--socket=inherit-wayland-socket",
|
|
||||||
"--socket=x11",
|
"--socket=x11",
|
||||||
"--socket=fallback-x11",
|
|
||||||
"--socket=session-bus",
|
|
||||||
"--socket=system-bus",
|
|
||||||
"--device=all",
|
"--device=all",
|
||||||
"--filesystem=host",
|
"--filesystem=host",
|
||||||
"--filesystem=home",
|
"--filesystem=home",
|
||||||
"--filesystem=~/.steam/steam:rw",
|
|
||||||
"--filesystem=~/.steam:rw",
|
|
||||||
"--filesystem=~/.var/app/com.valvesoftware.Steam:rw",
|
|
||||||
"--filesystem=/run/udev:ro",
|
|
||||||
"--filesystem=/run/media",
|
|
||||||
"--filesystem=xdg-documents",
|
|
||||||
"--filesystem=xdg-desktop",
|
|
||||||
"--filesystem=xdg-run/gamescope-0:rw",
|
|
||||||
"--env=PKG_CONFIG_LIBDIR=/app/lib",
|
"--env=PKG_CONFIG_LIBDIR=/app/lib",
|
||||||
"--env=FLATPAK_BUILD=true",
|
"--env=FLATPAK_BUILD=true",
|
||||||
"--allow=devel",
|
"--allow=devel"
|
||||||
"--talk-name=org.freedesktop.portal.OpenURI",
|
|
||||||
"--talk-name=org.freedesktop.Flatpak",
|
|
||||||
"--talk-name=org.a11y.Bus"
|
|
||||||
],
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
|
|
@ -42,6 +29,7 @@
|
||||||
"mkdir -p /app/lib",
|
"mkdir -p /app/lib",
|
||||||
"install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png",
|
"install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png",
|
||||||
"install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop",
|
"install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop",
|
||||||
|
"mv libvips-cpp.so.* /app/lib",
|
||||||
"mv * /app/share/gameflow/",
|
"mv * /app/share/gameflow/",
|
||||||
"mv /app/share/gameflow/gameflow /app/bin",
|
"mv /app/share/gameflow/gameflow /app/bin",
|
||||||
"mv /app/share/gameflow/bun /app/bin",
|
"mv /app/share/gameflow/bun /app/bin",
|
||||||
|
|
@ -51,15 +39,15 @@
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"type": "dir",
|
"type": "dir",
|
||||||
"path": "../../build/linux"
|
"path": "../build/linux"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "com.simeonradivoev.gameflow-deck.desktop"
|
"path": "../flatpak/com.simeonradivoev.gameflow-deck.desktop"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "../../src/mainview/public/256x256.png"
|
"path": "../src/mainview/public/256x256.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "script",
|
"type": "script",
|
||||||
|
|
@ -84,22 +72,23 @@
|
||||||
"only-arches": [
|
"only-arches": [
|
||||||
"aarch64"
|
"aarch64"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"path": "../node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3",
|
||||||
|
"only-arches": [
|
||||||
|
"x86_64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "NW.js",
|
"name": "webview",
|
||||||
"buildsystem": "simple",
|
"buildsystem": "cmake-ninja",
|
||||||
"build-commands": [
|
|
||||||
"mkdir -p /app/bin/nw",
|
|
||||||
"mv * /app/bin/nw",
|
|
||||||
"chmod +x /app/bin/nw/nw"
|
|
||||||
],
|
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "dir",
|
||||||
"url": "https://dl.nwjs.io/v0.110.1/nwjs-v0.110.1-linux-x64.tar.gz",
|
"path": "../flatpak/webview"
|
||||||
"sha256": "d9a9ed2255e9ee87c9dd1860d9c7a479cea5279dcd80d3e80e23b083d325554a"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
.config/flatpak/webview/CMakeLists.txt
Normal file
35
.config/flatpak/webview/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
14
.config/flatpak/webview/main.cpp
Normal file
14
.config/flatpak/webview/main.cpp
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#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
3
.gitattributes
vendored
|
|
@ -4,6 +4,3 @@
|
||||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||||
*.webp filter=lfs diff=lfs merge=lfs -text
|
*.webp filter=lfs diff=lfs merge=lfs -text
|
||||||
*.svg filter=lfs diff=lfs merge=lfs -text
|
*.svg filter=lfs diff=lfs merge=lfs -text
|
||||||
*.wav filter=lfs diff=lfs merge=lfs -text
|
|
||||||
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
|
||||||
*.ogg filter=lfs diff=lfs merge=lfs -text
|
|
||||||
BIN
.github/screenshots/3d screenshot.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/3d screenshot.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/3nhuKCK6E3.jpg
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/3nhuKCK6E3.jpg
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/3nhuKCK6E3.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/3nhuKCK6E3.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/6wz3gW8c2h.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/6wz3gW8c2h.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/EWPHmIBEE5.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/EWPHmIBEE5.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/GL7SkQbHIY.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/GL7SkQbHIY.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/MMeJxl4IXr.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/MMeJxl4IXr.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/Pkazk0RufB.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/Pkazk0RufB.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif
(Stored with Git LFS)
vendored
BIN
.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/mockup-1777308293568.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/mockup-1777308293568.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/rBY2mgTLy0.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/rBY2mgTLy0.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/xNj7scPEDQ.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/xNj7scPEDQ.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/yObFD2LySH.jpg
(Stored with Git LFS)
vendored
BIN
.github/screenshots/yObFD2LySH.jpg
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/zEQxtzhPGx.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/zEQxtzhPGx.png
(Stored with Git LFS)
vendored
Binary file not shown.
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
|
@ -83,7 +83,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
type: "zip"
|
type: "zip"
|
||||||
directory: ${{ github.workspace }}
|
directory: ${{ github.workspace }}
|
||||||
filename: "Gameflow-win32-x64.zip"
|
filename: "Gameflow-Windows.zip"
|
||||||
path: "canary-build-Windows"
|
path: "canary-build-Windows"
|
||||||
|
|
||||||
- name: Publish Release
|
- name: Publish Release
|
||||||
|
|
@ -96,4 +96,4 @@ jobs:
|
||||||
omitBodyDuringUpdate: true
|
omitBodyDuringUpdate: true
|
||||||
replacesArtifacts: true
|
replacesArtifacts: true
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-*.zip"
|
artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-Windows.zip"
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -28,8 +28,5 @@ downloads
|
||||||
gameflow-deck.code-workspace
|
gameflow-deck.code-workspace
|
||||||
.env.local
|
.env.local
|
||||||
src/tests/mock-roms/db.sqlite
|
src/tests/mock-roms/db.sqlite
|
||||||
src/tests/mock-roms/store
|
|
||||||
src/tests/mock-config
|
src/tests/mock-config
|
||||||
bin
|
bin
|
||||||
.config/flatpak/repo
|
|
||||||
xenia.log
|
|
||||||
18
.versionrc
18
.versionrc
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"packageFiles": [
|
|
||||||
{
|
|
||||||
"filename": "package.json",
|
|
||||||
"type": "json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"bumpFiles": [
|
|
||||||
{
|
|
||||||
"filename": "package.json",
|
|
||||||
"type": "json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "src/packages/gameflow-sdk/package.json",
|
|
||||||
"type": "json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
|
|
@ -6,22 +6,17 @@
|
||||||
"files.watcherExclude": {
|
"files.watcherExclude": {
|
||||||
"**/*.gen.*": true,
|
"**/*.gen.*": true,
|
||||||
"src/mainview/gen/*": true,
|
"src/mainview/gen/*": true,
|
||||||
"**/build": true,
|
|
||||||
"**/.config/flatpack/repo/**": true,
|
|
||||||
"**/.flatpak-builder/**": true,
|
|
||||||
},
|
},
|
||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"**/*.gen.*": true,
|
"**/*.gen.*": true,
|
||||||
"**/.flatpak-builder": true,
|
".flatpak-builder/**/*": true,
|
||||||
"**/.config/flatpack/repo/**": true,
|
|
||||||
"**/build": true,
|
|
||||||
"src/mainview/gen/*": true,
|
"src/mainview/gen/*": true,
|
||||||
},
|
},
|
||||||
"npm.scriptRunner": "bun",
|
"npm.scriptRunner": "bun",
|
||||||
"npm.exclude": [
|
"npm.exclude": [
|
||||||
"**/.flatpak-builder/**/*",
|
"**/.flatpak-builder/**/*",
|
||||||
"**/build/flatpack/**",
|
"**/build/flatpack/**",
|
||||||
"**/.config/flatpack/repo/**",
|
"**/flatpack/repo/**",
|
||||||
],
|
],
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
|
|
|
||||||
48
CHANGELOG.md
48
CHANGELOG.md
|
|
@ -1,52 +1,6 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||||
|
|
||||||
## [1.6.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.5.0...v1.6.0) (2026-05-09)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* Implemented public plugin system accessible from the store. ([38cb752](https://github.com/simeonradivoev/gameflow-deck/commit/38cb7525527b5ad4f6eb284cdad0001fd87eaf7e))
|
|
||||||
|
|
||||||
## [1.5.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.4.0...v1.5.0) (2026-05-05)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* Implemented local game import (with a wizard) ([06b7e40](https://github.com/simeonradivoev/gameflow-deck/commit/06b7e4074da23afdec3b2ff97f84a9e1486944d2))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* Navigation blocking now working with focuesed input fields ([4da717c](https://github.com/simeonradivoev/gameflow-deck/commit/4da717c26d9840febd48ee87a6a493a3e1acc6b9))
|
|
||||||
|
|
||||||
## [1.4.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.3.0...v1.4.0) (2026-04-26)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* Added more ways to detect duplicates ([05fafce](https://github.com/simeonradivoev/gameflow-deck/commit/05fafced07c853deb656d7c17d05184c42ee507c))
|
|
||||||
* added update notes and moved update to own tab ([cf84f40](https://github.com/simeonradivoev/gameflow-deck/commit/cf84f40a174b8f242ca58fb6fe02eefab46ff442))
|
|
||||||
* Added way to update the local games from romm when IDs change based on IGDB or Retro Achievement ID ([4806f34](https://github.com/simeonradivoev/gameflow-deck/commit/4806f3487a577ab8e7c66907e5b640d95ab8a46c)), closes [#2](https://github.com/simeonradivoev/gameflow-deck/issues/2)
|
|
||||||
* Bundled NW.js with appimages ([813785f](https://github.com/simeonradivoev/gameflow-deck/commit/813785f4f3d292a87cc4a6b86dc152c43572d2c8))
|
|
||||||
* Implemented audio effects ([edbc390](https://github.com/simeonradivoev/gameflow-deck/commit/edbc390d144bf44da35d0f5383ec36eb25c34d1b))
|
|
||||||
* Implemented dolphin integration ([a69147a](https://github.com/simeonradivoev/gameflow-deck/commit/a69147a4f73cf626b92622a8ee22b54f538d41a9))
|
|
||||||
* Implemented emulator launching ([09b8b9c](https://github.com/simeonradivoev/gameflow-deck/commit/09b8b9c6f850cea3b897308925faf9be02cefa1a)), closes [#1](https://github.com/simeonradivoev/gameflow-deck/issues/1)
|
|
||||||
* Implemented emulator versions and updating ([34db717](https://github.com/simeonradivoev/gameflow-deck/commit/34db717ec5cbcf8b1ae54fbda33bf9a78f01bd17))
|
|
||||||
* Implemented filtering and searching ([444d8c4](https://github.com/simeonradivoev/gameflow-deck/commit/444d8c4c278c6032b37f44a884cb6d7bf0b54c85))
|
|
||||||
* implemented haptics ([54dd925](https://github.com/simeonradivoev/gameflow-deck/commit/54dd9256e361877d0950a84061d9402616706352))
|
|
||||||
* Implemented romm saves for dolphin and xenia ([7948bd2](https://github.com/simeonradivoev/gameflow-deck/commit/7948bd24fabfc01b7be358f06fcd58c8795826c7))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* Fixed a bunch of issues on linux ([6aacec2](https://github.com/simeonradivoev/gameflow-deck/commit/6aacec2c0de253a71599e261e07aff53055cdb1e))
|
|
||||||
* Fixed emulator details buttons not showing ([04d5856](https://github.com/simeonradivoev/gameflow-deck/commit/04d5856f7d71c944c82877d2a1457facea4b6d31))
|
|
||||||
* Fixed tests ([c09fbd3](https://github.com/simeonradivoev/gameflow-deck/commit/c09fbd3dc88891227eda2b9f3bd9ac45621c00ea))
|
|
||||||
* logins now refresh on plugins load ([7bd0ebd](https://github.com/simeonradivoev/gameflow-deck/commit/7bd0ebdcca1843076911547ec1098cbaae9e2414))
|
|
||||||
* Made self update work on windows ([ae196e1](https://github.com/simeonradivoev/gameflow-deck/commit/ae196e11d616b9813dba11f64e7c844077686db8))
|
|
||||||
* Made store downloads extract in their own folder ([764691f](https://github.com/simeonradivoev/gameflow-deck/commit/764691fc8610fafebc93a69ca24f74bcac42a898))
|
|
||||||
|
|
||||||
## [1.3.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.2.1...v1.3.0) (2026-03-31)
|
## [1.3.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.2.1...v1.3.0) (2026-03-31)
|
||||||
|
|
||||||
|
|
|
||||||
72
README.md
72
README.md
|
|
@ -4,78 +4,50 @@ A Cross-Platform open source Retro gaming frontend designed for handheld and con
|
||||||
Focused on building a simple user experience and intuitive UI as a curated community driven experience.
|
Focused on building a simple user experience and intuitive UI as a curated community driven experience.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> This app is actively in development, it is constantly changing and improving.
|
> This app is actively in development, it doesn't have most of its major features implemented yet.
|
||||||
> It will have an opinionated design and will be used as an experiment in discovering a good UX.
|
> It will have an opinionated design and will be used as an experiment in discovering a good UX.
|
||||||
|
|
||||||
## Community
|
|
||||||
|
|
||||||
Join us on Discord, where you can ask questions, submit ideas and get help.
|
|
||||||
|
|
||||||
[](https://discord.gg/R9KakhY67d)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Integrations
|
### Integrations
|
||||||
|
|
||||||
- **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms.
|
- **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms.
|
||||||
- Show Achievements and sync playtime.
|
|
||||||
- Experimental save syncing
|
|
||||||
- **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores.
|
- **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores.
|
||||||
- **[RClone](https://github.com/rclone/rclone)** - sync saves between devices or cloud. Some Emulators and store games support it.
|
|
||||||
- **[UMU](https://github.com/Open-Wine-Components/umu-launcher)** - UMU Launcher for playing windows games on linux without needing steam. (Only used for store games for now)
|
|
||||||
|
|
||||||
### Store
|
### Store
|
||||||
|
|
||||||
- **Emulators** - (WIP) Download and install emulators and automatically configure them from a list of supported in the store. Some even come with advanced features like cloud saves.
|
- **Emulators** - (WIP) Download and install emulators and automatically configure them
|
||||||
- **Free Curated Games** - Download free curated games and homebrew roms without ever leaving the app
|
- **Free Curated Games** - Download free curreted games and homebrew roms without ever leaving the app
|
||||||
|
|
||||||
### Others
|
### Others
|
||||||
|
|
||||||
- **Cross Platform** - Can run on multiple platforms. Built with web technologies and bun backend.
|
- **Cross Platform** - Can run on multiple platforms. Built with web technologies and bun backend.
|
||||||
- **Steam Deck Support** - Extensively tested with the steam deck. It can use flatpak installed browsers.
|
- **Steam Deck Support** - Extensively tested with the steam deck. It can use flatpak installed browsers.
|
||||||
- **Lightweight** - It uses the window's webview as a frontend, reducing build size and ram usage.
|
- **Lightweight** - It uses the existing system browser to launch the front end, so no need to include a whole web browser.
|
||||||
- On Windows it first uses webview2 then your browser
|
- On Windows it first uses webview2 then your browser
|
||||||
- On linux it does ship with NW.js to work on most distros. A big one is the steam deck missing WebKitGTK.
|
- On linux it uses WebKitGTK or a browser even from flatpak
|
||||||
- Not tested on Mac yet
|
- Not tested on Mac yet
|
||||||
- **Great for Controllers** - The UI is inspired by the switch and works great with joysticks and dpads.
|
- **Great for Controllers** - The UI is inspired by the switch and works great with joysticks and dpads.
|
||||||
- **Automatic Downloads** - Downloads roms from ROMM automatically
|
- **Automatic Downloads** - Downloads roms from ROMM automatically
|
||||||
- **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch roms. You can bring your existing configurations.
|
- **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch games.
|
||||||
- Easy fallback configuration with built in file browser.
|
- Easy fallback configuration with built in file browser.
|
||||||
- **Responsive Layout** - Optimized mainly for the steam deck with responsive layout support and dynamic switching of inputs.
|
- **Responsive Layout** - Optimized mainly for the steam deck with responsive layout support and dynamic switching of inputs.
|
||||||
- **Cloud/Device Save Sync** - For supported games and emulators.
|
|
||||||
- **Dark and Light** - Dark and light themes for your preference.
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<img src=".github/screenshots/3d screenshot.png" title="Home Screen Showing games sorted by latest activity" width="25%"></img>
|
<img src=".github/screenshots/Pkazk0RufB.png" width="25%"></img>
|
||||||
<img src=".github/screenshots/3nhuKCK6E3.png" title="Game Details." width="25%"></img>
|
<img src=".github/screenshots/3nhuKCK6E3.jpg" width="25%"></img>
|
||||||
<img src=".github/screenshots/yObFD2LySH.jpg" title="Home Screen in dark mode" width="25%"></img>
|
<img src=".github/screenshots/yObFD2LySH.jpg" width="25%"></img>
|
||||||
<img src=".github/screenshots/GL7SkQbHIY.png" title="Plugins Page" width="25%"></img>
|
<img src=".github/screenshots/GL7SkQbHIY.png" width="25%"></img>
|
||||||
<img src=".github/screenshots/CpBLzTNM6N.png" title="Store Home Page" width="25%"></img>
|
<img src=".github/screenshots/CpBLzTNM6N.png" width="25%"></img>
|
||||||
<img src=".github/screenshots/xNj7scPEDQ.png" title="Store emulator details" width="25%"></img>
|
<img src=".github/screenshots/xNj7scPEDQ.png" width="25%"></img>
|
||||||
<img src=".github/screenshots/zEQxtzhPGx.png" title="Store Emulators in dark mode" width="25%"></img>
|
|
||||||
<img src=".github/screenshots/MMeJxl4IXr.png" title="Store Emulators in light mode" width="25%"></img>
|
|
||||||
<img src=".github/screenshots/EWPHmIBEE5.png" title="Platform Grouping List" width="25%"></img>
|
|
||||||
<img src=".github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif" title="Platform Grouping List" width="76%"></img>
|
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
- I want to build an open and free platform where you can play and discover new hidden gems from the past.
|
- I want to build an open and free platform where you can play and discover new hidden gems from the past.
|
||||||
- I plan to add a free store where you can download all your needed emulators, the goal is to not have to leave the UI for anything.
|
- I plan to add a free store where you can download all your needed emulators, the goal is to not have to leave the UI for anything.
|
||||||
- I really want to add matrix chat support in the app for engaging with your favorite community. Having access to so many nodejs libraries would make it quite straight forward.
|
- I really want to add matrix chat support in the app for engaging with your favorite community. Having access to so many nodejs libraries would make it quite straight forward.
|
||||||
- I'm sick of closed source and private store fronts, and want a way to share community curated free experiences. I'm also sick of the profit driven nature of games and promotions.
|
- I'm sick of closed source and private store fronts, and want a way to share community currated free experiences. I'm also sick of the profit driven nature of games and promotions.
|
||||||
- Being self contained, I want to avoid writing as little as possible to system and contain and manage settings in a custom changeable directory. This was mainly a side-effect of having the low storage steam deck and always running out of space on my internal hard drive.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
There are currently 2 ways of getting games. One is logging in through romm and importing your games from there. The other is the store (it's a bit limited right now). I might add local import of roms since IGDB login is already implemented.
|
|
||||||
|
|
||||||
The app created a default folder in your home folder. You can move it. It stores everything there. From downloaded roms, emulators and configs.
|
|
||||||
|
|
||||||
## Existing Setups
|
|
||||||
|
|
||||||
The game should work pretty well with existing emulators one has installed. It uses the ES-DE config to find installed emulators. Only downside is more advanced integrations won't work, as they are mainly used for store emulators where the app has more control over, plus I don't want to mess up existing setups.
|
|
||||||
But given it's an existing setup, say from emudeck it won't matter much as it's already configured say for the steam deck.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|
@ -106,17 +78,6 @@ But given it's an existing setup, say from emudeck it won't matter much as it's
|
||||||
- `bun run openapi-ts` generated the openapi client calls from romm's API
|
- `bun run openapi-ts` generated the openapi client calls from romm's API
|
||||||
- `bun run package:windows` builds an package to be distributed on windows
|
- `bun run package:windows` builds an package to be distributed on windows
|
||||||
- `bun run package:linux` builds an AppImage to be distributed on linux
|
- `bun run package:linux` builds an AppImage to be distributed on linux
|
||||||
- `bun run test` run tests
|
|
||||||
- `bun run download:chromium` downloads degoogled chromium to use as the frontend
|
|
||||||
- `bun run download:nwjs` downloads NW.js to use as a frontend.
|
|
||||||
|
|
||||||
## Plugins
|
|
||||||
|
|
||||||
To create a plugin create a new npm project and install:
|
|
||||||
`bun i --peer @simeonradivoev/gameflow-sdk`
|
|
||||||
|
|
||||||
Then publish the package to npmjs with a tag `gameflow-plugin` to appear in the UI.
|
|
||||||
For more info check the [SDK README](./scripts/sdk/README.md)
|
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
|
|
@ -129,10 +90,3 @@ For more info check the [SDK README](./scripts/sdk/README.md)
|
||||||
- [elysia](https://elysiajs.com/) for the APIs
|
- [elysia](https://elysiajs.com/) for the APIs
|
||||||
- [webview](https://github.com/webview/webview) for launching existing system webviews instead of full browser if possible.
|
- [webview](https://github.com/webview/webview) for launching existing system webviews instead of full browser if possible.
|
||||||
- [emulatorjs](https://emulatorjs.org/) for playing lots of roms inside the app without having to deal with external emulators
|
- [emulatorjs](https://emulatorjs.org/) for playing lots of roms inside the app without having to deal with external emulators
|
||||||
|
|
||||||
### Credits
|
|
||||||
|
|
||||||
- UI Sounds
|
|
||||||
- [CC BY 4.0 - Credit: JC Sounds](https://opengameart.org/content/jc-sounds-ui-utility-pack-vol-1)
|
|
||||||
- [Sounds by: Chhoff](https://chhoffmusic.itch.io/classic-ui-sfx)
|
|
||||||
- [UI Sound Effects by lolurio](https://lolurio.itch.io/lolurios-free-cozy-ui-sfx)
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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`);
|
|
||||||
|
|
@ -1,479 +0,0 @@
|
||||||
{
|
|
||||||
"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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -15,13 +15,6 @@
|
||||||
"when": 1772998956867,
|
"when": 1772998956867,
|
||||||
"tag": "0001_outstanding_silk_fever",
|
"tag": "0001_outstanding_silk_fever",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 2,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1776111721964,
|
|
||||||
"tag": "0002_flowery_rocket_raccoon",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
126
package.json
126
package.json
|
|
@ -1,26 +1,17 @@
|
||||||
{
|
{
|
||||||
"name": "com.simeonradivoev.gameflow-deck",
|
"name": "com.simeonradivoev.gameflow-deck",
|
||||||
"displayName": "Gameflow",
|
"displayName": "Gameflow",
|
||||||
"author": {
|
"version": "1.3.0",
|
||||||
"name": "Simeon Radivoev",
|
|
||||||
"email": "work@simeonradivoev.com",
|
|
||||||
"url": "https://simeonradivoev.com"
|
|
||||||
},
|
|
||||||
"version": "1.6.0",
|
|
||||||
"description": "Game Launcher",
|
"description": "Game Launcher",
|
||||||
"icon": "./src/mainview/assets/icon.svg",
|
"icon": "./src/mainview/assets/icon.svg",
|
||||||
"main": "./src/bun/index.ts",
|
"main": "./src/bun/index.ts",
|
||||||
"bin": "gameflow",
|
"bin": "gameflow",
|
||||||
"license": "AGPL-3.0",
|
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/simeonradivoev/gameflow-deck"
|
"url": "https://github.com/simeonradivoev/gameflow-deck"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.3.9",
|
"packageManager": "bun@1.3.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
|
||||||
"./src/packages/gameflow-sdk"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'",
|
"dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'",
|
||||||
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
|
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
|
||||||
|
|
@ -30,8 +21,8 @@
|
||||||
"build:prod:vite": "NODE_ENV=production bun run build:vite",
|
"build:prod:vite": "NODE_ENV=production bun run build:vite",
|
||||||
"build:dev:vite": "NODE_ENV=development bun run build:vite",
|
"build:dev:vite": "NODE_ENV=development bun run build:vite",
|
||||||
"build": "bun run build:vite && bun run ./scripts/package-bun.ts",
|
"build": "bun run build:vite && bun run ./scripts/package-bun.ts",
|
||||||
"build:non-compiled": "bun run build:vite && NON_COMPILED=true bun run ./scripts/package-bun.ts",
|
|
||||||
"build:prod": "NODE_ENV=production bun run build",
|
"build:prod": "NODE_ENV=production bun run build",
|
||||||
|
"build:prod:dynamic": "NODE_ENV=production NON_COMPILED=true bun run build",
|
||||||
"build:linux": "TARGET=bun-linux-x64 bun run build",
|
"build:linux": "TARGET=bun-linux-x64 bun run build",
|
||||||
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
|
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
|
||||||
"run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml",
|
"run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml",
|
||||||
|
|
@ -42,121 +33,100 @@
|
||||||
"flatpak:generate-sources": "bun run ./scripts/generate-flatpak-sources.ts",
|
"flatpak:generate-sources": "bun run ./scripts/generate-flatpak-sources.ts",
|
||||||
"flatpak:override": "flatpak override org.flatpak.Builder --filesystem=host --device=all",
|
"flatpak:override": "flatpak override org.flatpak.Builder --filesystem=host --device=all",
|
||||||
"flatpak:restore": "flatpak override --reset --user org.flatpak.Builder",
|
"flatpak:restore": "flatpak override --reset --user org.flatpak.Builder",
|
||||||
"flatpak:build": "FLATPAK_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:build": "flatpak run org.flatpak.Builder build/flatpak flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --force-clean",
|
||||||
"flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck",
|
"flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck",
|
||||||
"build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts",
|
"build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts",
|
||||||
"build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts",
|
"build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts",
|
||||||
"version:generate": "commit-and-tag-version --sign",
|
"version:generate": "standard-version --sign",
|
||||||
"package:Linux": "bun run build:prod:appimage",
|
"package:Linux": "bun run build:prod:appimage",
|
||||||
"package:Windows": "bun run build:prod",
|
"package:Windows": "bun run build:prod",
|
||||||
"download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium",
|
"download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium"
|
||||||
"download:nwjs": "bun scripts/download-nw.ts",
|
|
||||||
"build:audiosprites": "bun ./scripts/generate-audio-sprites.ts",
|
|
||||||
"tsc": "tsc --noEmit",
|
|
||||||
"publish:sdk": "bun publish --cwd ./src/packages/gameflow-sdk/ --access public"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"@auth/core": "^0.34.3",
|
"@auth/core": "^0.34.3",
|
||||||
"@elysiajs/cors": "^1.4.2",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
"@elysiajs/eden": "^1.4.9",
|
"@elysiajs/eden": "^1.4.6",
|
||||||
"@jimp/wasm-webp": "^1.6.1",
|
"@jimp/wasm-webp": "^1.6.0",
|
||||||
"@phalcode/ts-igdb-client": "^1.0.26",
|
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"conf": "^15.1.0",
|
"conf": "^15.0.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.1",
|
||||||
"elysia": "^1.4.28",
|
"elysia": "^1.4.22",
|
||||||
"fs-extra": "^11.3.5",
|
"fs-extra": "^11.3.3",
|
||||||
"get-folder-size": "^5.0.0",
|
"get-folder-size": "^5.0.0",
|
||||||
"ini": "^6.0.0",
|
"ini": "^6.0.0",
|
||||||
"jimp": "^1.6.1",
|
"jimp": "^1.6.0",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"node-7z": "^3.0.0",
|
"node-7z": "^3.0.0",
|
||||||
"node-disk-info": "^1.3.0",
|
"node-disk-info": "^1.3.0",
|
||||||
"node-downloader-helper": "^2.1.11",
|
"node-downloader-helper": "^2.1.10",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"node-unrar-js": "^2.0.2",
|
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
"p-queue": "^9.2.0",
|
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"slugify": "^1.6.9",
|
"systeminformation": "^5.31.5",
|
||||||
"smol-toml": "^1.6.1",
|
"tapable": "^2.3.0",
|
||||||
"systeminformation": "^5.31.6",
|
"tough-cookie": "^6.0.0",
|
||||||
"tapable": "^2.3.3",
|
|
||||||
"tough-cookie": "^6.0.1",
|
|
||||||
"tough-cookie-file-store": "^3.3.0",
|
"tough-cookie-file-store": "^3.3.0",
|
||||||
|
"ts-igdb-client": "^0.4.2",
|
||||||
"unzip-stream": "^0.3.4",
|
"unzip-stream": "^0.3.4",
|
||||||
"webview-bun": "^2.4.0",
|
"webview-bun": "^2.4.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.3.6"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@tanstack/router-generator": {
|
|
||||||
"zod": "^3.23.8"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ap0nia/eden": "^1.6.1",
|
"@ap0nia/eden": "^1.0.0-next.22",
|
||||||
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
|
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
|
||||||
"@emulatorjs/emulatorjs": "^4.2.3",
|
"@emulatorjs/emulatorjs": "^4.2.3",
|
||||||
"@hey-api/openapi-ts": "^0.91.1",
|
"@hey-api/openapi-ts": "^0.91.0",
|
||||||
"@noriginmedia/norigin-spatial-navigation": "^3.1.0",
|
"@noriginmedia/norigin-spatial-navigation": "^2.3.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tanstack/react-form": "^1.28.0",
|
||||||
"@tanstack/react-form": "^1.32.0",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@tanstack/react-query-devtools": "^5.100.10",
|
"@tanstack/react-router": "^1.157.16",
|
||||||
"@tanstack/react-query-persist-client": "^5.100.10",
|
"@tanstack/react-router-devtools": "^1.154.12",
|
||||||
"@tanstack/react-router": "^1.169.2",
|
"@tanstack/react-router-ssr-query": "^1.157.17",
|
||||||
"@tanstack/react-router-devtools": "^1.166.13",
|
"@tanstack/router-plugin": "^1.157.16",
|
||||||
"@tanstack/react-router-ssr-query": "^1.166.12",
|
"@tanstack/zod-adapter": "^1.162.4",
|
||||||
"@tanstack/router-plugin": "^1.167.35",
|
|
||||||
"@tanstack/zod-adapter": "^1.166.9",
|
|
||||||
"@types/adm-zip": "^0.5.8",
|
"@types/adm-zip": "^0.5.8",
|
||||||
"@types/audiosprite": "^0.7.3",
|
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/howler": "^2.2.12",
|
"@types/howler": "^2.2.12",
|
||||||
"@types/ini": "^4.1.1",
|
"@types/ini": "^4.1.1",
|
||||||
"@types/json-schema": "^7.0.15",
|
|
||||||
"@types/mustache": "^4.2.6",
|
"@types/mustache": "^4.2.6",
|
||||||
"@types/node-7z": "^2.1.11",
|
"@types/node-7z": "^2.1.11",
|
||||||
"@types/rclone.js": "^0.6.3",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/unzip-stream": "^0.3.4",
|
"@types/unzip-stream": "^0.3.4",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.16",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"app-builder-bin": "^5.0.0-alpha.13",
|
"app-builder-bin": "^5.0.0-alpha.13",
|
||||||
"audiosprite": "^0.7.2",
|
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"commit-and-tag-version": "^12.7.3",
|
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"daisyui": "^5.5.19",
|
"daisyui": "^5.5.14",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.9",
|
||||||
|
"dts-bundle-generator": "^9.5.1",
|
||||||
"eden-tanstack-query": "^0.0.9",
|
"eden-tanstack-query": "^0.0.9",
|
||||||
"howler": "^2.2.4",
|
"howler": "^2.2.4",
|
||||||
"idb-keyval": "^6.2.2",
|
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"pretty-bytes": "^7.1.0",
|
"pretty-bytes": "^7.1.0",
|
||||||
"pretty-ms": "^9.3.0",
|
"react": "^19.2.4",
|
||||||
"react": "^19.2.6",
|
"react-dom": "^19.2.4",
|
||||||
"react-dom": "^19.2.6",
|
"react-error-boundary": "^6.1.0",
|
||||||
"react-error-boundary": "^6.1.1",
|
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-qr-code": "^2.0.18",
|
||||||
"react-qr-code": "^2.0.21",
|
"sass-embedded": "^1.97.3",
|
||||||
"sass-embedded": "^1.99.0",
|
"standard-version": "^9.5.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"vite": "^7.3.3",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-svg-icons-ng": "^1.9.1",
|
"vite-plugin-svg-icons-ng": "^1.5.2",
|
||||||
"vite-static-assets-plugin": "^1.2.2",
|
"vite-static-assets-plugin": "^1.2.2",
|
||||||
"vite-tsconfig-paths": "^6.1.1"
|
"vite-tsconfig-paths": "^6.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import fs from 'node:fs/promises';
|
||||||
import { appBuilderPath, } from 'app-builder-bin';
|
import { appBuilderPath, } from 'app-builder-bin';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import mustache from "mustache";
|
|
||||||
|
|
||||||
const APP_DIR = process.env.BUILD_DIR ?? `./build/${process.platform}`;
|
const APP_DIR = process.env.BUILD_DIR ?? `./build/${process.platform}`;
|
||||||
const BINARY_NAME = pkg.bin;
|
const BINARY_NAME = pkg.bin;
|
||||||
const ICON = "./src/mainview/public/256x256.png";
|
const ICON = "./src/mainview/public/256x256.png";
|
||||||
|
const DESKTOP = "./flatpak/com.simeonradivoev.gameflow-deck.desktop";
|
||||||
const TMP_FOLDER = ".";
|
const TMP_FOLDER = ".";
|
||||||
|
|
||||||
const APP_NAME = pkg.displayName ?? pkg.name;
|
const APP_NAME = pkg.displayName ?? pkg.name;
|
||||||
|
|
@ -27,45 +27,24 @@ await fs.rename(path.join(APPDIR, `usr`, 'share', BINARY_NAME), path.join(APPDIR
|
||||||
await fs.rename(path.join(APPDIR, `usr`, 'share', `libwebview-${process.arch}.so`), path.join(APPDIR, `usr`, 'lib', `libwebview-${process.arch}.so`));
|
await fs.rename(path.join(APPDIR, `usr`, 'share', `libwebview-${process.arch}.so`), path.join(APPDIR, `usr`, 'lib', `libwebview-${process.arch}.so`));
|
||||||
await fs.rename(path.join(APPDIR, `usr`, 'share', `7za`), path.join(APPDIR, `usr`, 'bin', `7za`));
|
await fs.rename(path.join(APPDIR, `usr`, 'share', `7za`), path.join(APPDIR, `usr`, 'bin', `7za`));
|
||||||
|
|
||||||
if (!await fs.exists('./bin/nw/nw'))
|
await fs.writeFile(path.join(APPDIR, `${APP_ID}.desktop`), `[Desktop Entry]
|
||||||
{
|
Version=${pkg.version}
|
||||||
await import('./download-nw');
|
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;
|
||||||
|
`);
|
||||||
|
|
||||||
await ensureDir(path.join(APPDIR, `usr`, 'lib', 'nw'));
|
await Bun.write(path.join(APPDIR, "AppRun"), `#!/bin/bash
|
||||||
await fs.cp('./bin/nw', path.join(APPDIR, `usr`, 'lib', 'nw'), { recursive: true });
|
APPDIR="$(dirname "$(readlink -f "$0")")"
|
||||||
await fs.symlink(path.join(APPDIR, `usr`, 'lib', 'nw', 'nw'), path.join(APPDIR, `usr`, `bin`, 'nw'));
|
APPIMAGE=true
|
||||||
|
exec "$APPDIR/usr/bin/${BINARY_NAME}" "$@"
|
||||||
const templateVars = {
|
`);
|
||||||
APP_NAME,
|
|
||||||
VERSION: pkg.version,
|
|
||||||
ARCH: process.arch,
|
|
||||||
DESCRIPTION: pkg.description,
|
|
||||||
APP_ID,
|
|
||||||
BINARY_NAME,
|
|
||||||
LICENSE: pkg.license
|
|
||||||
};
|
|
||||||
|
|
||||||
const desktopFileTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.desktop', 'utf8');
|
|
||||||
|
|
||||||
const raw = await $`git tag --sort=-version:refname`.text().then(d => d.trim());
|
|
||||||
const tags = raw.split('\n').filter(t => t.match(/^\d+\.\d+\.\d+$/));
|
|
||||||
console.log("tags", tags);
|
|
||||||
|
|
||||||
console.log(">>> Updating Release History...");
|
|
||||||
const releases = await Promise.all(tags.map(async tag =>
|
|
||||||
{
|
|
||||||
const date = await $`git log -1 --format=%as ${tag}`.text().then(d => d.trim());
|
|
||||||
const version = tag.replace(/^v/, '');
|
|
||||||
return ` <release version="${version}" date="${date}"/>`;
|
|
||||||
}));
|
|
||||||
|
|
||||||
const appStreamTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml', 'utf8');
|
|
||||||
await ensureDir(path.join(APPDIR, 'usr', 'share', 'metainfo'));
|
|
||||||
await fs.writeFile(path.join(APPDIR, 'usr', 'share', 'metainfo', `${APP_ID}.appdata.xml`), mustache.render(appStreamTemplate, { ...templateVars, RELEASES: releases }));
|
|
||||||
|
|
||||||
const appRunTemplate = await fs.readFile(`./.config/appimage/AppRun`, 'utf8');
|
|
||||||
await Bun.write(path.join(APPDIR, "AppRun"), mustache.render(appRunTemplate, templateVars));
|
|
||||||
await $`chmod +x ${APPDIR}/AppRun`;
|
await $`chmod +x ${APPDIR}/AppRun`;
|
||||||
|
|
||||||
console.log(">>> Building AppImage...");
|
console.log(">>> Building AppImage...");
|
||||||
|
|
@ -73,7 +52,7 @@ const config = {
|
||||||
productName: pkg.displayName,
|
productName: pkg.displayName,
|
||||||
productFilename: pkg.name,
|
productFilename: pkg.name,
|
||||||
executableName: BINARY_NAME,
|
executableName: BINARY_NAME,
|
||||||
desktopEntry: mustache.render(desktopFileTemplate, templateVars),
|
desktopEntry: DESKTOP,
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
file: ICON,
|
file: ICON,
|
||||||
|
|
@ -88,7 +67,7 @@ const config = {
|
||||||
// Remove the build dir, mainly to help with CIs
|
// Remove the build dir, mainly to help with CIs
|
||||||
await fs.rm(APP_DIR, { recursive: true });
|
await fs.rm(APP_DIR, { recursive: true });
|
||||||
await ensureDir(APP_DIR);
|
await ensureDir(APP_DIR);
|
||||||
const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}-${process.platform}-${process.arch}.AppImage`);
|
const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}.AppImage`);
|
||||||
const STAGE = path.resolve(TMP_FOLDER, `${APP_ID}.stage`);
|
const STAGE = path.resolve(TMP_FOLDER, `${APP_ID}.stage`);
|
||||||
|
|
||||||
await ensureDir(STAGE);
|
await ensureDir(STAGE);
|
||||||
|
|
@ -107,9 +86,8 @@ const proc = Bun.spawn([
|
||||||
});
|
});
|
||||||
|
|
||||||
const code = await proc.exited;
|
const code = await proc.exited;
|
||||||
await fs.rm(STAGE, { recursive: true, force: true });
|
|
||||||
await fs.rm(APPDIR, { recursive: true, force: true });
|
await fs.rm(APPDIR, { recursive: true, force: true });
|
||||||
|
await fs.rm(STAGE, { recursive: true, force: true });
|
||||||
if (code !== 0) process.exit(code);
|
if (code !== 0) process.exit(code);
|
||||||
|
|
||||||
console.log(`\n Done!`);
|
console.log(`\n Done!`);
|
||||||
|
|
@ -2,44 +2,50 @@ import EventEmitter from "events";
|
||||||
import browser from '../src/bun/browser';
|
import browser from '../src/bun/browser';
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { watch } from "fs";
|
import { createInterface } from "readline";
|
||||||
import { sleep } from "bun";
|
import { Readable } from "stream";
|
||||||
const events = new EventEmitter();
|
const events = new EventEmitter();
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
let restarting = false;
|
|
||||||
|
|
||||||
process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222";
|
process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222";
|
||||||
process.env.NODE_ENV = "development";
|
process.env.NODE_ENV = "development";
|
||||||
|
|
||||||
|
let retries = 0;
|
||||||
|
|
||||||
function spawnServer ()
|
function spawnServer ()
|
||||||
{
|
{
|
||||||
const s = Bun.spawn(["bun", '--install=fallback', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
|
const s = Bun.spawn(["bun", '--watch', '--install=fallback', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
HEADLESS: "true",
|
HEADLESS: "true",
|
||||||
},
|
},
|
||||||
stdout: 'inherit',
|
stdout: "pipe",
|
||||||
stderr: 'inherit',
|
stderr: "inherit",
|
||||||
stdin: 'inherit',
|
stdin: "pipe",
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
killSignal: 'SIGKILL',
|
killSignal: 'SIGUSR1',
|
||||||
ipc (message, subprocess, handle)
|
|
||||||
{
|
|
||||||
if (message === 'focus')
|
|
||||||
{
|
|
||||||
events.emit('focus');
|
|
||||||
} else if (message === 'exitapp')
|
|
||||||
{
|
|
||||||
events.emit('exitapp');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onExit (subprocess, exitCode, signalCode)
|
onExit (subprocess, exitCode, signalCode)
|
||||||
{
|
{
|
||||||
if (!restarting)
|
if (exitCode === 1 && retries <= 3)
|
||||||
|
{
|
||||||
|
server = spawnServer();
|
||||||
|
retries++;
|
||||||
|
} else
|
||||||
{
|
{
|
||||||
console.log("Existing Dev With", exitCode);
|
|
||||||
process.exit();
|
process.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const rl = createInterface({ input: Readable.fromWeb(s.stdout as any) });
|
||||||
|
rl.on('line', e =>
|
||||||
|
{
|
||||||
|
if (e === 'focus')
|
||||||
|
{
|
||||||
|
events.emit('focus');
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
console.log(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return s;
|
return s;
|
||||||
|
|
@ -50,10 +56,9 @@ function spawnBrowser ()
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
||||||
return browser(events, {
|
return browser(events, process.env.FORCE_BROWSER === "true", {
|
||||||
configPath: path.join(tmpdir(), 'gameflow'),
|
configPath: path.join(tmpdir(), 'gameflow'),
|
||||||
isSteamDeckGameMode: false,
|
isSteamDeckGameMode: false
|
||||||
forceBrowser: process.env.FORCE_BROWSER === "true"
|
|
||||||
});
|
});
|
||||||
} catch (error)
|
} catch (error)
|
||||||
{
|
{
|
||||||
|
|
@ -61,44 +66,13 @@ function spawnBrowser ()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restart ()
|
let server = spawnServer();
|
||||||
{
|
|
||||||
if (server)
|
|
||||||
{
|
|
||||||
restarting = true;
|
|
||||||
server.kill();
|
|
||||||
await server.exited;
|
|
||||||
server = undefined;
|
|
||||||
console.log("Old Server stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
server = spawnServer();
|
|
||||||
await sleep(1000);
|
|
||||||
console.log("New Server started");
|
|
||||||
restarting = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
watch("./src/bun", { recursive: true }, (event, filename) =>
|
|
||||||
{
|
|
||||||
if (restarting) return;
|
|
||||||
console.log(`[watcher] ${event}: ${filename} — restarting...`);
|
|
||||||
restart();
|
|
||||||
});
|
|
||||||
|
|
||||||
watch("./src/packages", { recursive: true }, (event, filename) =>
|
|
||||||
{
|
|
||||||
if (restarting) return;
|
|
||||||
console.log(`[watcher] ${event}: ${filename} — restarting...`);
|
|
||||||
restart();
|
|
||||||
});
|
|
||||||
|
|
||||||
let server: Bun.Subprocess | undefined = spawnServer();
|
|
||||||
if (!process.env.HEADLESS)
|
if (!process.env.HEADLESS)
|
||||||
{
|
{
|
||||||
spawnBrowser()?.then(async e =>
|
spawnBrowser()?.then(async e =>
|
||||||
{
|
{
|
||||||
if (!server) return;
|
console.log("Sending exit Signal to server");
|
||||||
abortController.abort();
|
await server.stdin.write('shutdown\n');
|
||||||
await server.exited;
|
await server.stdin.flush();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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`);
|
|
||||||
|
|
@ -1,234 +0,0 @@
|
||||||
{
|
|
||||||
"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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "sqlite",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"idx": 0,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1776039605377,
|
|
||||||
"tag": "0000_sparkling_banshee",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -96,18 +96,12 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
||||||
});
|
});
|
||||||
|
|
||||||
const rommMapping = rommPlatforms.data?.find(p =>
|
const rommMapping = rommPlatforms.data?.find(p =>
|
||||||
{
|
p.slug === (customMappings as any)[name] ||
|
||||||
const custom = (customMappings as any)[name];
|
p.slug === name ||
|
||||||
if (Array.isArray(custom) && custom.some(m => m === p.slug))
|
p.igdb_slug === name ||
|
||||||
{
|
p.hltb_slug === name ||
|
||||||
return true;
|
p.moby_slug === name ||
|
||||||
}
|
p.display_name === fullname
|
||||||
|
|
||||||
return p.slug === custom ||
|
|
||||||
p.slug === name ||
|
|
||||||
p.igdb_slug === name ||
|
|
||||||
p.display_name === fullname;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const mappings: {
|
const mappings: {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
|
|
||||||
|
const lockfile = Bun.argv[2] ?? "bun.lockb";
|
||||||
const output = Bun.argv[3] ?? ".config/flatpak/sources.gen.json";
|
const output = Bun.argv[3] ?? ".config/flatpak/sources.gen.json";
|
||||||
|
|
||||||
const text = await $`bun ./bun.lockb --hash: 0000000000000000-0000000000000000-0000000000000000-0000000000000000`.text();
|
const text = await $`bun ./bun.lockb --hash: 0000000000000000-0000000000000000-0000000000000000-0000000000000000`.text();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
import { TaskQueue, AppEventMap } from "@simeonradivoev/gameflow-sdk";
|
import { TaskQueue } from "./task-queue";
|
||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import { CookieJar } from 'tough-cookie';
|
import { CookieJar } from 'tough-cookie';
|
||||||
import FileCookieStore from 'tough-cookie-file-store';
|
import FileCookieStore from 'tough-cookie-file-store';
|
||||||
|
|
@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import Conf from "conf";
|
import Conf from "conf";
|
||||||
import projectPackage from '~/package.json';
|
import projectPackage from '~/package.json';
|
||||||
import { SettingsType, SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
import { SettingsSchema, SettingsType } from "@shared/constants";
|
||||||
import { client } from "@clients/romm/client.gen";
|
import { client } from "@clients/romm/client.gen";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import cacheSchema from "@schema/cache";
|
import cacheSchema from "@schema/cache";
|
||||||
|
|
@ -18,12 +18,13 @@ import EventEmitter from "node:events";
|
||||||
import { appPath } from "../utils";
|
import { appPath } from "../utils";
|
||||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
|
import UpdateStoreJob from "./jobs/update-store";
|
||||||
|
import { getStoreFolder } from "./store/services/gamesService";
|
||||||
import { PluginManager } from "./plugins/plugin-manager";
|
import { PluginManager } from "./plugins/plugin-manager";
|
||||||
import registerPlugins from "./plugins/register-plugins";
|
import registerPlugins from "./plugins/register-plugins";
|
||||||
import controls from './controls/controls';
|
import controls from './controls/controls';
|
||||||
import { RunAPIServer } from "./rpc";
|
import { RunAPIServer } from "./rpc";
|
||||||
import { RunBunServer } from "../server";
|
import { RunBunServer } from "../server";
|
||||||
import ReloadPluginsJob from "./jobs/reload-plugins-job";
|
|
||||||
|
|
||||||
export let config: Conf<SettingsType>;
|
export let config: Conf<SettingsType>;
|
||||||
export let customEmulators: Conf<Record<string, string>>;
|
export let customEmulators: Conf<Record<string, string>>;
|
||||||
|
|
@ -42,8 +43,6 @@ export let events: EventEmitter<AppEventMap>;
|
||||||
let controlsHandle: { cleanup: () => void; };
|
let controlsHandle: { cleanup: () => void; };
|
||||||
let api: { cleanup: () => Promise<void>; };
|
let api: { cleanup: () => Promise<void>; };
|
||||||
let bunServer: { cleanup: () => Promise<void>; } | undefined;
|
let bunServer: { cleanup: () => Promise<void>; } | undefined;
|
||||||
let cleannedUp = false;
|
|
||||||
let cleaningUp = false;
|
|
||||||
|
|
||||||
export async function load ()
|
export async function load ()
|
||||||
{
|
{
|
||||||
|
|
@ -57,7 +56,6 @@ export async function load ()
|
||||||
windowSize: { width: 1280, height: 800 }
|
windowSize: { width: 1280, height: 800 }
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
customEmulators = new Conf<Record<string, string>>({
|
customEmulators = new Conf<Record<string, string>>({
|
||||||
projectName: projectPackage.name,
|
projectName: projectPackage.name,
|
||||||
projectSuffix: 'bun',
|
projectSuffix: 'bun',
|
||||||
|
|
@ -74,7 +72,7 @@ export async function load ()
|
||||||
console.log("Config Path Located At: ", config.path);
|
console.log("Config Path Located At: ", config.path);
|
||||||
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
||||||
console.log("App Directory is ", process.env.APPDIR);
|
console.log("App Directory is ", process.env.APPDIR);
|
||||||
console.log("Cache Path is ", cachePath);
|
console.log("Store Directory is ", getStoreFolder());
|
||||||
|
|
||||||
cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
|
cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
|
||||||
fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
||||||
|
|
@ -86,21 +84,18 @@ export async function load ()
|
||||||
emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
||||||
await reloadDatabase();
|
await reloadDatabase();
|
||||||
plugins = new PluginManager();
|
plugins = new PluginManager();
|
||||||
api = await RunAPIServer();
|
|
||||||
await registerPlugins(plugins);
|
await registerPlugins(plugins);
|
||||||
taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
|
api = await RunAPIServer();
|
||||||
controlsHandle = await controls();
|
controlsHandle = await controls();
|
||||||
if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer();
|
if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer();
|
||||||
|
|
||||||
config.onDidChange('downloadPath', () => reloadDatabase());
|
config.onDidChange('downloadPath', () => reloadDatabase());
|
||||||
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
||||||
|
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanup ()
|
export async function cleanup ()
|
||||||
{
|
{
|
||||||
if (cleaningUp) throw new Error("Already Cleaning Up");
|
|
||||||
cleaningUp = true;
|
|
||||||
if (cleannedUp) throw new Error("Already Cleaned Up. Skipping");
|
|
||||||
console.log("Cleaning Up");
|
console.log("Cleaning Up");
|
||||||
await bunServer?.cleanup();
|
await bunServer?.cleanup();
|
||||||
await api.cleanup();
|
await api.cleanup();
|
||||||
|
|
@ -113,14 +108,6 @@ export async function cleanup ()
|
||||||
config._closeWatcher();
|
config._closeWatcher();
|
||||||
customEmulators._closeWatcher();
|
customEmulators._closeWatcher();
|
||||||
console.log("Finished Cleaning Up");
|
console.log("Finished Cleaning Up");
|
||||||
cleannedUp = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset the cleanup flags. This is mainly used by tests since they run the same app. */
|
|
||||||
export async function resetCleanup ()
|
|
||||||
{
|
|
||||||
cleaningUp = false;
|
|
||||||
cleannedUp = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reloadDatabase ()
|
export async function reloadDatabase ()
|
||||||
|
|
@ -133,7 +120,6 @@ export async function reloadDatabase ()
|
||||||
db = drizzle(sqlite, { schema });
|
db = drizzle(sqlite, { schema });
|
||||||
cache = drizzle(cacheSqlite, { schema: cacheSchema });
|
cache = drizzle(cacheSqlite, { schema: cacheSchema });
|
||||||
migrate(db!, { migrationsFolder: appPath("./drizzle") });
|
migrate(db!, { migrationsFolder: appPath("./drizzle") });
|
||||||
sqlite.run("PRAGMA foreign_keys = ON;");
|
|
||||||
await cache.run(`
|
await cache.run(`
|
||||||
CREATE TABLE IF NOT EXISTS item_cache (
|
CREATE TABLE IF NOT EXISTS item_cache (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { config, events, plugins, taskQueue } from "./app";
|
import { config, events, jar, plugins, taskQueue } from "./app";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { getCurrentUserApiUsersMeGet, tokenApiTokenPost, UserSchema } from "@clients/romm";
|
import { getCurrentUserApiUsersMeGet, tokenApiTokenPost, UserSchema } from "@clients/romm";
|
||||||
import secrets from '../api/secrets';
|
import secrets from '../api/secrets';
|
||||||
|
|
@ -46,7 +46,67 @@ export default new Elysia()
|
||||||
|
|
||||||
return status(res.status, res.statusText);
|
return status(res.status, res.statusText);
|
||||||
})
|
})
|
||||||
.get('/login/twitch', checkLoginAndRefreshTwitch)
|
.get('/login/twitch', async () =>
|
||||||
|
{
|
||||||
|
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
||||||
|
if (!access_token)
|
||||||
|
{
|
||||||
|
return status('Not Found', "Not Logged In");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } });
|
||||||
|
if (res.ok)
|
||||||
|
{
|
||||||
|
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.TWITCH_CLIENT_ID)
|
||||||
|
{
|
||||||
|
return status("Not Found", "Twitch Client ID not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" });
|
||||||
|
if (!refresh_token)
|
||||||
|
{
|
||||||
|
return status("Not Found", "Refresh Token Not Found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh token
|
||||||
|
const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({
|
||||||
|
client_id: process.env.TWITCH_CLIENT_ID,
|
||||||
|
access_token,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refreshResponse.ok)
|
||||||
|
{
|
||||||
|
const data: {
|
||||||
|
access_token: string,
|
||||||
|
refresh_token: string,
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
} = await refreshResponse.json();
|
||||||
|
|
||||||
|
await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
|
||||||
|
await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
|
||||||
|
await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
|
||||||
|
|
||||||
|
await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' });
|
||||||
|
|
||||||
|
events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' });
|
||||||
|
|
||||||
|
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } });
|
||||||
|
if (res.ok)
|
||||||
|
{
|
||||||
|
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status(400, res.statusText);
|
||||||
|
})
|
||||||
.post('/login/romm/qr', async () =>
|
.post('/login/romm/qr', async () =>
|
||||||
{
|
{
|
||||||
if (taskQueue.hasActiveOfType(LoginJob))
|
if (taskQueue.hasActiveOfType(LoginJob))
|
||||||
|
|
@ -63,7 +123,47 @@ export default new Elysia()
|
||||||
return data.data as UserSchema;
|
return data.data as UserSchema;
|
||||||
})
|
})
|
||||||
.post('/login/romm', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
|
.post('/login/romm', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
|
||||||
.get('/login/romm', checkLoginAndRefreshRomm,
|
.get('/login/romm', async () =>
|
||||||
|
{
|
||||||
|
const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' });
|
||||||
|
if (!access_token)
|
||||||
|
{
|
||||||
|
return { hasLogin: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" });
|
||||||
|
if (expires_in)
|
||||||
|
{
|
||||||
|
const date = new Date(expires_in);
|
||||||
|
if (date > new Date())
|
||||||
|
{
|
||||||
|
return { hasLogin: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" });
|
||||||
|
if (!refresh_token)
|
||||||
|
{
|
||||||
|
return { hasLogin: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } });
|
||||||
|
|
||||||
|
if (refreshResponse.response.ok && refreshResponse.data)
|
||||||
|
{
|
||||||
|
await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token });
|
||||||
|
if (refreshResponse.data.refresh_token)
|
||||||
|
await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token });
|
||||||
|
await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() });
|
||||||
|
|
||||||
|
await plugins.hooks.auth.loginComplete.promise({ service: 'romm' });
|
||||||
|
|
||||||
|
events.emit('notification', { message: "Romm Refresh Successful", type: 'success' });
|
||||||
|
return { hasLogin: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return status(refreshResponse.response.status, refreshResponse.response.statusText) as any;
|
||||||
|
},
|
||||||
{ response: z.object({ hasLogin: z.boolean() }) })
|
{ response: z.object({ hasLogin: z.boolean() }) })
|
||||||
.post('/logout/romm', async () =>
|
.post('/logout/romm', async () =>
|
||||||
{
|
{
|
||||||
|
|
@ -74,115 +174,6 @@ export default new Elysia()
|
||||||
}, { response: z.any() });
|
}, { response: z.any() });
|
||||||
|
|
||||||
|
|
||||||
export async function checkLoginAndRefreshTwitch ()
|
|
||||||
{
|
|
||||||
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
|
||||||
if (!access_token)
|
|
||||||
{
|
|
||||||
return status('Not Found', "Not Logged In");
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } });
|
|
||||||
if (res.ok)
|
|
||||||
{
|
|
||||||
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.TWITCH_CLIENT_ID)
|
|
||||||
{
|
|
||||||
return status("Not Found", "Twitch Client ID not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" });
|
|
||||||
if (!refresh_token)
|
|
||||||
{
|
|
||||||
return status("Not Found", "Refresh Token Not Found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// refresh token
|
|
||||||
const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', {
|
|
||||||
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({
|
|
||||||
client_id: process.env.TWITCH_CLIENT_ID,
|
|
||||||
access_token,
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (refreshResponse.ok)
|
|
||||||
{
|
|
||||||
const data: {
|
|
||||||
access_token: string,
|
|
||||||
refresh_token: string,
|
|
||||||
token_type: string;
|
|
||||||
expires_in: number;
|
|
||||||
} = await refreshResponse.json();
|
|
||||||
|
|
||||||
await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
|
|
||||||
await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
|
|
||||||
await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
|
|
||||||
|
|
||||||
await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' });
|
|
||||||
|
|
||||||
events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' });
|
|
||||||
|
|
||||||
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } });
|
|
||||||
if (res.ok)
|
|
||||||
{
|
|
||||||
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return status(400, res.statusText);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkLoginAndRefreshRomm ()
|
|
||||||
{
|
|
||||||
//TODO: move to plugin logic
|
|
||||||
if (plugins.plugins['com.simeonradivoev.gameflow.romm'].config?.get('clientApiToken'))
|
|
||||||
{
|
|
||||||
return { hasLogin: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' });
|
|
||||||
if (!access_token)
|
|
||||||
{
|
|
||||||
return { hasLogin: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" });
|
|
||||||
if (expires_in)
|
|
||||||
{
|
|
||||||
const date = new Date(expires_in);
|
|
||||||
if (date > new Date())
|
|
||||||
{
|
|
||||||
return { hasLogin: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" });
|
|
||||||
if (!refresh_token)
|
|
||||||
{
|
|
||||||
return { hasLogin: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } });
|
|
||||||
|
|
||||||
if (refreshResponse.response.ok && refreshResponse.data)
|
|
||||||
{
|
|
||||||
await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token });
|
|
||||||
if (refreshResponse.data.refresh_token)
|
|
||||||
await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token });
|
|
||||||
await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() });
|
|
||||||
|
|
||||||
await plugins.hooks.auth.loginComplete.promise({ service: 'romm' });
|
|
||||||
|
|
||||||
events.emit('notification', { message: "Romm Refresh Successful", type: 'success' });
|
|
||||||
return { hasLogin: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return status(refreshResponse.response.status, refreshResponse.response.statusText) as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function tryLoginAndSave ({ host, username, password }: { host: string, username: string, password: string; })
|
export async function tryLoginAndSave ({ host, username, password }: { host: string, username: string, password: string; })
|
||||||
{
|
{
|
||||||
|
|
@ -190,7 +181,7 @@ export async function tryLoginAndSave ({ host, username, password }: { host: str
|
||||||
body: {
|
body: {
|
||||||
password,
|
password,
|
||||||
username,
|
username,
|
||||||
scope: 'me.read roms.read platforms.read assets.read assets.write firmware.read roms.user.read collections.read me.write roms.user.write'
|
scope: 'me.read roms.read platforms.read assets.read firmware.read roms.user.read collections.read me.write roms.user.write'
|
||||||
}, baseUrl: host
|
}, baseUrl: host
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { cache } from "./app";
|
import { cache } from "./app";
|
||||||
import cacheSchema from "@schema/cache";
|
import cacheSchema from "@schema/cache";
|
||||||
import { GithubReleaseSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
import { GithubReleaseSchema } from "@/shared/constants";
|
||||||
import PQueue from "p-queue";
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
export const CACHE_KEYS = {
|
export const CACHE_KEYS = {
|
||||||
ROM_PLATFORMS: 'rom-platforms',
|
ROM_PLATFORMS: 'rom-platforms',
|
||||||
|
|
@ -11,21 +9,17 @@ export const CACHE_KEYS = {
|
||||||
STORE_GAME_MANIFEST: 'store-game-manifest'
|
STORE_GAME_MANIFEST: 'store-game-manifest'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// we aggressively cache github data so burst of calls is fine.
|
export async function getOrCached<T> (key: string, getter: () => Promise<T>, options?: { expireMs?: number; }): Promise<T>
|
||||||
export const githubRequestQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60 * 60, strict: true });
|
|
||||||
|
|
||||||
export async function getOrCached<T> (key: string, getter: (lastValue: T | undefined) => Promise<T>, options?: { expireMs?: number; force?: boolean; }): Promise<T>
|
|
||||||
{
|
{
|
||||||
const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) });
|
const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) });
|
||||||
const updated_at = new Date();
|
const updated_at = new Date();
|
||||||
|
|
||||||
if (cached && cached.expire_at > updated_at && !options?.force)
|
if (cached && cached.expire_at > updated_at)
|
||||||
{
|
{
|
||||||
return cached.data as T;
|
return cached.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getter(cached?.data as T);
|
const data = await getter();
|
||||||
if (data === undefined) return data;
|
|
||||||
|
|
||||||
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
|
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
|
@ -40,15 +34,12 @@ export async function getOrCached<T> (key: string, getter: (lastValue: T | undef
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCachedGithubRelease (path: string, forceCheck?: boolean)
|
export async function getOrCachedGithubRelease (path: string)
|
||||||
{
|
{
|
||||||
return getOrCached<z.infer<typeof GithubReleaseSchema>>(`github-release-${path}`, () => githubRequestQueue.add(async () =>
|
return getOrCached(`github-release-${path}`, async () =>
|
||||||
{
|
{
|
||||||
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, {
|
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" });
|
||||||
method: "GET"
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(response.statusText);
|
if (!response.ok) throw new Error(response.statusText);
|
||||||
const release = await GithubReleaseSchema.parseAsync(await response.json());
|
return GithubReleaseSchema.parseAsync(await response.json());
|
||||||
return release;
|
});
|
||||||
}), { expireMs: 1000 * 60 * 60, force: forceCheck });
|
|
||||||
}
|
}
|
||||||
|
|
@ -5,10 +5,9 @@ import games from "./games/games";
|
||||||
import platforms from "./games/platforms";
|
import platforms from "./games/platforms";
|
||||||
import auth from "./auth";
|
import auth from "./auth";
|
||||||
import collections from "./games/collections";
|
import collections from "./games/collections";
|
||||||
import emulatorjs from "./emulatorjs/emulatorjs";
|
|
||||||
|
|
||||||
export default new Elysia({ prefix: "/api/romm" })
|
export default new Elysia({ prefix: "/api/romm" })
|
||||||
.use([games, platforms, collections, auth, emulatorjs])
|
.use([games, platforms, collections, auth])
|
||||||
.all("/*", async ({ request, set }) =>
|
.all("/*", async ({ request, set }) =>
|
||||||
{
|
{
|
||||||
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ export default async function Initialize ()
|
||||||
const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
|
const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
|
||||||
if (launchGameTask)
|
if (launchGameTask)
|
||||||
{
|
{
|
||||||
taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300));
|
|
||||||
launchGameTask.abort('exit');
|
launchGameTask.abort('exit');
|
||||||
|
taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300));
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
events.emit('focus');
|
events.emit('focus');
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ export class GamepadWindows implements IGamepadBackend
|
||||||
private index: number;
|
private index: number;
|
||||||
private buffer = new ArrayBuffer(16);
|
private buffer = new ArrayBuffer(16);
|
||||||
private view = new DataView(this.buffer);
|
private view = new DataView(this.buffer);
|
||||||
|
private prevButtons = 0;
|
||||||
private currButtons = 0;
|
private currButtons = 0;
|
||||||
|
|
||||||
constructor(index = 0) { this.index = index; }
|
constructor(index = 0) { this.index = index; }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import si from 'systeminformation';
|
import si from 'systeminformation';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { Drive } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
|
|
||||||
async function getAccess (path: string)
|
async function getAccess (path: string)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,4 @@
|
||||||
// ES-DE to emulator JS mapping
|
// ES-DE to emulator JS mapping
|
||||||
|
|
||||||
import Elysia, { status } from "elysia";
|
|
||||||
import z from "zod";
|
|
||||||
import path from 'node:path';
|
|
||||||
import { config, events, plugins } from "../app";
|
|
||||||
import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService";
|
|
||||||
import { SaveFileChange } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
// TODO: use the retroarch cores based on ES-DE
|
// TODO: use the retroarch cores based on ES-DE
|
||||||
export const cores: Record<string, string> = {
|
export const cores: Record<string, string> = {
|
||||||
"atari5200": "atari5200",
|
"atari5200": "atari5200",
|
||||||
|
|
@ -51,57 +43,4 @@ export const cores: Record<string, string> = {
|
||||||
"plus4": "plus4",
|
"plus4": "plus4",
|
||||||
"vic20": "vic20",
|
"vic20": "vic20",
|
||||||
"dos": "dos"
|
"dos": "dos"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default new Elysia({ prefix: '/emulatorjs' })
|
|
||||||
.put('/save', async ({ body: { save, screenshot } }) =>
|
|
||||||
{
|
|
||||||
await Bun.write(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", save.name), save);
|
|
||||||
}, {
|
|
||||||
body: z.object({
|
|
||||||
save: z.file(),
|
|
||||||
screenshot: z.file().optional()
|
|
||||||
})
|
|
||||||
}).get('/load', async ({ query: { filePath } }) =>
|
|
||||||
{
|
|
||||||
return Bun.file(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", filePath));
|
|
||||||
}, { query: z.object({ filePath: z.string() }) })
|
|
||||||
.post('/post_play/:source/:id', async ({ params: { source, id }, body: { save } }) =>
|
|
||||||
{
|
|
||||||
const localGame = await getLocalGame(source, id);
|
|
||||||
if (!localGame) return status("Not Found");
|
|
||||||
|
|
||||||
const changedSaveFiles: Record<string, SaveFileChange> = {};
|
|
||||||
if (save)
|
|
||||||
{
|
|
||||||
const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS");
|
|
||||||
const saveFile = path.join(savesPath, save.name);
|
|
||||||
await Bun.write(saveFile, save);
|
|
||||||
changedSaveFiles.gameflow = { subPath: save.name, cwd: savesPath, shared: false };
|
|
||||||
events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" });
|
|
||||||
}
|
|
||||||
await updateLocalLastPlayed(localGame.id);
|
|
||||||
await plugins.hooks.games.postPlay.promise({
|
|
||||||
source,
|
|
||||||
id,
|
|
||||||
saveFolderSlots: { 'emulatorjs': { cwd: path.join(config.get('downloadPath'), "saves", "EMULATORJS") } },
|
|
||||||
gameInfo: { platformSlug: localGame?.platform.slug },
|
|
||||||
changedSaveFiles: [],
|
|
||||||
validChangedSaveFiles: changedSaveFiles,
|
|
||||||
command: {
|
|
||||||
id: "EMULATORJS",
|
|
||||||
command: "",
|
|
||||||
emulator: "EMULATORJS",
|
|
||||||
valid: true,
|
|
||||||
metadata: {
|
|
||||||
romPath: localGame?.path_fs ?? undefined,
|
|
||||||
emulatorBin: undefined,
|
|
||||||
emulatorDir: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, {
|
|
||||||
body: z.object({
|
|
||||||
save: z.file().optional()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { plugins } from "../app";
|
import { plugins } from "../app";
|
||||||
import { FrontEndCollection } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default new Elysia()
|
export default new Elysia()
|
||||||
.get('/collections', async () =>
|
.get('/collections', async () =>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,25 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { config, db, emulatorsDb, plugins, taskQueue } from "../app";
|
import { config, db, emulatorsDb, plugins, taskQueue } from "../app";
|
||||||
import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm";
|
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { SERVER_URL } from "@shared/constants";
|
import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
|
||||||
import { CommandEntry, DownloadLookupEntry, DownloadsLookupFilterValues, GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
import { InstallJob } from "../jobs/install-job";
|
import { InstallJob } from "../jobs/install-job";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
||||||
import buildStatusResponse, { customUpdate, fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
|
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
||||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||||
import { launchCommand } from "./services/launchGameService";
|
import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService";
|
||||||
import { getErrorMessage, SeededRandom } from "@/bun/utils";
|
import { getErrorMessage, SeededRandom } from "@/bun/utils";
|
||||||
import { defaultFormats, defaultPlugins } from 'jimp';
|
import { defaultFormats, defaultPlugins } from 'jimp';
|
||||||
import { createJimp } from "@jimp/core";
|
import { createJimp } from "@jimp/core";
|
||||||
import webp from "@jimp/wasm-webp";
|
import webp from "@jimp/wasm-webp";
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
|
||||||
|
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
|
||||||
import { host } from "@/bun/utils/host";
|
import { host } from "@/bun/utils/host";
|
||||||
import { LaunchGameJob } from "../jobs/launch-game-job";
|
import { LaunchGameJob } from "../jobs/launch-game-job";
|
||||||
import { cores } from "../emulatorjs/emulatorjs";
|
|
||||||
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
|
|
||||||
import { ImportJob } from "../jobs/import-job";
|
|
||||||
import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
// A custom jimp that supports webp
|
// A custom jimp that supports webp
|
||||||
const Jimp = createJimp({
|
const Jimp = createJimp({
|
||||||
|
|
@ -61,15 +57,8 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width,
|
||||||
|
|
||||||
if (typeof img === 'string')
|
if (typeof img === 'string')
|
||||||
{
|
{
|
||||||
const res = await fetch(img);
|
const rommFetch = await fetch(img);
|
||||||
|
return rommFetch;
|
||||||
return new Response(res.body, {
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": res.headers.get("Content-Type") ?? "image/jpeg",
|
|
||||||
"Cache-Control": "public, max-age=86400",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return img;
|
return img;
|
||||||
|
|
@ -146,142 +135,97 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
const games: FrontEndGameType[] = [];
|
const games: FrontEndGameType[] = [];
|
||||||
|
|
||||||
const where: any[] = [];
|
if (query.source === 'store')
|
||||||
let localGamesSet: Set<string> | undefined;
|
|
||||||
|
|
||||||
if (query.platform_slug)
|
|
||||||
{
|
{
|
||||||
where.push(eq(schema.platforms.slug, query.platform_slug));
|
const shuffledGames = await getShuffledStoreGames();
|
||||||
} else if (query.platform_id && query.platform_source === 'local')
|
set.headers['x-max-items'] = shuffledGames.length;
|
||||||
{
|
const storeGames = await Promise.all(shuffledGames
|
||||||
where.push(eq(schema.platforms.id, query.platform_id));
|
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length))
|
||||||
}
|
.map(async (e) =>
|
||||||
else if (query.platform_id && query.platform_source)
|
|
||||||
{
|
|
||||||
const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: query.platform_id ? String(query.platform_id) : undefined });
|
|
||||||
if (platform)
|
|
||||||
{
|
|
||||||
where.push(eq(schema.platforms.slug, platform?.slug));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.search)
|
|
||||||
{
|
|
||||||
where.push(like(schema.games.name, query.search));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.source)
|
|
||||||
{
|
|
||||||
where.push(eq(schema.games.source, query.source));
|
|
||||||
}
|
|
||||||
|
|
||||||
const ordering: any[] = [];
|
|
||||||
|
|
||||||
if (query.orderBy)
|
|
||||||
{
|
|
||||||
switch (query.orderBy)
|
|
||||||
{
|
|
||||||
case 'added':
|
|
||||||
ordering.push(desc(schema.games.created_at));
|
|
||||||
break;
|
|
||||||
case 'activity':
|
|
||||||
ordering.push(sql`MAX(COALESCE(${schema.games.created_at}, '1970-01-01'), COALESCE(${schema.games.last_played}, '1970-01-01')) DESC`);
|
|
||||||
break;
|
|
||||||
case 'name':
|
|
||||||
ordering.push(desc(schema.games.name));
|
|
||||||
break;
|
|
||||||
case "release":
|
|
||||||
ordering.push(sql`json_extract(${schema.games.metadata}, '$.first_release_date') DESC NULLS LAST`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const localGames = await db.select({
|
|
||||||
...getTableColumns(schema.games),
|
|
||||||
platform: schema.platforms,
|
|
||||||
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
|
|
||||||
})
|
|
||||||
.from(schema.games)
|
|
||||||
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
|
||||||
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
|
|
||||||
.groupBy(schema.games.id)
|
|
||||||
.orderBy(...ordering)
|
|
||||||
.where(and(...where));
|
|
||||||
|
|
||||||
localGamesSet = new Set(
|
|
||||||
localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)
|
|
||||||
.concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`))
|
|
||||||
);
|
|
||||||
|
|
||||||
function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; })
|
|
||||||
{
|
|
||||||
if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true;
|
|
||||||
if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true;
|
|
||||||
if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.collection_id)
|
|
||||||
{
|
|
||||||
// Collections are just a remote thing for now.
|
|
||||||
const remoteGames: FrontEndGameTypeWithIds[] = [];
|
|
||||||
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
|
|
||||||
games.push(...remoteGames.map(g =>
|
|
||||||
{
|
|
||||||
if (localGameExistsPredicate(g))
|
|
||||||
{
|
{
|
||||||
return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!);
|
const system = path.dirname(e.path);
|
||||||
}
|
const id = path.basename(e.path, path.extname(e.path));
|
||||||
else
|
|
||||||
{
|
|
||||||
return g;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
const localGame = await db.select({
|
||||||
|
...getTableColumns(schema.games),
|
||||||
|
platform: schema.platforms,
|
||||||
|
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
|
||||||
|
})
|
||||||
|
.from(schema.games)
|
||||||
|
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
||||||
|
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
|
||||||
|
.groupBy(schema.games.id)
|
||||||
|
.where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)));
|
||||||
|
|
||||||
|
if (localGame.length > 0) return convertLocalToFrontend(localGame[0]);
|
||||||
|
|
||||||
|
const storeGame = await getStoreGameFromPath(e.path);
|
||||||
|
|
||||||
|
return convertStoreToFrontend(system, id, storeGame);
|
||||||
|
}));
|
||||||
|
games.push(...storeGames.filter(g => g !== undefined));
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
games.push(...localGames.slice(query.offset, query.limit !== undefined ? ((query.offset ?? 0) + query.limit) : undefined).filter(g =>
|
const where: any[] = [];
|
||||||
|
let localGamesSet: Set<string> | undefined;
|
||||||
|
|
||||||
|
if (query.platform_slug)
|
||||||
{
|
{
|
||||||
if (query.genres && query.genres.length > 0)
|
where.push(eq(schema.platforms.slug, query.platform_slug));
|
||||||
|
} else if (query.platform_id && query.platform_source === 'local')
|
||||||
|
{
|
||||||
|
where.push(eq(schema.platforms.id, query.platform_id));
|
||||||
|
}
|
||||||
|
else if (query.platform_id && query.platform_source)
|
||||||
|
{
|
||||||
|
const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: String(query.platform_id) });
|
||||||
|
if (platform)
|
||||||
{
|
{
|
||||||
if (!g.metadata) return false;
|
where.push(eq(schema.platforms.slug, platform?.slug));
|
||||||
if (!g.metadata.genres) return false;
|
|
||||||
if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
if (query.source)
|
||||||
}).map(g =>
|
|
||||||
{
|
{
|
||||||
return convertLocalToFrontend(g);
|
where.push(eq(schema.games.source, query.source));
|
||||||
}));
|
}
|
||||||
|
|
||||||
if (query.localOnly !== true)
|
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(and(...where));
|
||||||
|
|
||||||
|
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
|
||||||
|
|
||||||
|
if (!query.collection_id)
|
||||||
{
|
{
|
||||||
const remoteGames: FrontEndGameTypeWithIds[] = [];
|
games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g =>
|
||||||
const remoteGameSet = new Set<string>();
|
|
||||||
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
|
|
||||||
games.push(...remoteGames.filter(g =>
|
|
||||||
{
|
{
|
||||||
if (localGameExistsPredicate(g))
|
return convertLocalToFrontend(g);
|
||||||
{
|
}));
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (g.igdb_id)
|
const remoteGames: FrontEndGameType[] = [];
|
||||||
|
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
|
||||||
|
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`)));
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const remoteGames: FrontEndGameType[] = [];
|
||||||
|
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
|
||||||
|
games.push(...remoteGames.map(g =>
|
||||||
|
{
|
||||||
|
if (localGamesSet?.has(`${g.id.source}@${g.id.id}`))
|
||||||
{
|
{
|
||||||
const igdbId = `igdb@${g.igdb_id}`;
|
return convertLocalToFrontend(localGames.find(l => l.source === g.id.source && l.source_id === g.id.id)!);
|
||||||
if (remoteGameSet.has(igdbId)) return false;
|
} else
|
||||||
remoteGameSet.add(igdbId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (g.ra_id)
|
|
||||||
{
|
{
|
||||||
const raId = `ra@${g.ra_id}`;
|
return g;
|
||||||
if (remoteGameSet.has(raId)) return false;
|
|
||||||
remoteGameSet.add(raId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -299,9 +243,6 @@ export default new Elysia()
|
||||||
case 'name':
|
case 'name':
|
||||||
games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
||||||
break;
|
break;
|
||||||
case "release":
|
|
||||||
games.sort((a, b) => (b.metadata.first_release_date?.getTime() ?? 0) - (a.metadata.first_release_date?.getTime() ?? 0));
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -310,55 +251,27 @@ export default new Elysia()
|
||||||
}, {
|
}, {
|
||||||
query: GameListFilterSchema,
|
query: GameListFilterSchema,
|
||||||
})
|
})
|
||||||
.get('/games/filters', async ({ query: { source } }) =>
|
|
||||||
{
|
|
||||||
const filterSets: FrontEndFilterSets = {
|
|
||||||
age_ratings: new Set(),
|
|
||||||
player_counts: new Set(),
|
|
||||||
languages: new Set(),
|
|
||||||
companies: new Set(),
|
|
||||||
genres: new Set()
|
|
||||||
};
|
|
||||||
|
|
||||||
let filter: any = undefined;
|
|
||||||
if (source) filter = eq(schema.games.source, source);
|
|
||||||
const local_metadata = await db.query.games.findMany({ columns: { metadata: true }, where: filter });
|
|
||||||
|
|
||||||
local_metadata.forEach(game =>
|
|
||||||
{
|
|
||||||
game.metadata.age_ratings?.forEach(r => filterSets.age_ratings.add(r));
|
|
||||||
game.metadata.genres?.forEach(r => filterSets.genres.add(r));
|
|
||||||
game.metadata.companies?.forEach(r => filterSets.companies.add(r));
|
|
||||||
|
|
||||||
if (game.metadata.player_count)
|
|
||||||
filterSets.player_counts.add(game.metadata.player_count);
|
|
||||||
});
|
|
||||||
|
|
||||||
await plugins.hooks.games.fetchFilters.promise({ filters: filterSets, source });
|
|
||||||
|
|
||||||
const filters: FrontEndFilterLists = {
|
|
||||||
age_ratings: Array.from(filterSets.age_ratings),
|
|
||||||
player_counts: Array.from(filterSets.player_counts),
|
|
||||||
languages: Array.from(filterSets.languages),
|
|
||||||
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 } }) =>
|
.get('/rom/:source/:id', async ({ params: { id, source } }) =>
|
||||||
{
|
{
|
||||||
const filePaths = await plugins.hooks.games.fetchRomFiles.promise({ source, id });
|
const localGame = await db.query.games.findFirst({
|
||||||
|
where: getLocalGameMatch(id, source),
|
||||||
|
columns: { path_fs: true }
|
||||||
|
});
|
||||||
|
|
||||||
if (!filePaths || filePaths.length <= 0)
|
if (!localGame?.path_fs)
|
||||||
{
|
{
|
||||||
return status("Not Found", "No Valid Roms Found");
|
return status("Not Found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Bun.file(filePaths[0]);
|
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(path_fs);
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ source: z.string(), id: z.string() })
|
params: z.object({ source: z.string(), id: z.string() })
|
||||||
})
|
})
|
||||||
|
|
@ -373,61 +286,38 @@ export default new Elysia()
|
||||||
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
|
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
|
||||||
if (systemMapping)
|
if (systemMapping)
|
||||||
{
|
{
|
||||||
const emulatorNames: string[] = [];
|
const emulatorNames = await getEmulatorsForSystem(systemMapping.system);
|
||||||
await plugins.hooks.emulators.findEmulatorForSystem.promise({ system: systemMapping.system, emulators: emulatorNames });
|
const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e }))));
|
||||||
|
|
||||||
sourceData.emulators = (await Promise.all(emulatorNames.map(async name =>
|
sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) =>
|
||||||
{
|
{
|
||||||
if (name === 'EMULATORJS')
|
if (data)
|
||||||
|
{
|
||||||
|
const systems = await buildStoreFrontendEmulatorSystems(data);
|
||||||
|
return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true };
|
||||||
|
}
|
||||||
|
else if (name === 'EMULATORJS')
|
||||||
{
|
{
|
||||||
return {
|
return {
|
||||||
name: 'EMULATORJS',
|
name: 'EMULATORJS',
|
||||||
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
|
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
|
||||||
logo: 'https://emulatorjs.org/logo/EmulatorJS.png',
|
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||||
systems: await Promise.all(Object.keys(cores).map(async c =>
|
systems: [],
|
||||||
{
|
gameCount: 0
|
||||||
const mapping = await emulatorsDb.query.systemMappings.findFirst({
|
} satisfies FrontEndGameTypeDetailedEmulator;
|
||||||
where (fields, operators)
|
}
|
||||||
{
|
else
|
||||||
return operators.and(operators.eq(fields.source, "romm"), operators.eq(fields.system, c));
|
{
|
||||||
}, columns: { sourceSlug: true }
|
return {
|
||||||
});
|
name: name,
|
||||||
const system: EmulatorSystem = {
|
logo: "",
|
||||||
id: c,
|
systems: [],
|
||||||
name: c,
|
|
||||||
iconUrl: `/api/romm/image/romm/assets/platforms/${mapping?.sourceSlug}.svg`
|
|
||||||
};
|
|
||||||
return system;
|
|
||||||
})),
|
|
||||||
gameCount: 0,
|
gameCount: 0,
|
||||||
source: 'local',
|
validSources: []
|
||||||
integrations: []
|
|
||||||
} satisfies FrontEndGameTypeDetailedEmulator;
|
} satisfies FrontEndGameTypeDetailedEmulator;
|
||||||
}
|
}
|
||||||
|
|
||||||
const foundEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: name });
|
}));
|
||||||
|
|
||||||
const execPaths: EmulatorSourceEntryType[] = [];
|
|
||||||
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: name, sources: execPaths });
|
|
||||||
const integrations = findEmulatorPluginIntegration(id, execPaths);
|
|
||||||
|
|
||||||
if (foundEmulator)
|
|
||||||
{
|
|
||||||
foundEmulator.validSources = execPaths;
|
|
||||||
foundEmulator.integrations = integrations;
|
|
||||||
return foundEmulator;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: name,
|
|
||||||
logo: "",
|
|
||||||
source: 'local',
|
|
||||||
systems: [],
|
|
||||||
gameCount: 0,
|
|
||||||
validSources: execPaths,
|
|
||||||
integrations: integrations
|
|
||||||
} satisfies FrontEndGameTypeDetailedEmulator;
|
|
||||||
}))).filter(e => !!e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,18 +344,17 @@ export default new Elysia()
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.string(), source: z.string() }),
|
params: z.object({ id: z.string(), source: z.string() }),
|
||||||
})
|
})
|
||||||
.post('/game/:source/:id/install', async ({ params: { id, source }, body }) =>
|
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||||
{
|
{
|
||||||
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
|
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
|
||||||
{
|
{
|
||||||
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body));
|
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source));
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
return status('Not Implemented');
|
return status('Not Implemented');
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.string(), source: z.string() }),
|
params: z.object({ id: z.string(), source: z.string() }),
|
||||||
body: z.object({ downloadId: z.string().optional() }).optional(),
|
|
||||||
response: z.any()
|
response: z.any()
|
||||||
})
|
})
|
||||||
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||||
|
|
@ -481,56 +370,7 @@ export default new Elysia()
|
||||||
params: z.object({ id: z.string(), source: z.string() }),
|
params: z.object({ id: z.string(), source: z.string() }),
|
||||||
response: z.any()
|
response: z.any()
|
||||||
})
|
})
|
||||||
.get('/game/:source/:id/validate', async ({ params: { id, source } }) =>
|
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
|
||||||
{
|
|
||||||
const valid = await validateGameSource(source, id);
|
|
||||||
return { valid: valid.valid, reason: valid.reason };
|
|
||||||
})
|
|
||||||
.post('/game/:source/:id/fix_source', async ({ params: { id, source } }) =>
|
|
||||||
{
|
|
||||||
return fixSource(source, id);
|
|
||||||
})
|
|
||||||
.post('/game/:source/:id/update', async ({ params: { id, source } }) =>
|
|
||||||
{
|
|
||||||
return update(source, id);
|
|
||||||
})
|
|
||||||
.post('/game/:source/:id/update', async ({ params: { id, source }, body }) =>
|
|
||||||
{
|
|
||||||
return customUpdate(source, id, body.source, body.id);
|
|
||||||
}, { body: z.object({ source: z.string(), id: z.string() }) })
|
|
||||||
.get('/lookup', async ({ query: { search } }) =>
|
|
||||||
{
|
|
||||||
const matches = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matches, { search });
|
|
||||||
return { hadMatchers: matches.size > 0, matches: Array.from(matches.values()).flatMap(m => m) };
|
|
||||||
}, {
|
|
||||||
query: z.object({ search: z.string() })
|
|
||||||
})
|
|
||||||
.get('/lookup/:source/:id', async ({ params: { source, id } }) =>
|
|
||||||
{
|
|
||||||
const matches = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matches, { source, id });
|
|
||||||
return Array.from(matches.values()).flatMap(m => m);
|
|
||||||
})
|
|
||||||
.get('/game/:source/:id/commands', async ({ params: { id, source }, set }) =>
|
|
||||||
{
|
|
||||||
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
|
||||||
if (validCommands instanceof Error)
|
|
||||||
{
|
|
||||||
return errorToResponse(validCommands, set);
|
|
||||||
}
|
|
||||||
return validCommands as {
|
|
||||||
commands: CommandEntry[];
|
|
||||||
gameId: FrontEndId;
|
|
||||||
source?: string;
|
|
||||||
sourceId?: string;
|
|
||||||
} | undefined;
|
|
||||||
}, {
|
|
||||||
response: z.object({
|
|
||||||
commands: z.custom<CommandEntry>().array()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.post('/game/:source/:id/play', async ({ params: { id, source }, body: { command_id }, set }) =>
|
|
||||||
{
|
{
|
||||||
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
||||||
if (validCommands)
|
if (validCommands)
|
||||||
|
|
@ -543,11 +383,11 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const validCommand = command_id ? validCommands.commands.find(c => c.id === command_id) : validCommands.commands[0];
|
const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0];
|
||||||
if (validCommand)
|
if (validCommand)
|
||||||
{
|
{
|
||||||
// launch command waits for the game to exit, we don't want that.
|
// launch command waits for the game to exit, we don't want that.
|
||||||
await launchCommand(validCommand, validCommands.gameId, validCommands.source, validCommands.sourceId);
|
await launchCommand(validCommand, source, id, validCommands.gameId);
|
||||||
return { type: 'application', command: null };
|
return { type: 'application', command: null };
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
|
|
@ -588,6 +428,8 @@ export default new Elysia()
|
||||||
const emulator = await getStoreEmulatorPackage(id);
|
const emulator = await getStoreEmulatorPackage(id);
|
||||||
if (!emulator) return status("Not Found");
|
if (!emulator) return status("Not Found");
|
||||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||||
|
const systemsIdSet = new Set(systems.map(s => s.id));
|
||||||
|
|
||||||
|
|
||||||
const games: FrontEndGameType[] = [];
|
const games: FrontEndGameType[] = [];
|
||||||
|
|
||||||
|
|
@ -614,6 +456,28 @@ export default new Elysia()
|
||||||
await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames });
|
await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames });
|
||||||
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`)));
|
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`)));
|
||||||
|
|
||||||
|
const gamesManifest = await getStoreGameManifest();
|
||||||
|
const storeGames = await Promise.all(gamesManifest
|
||||||
|
.filter(g => systemsIdSet.has(path.dirname(g.path)))
|
||||||
|
.map(async (e) =>
|
||||||
|
{
|
||||||
|
const system = path.dirname(e.path);
|
||||||
|
const id = path.basename(e.path, path.extname(e.path));
|
||||||
|
|
||||||
|
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
|
||||||
|
|
||||||
|
if (localGame)
|
||||||
|
{
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeGame = await getStoreGameFromPath(e.path);
|
||||||
|
|
||||||
|
return convertStoreToFrontend(system, id, storeGame);
|
||||||
|
}));
|
||||||
|
|
||||||
|
games.push(...storeGames.filter(g => g !== undefined).slice(0, 3));
|
||||||
|
|
||||||
return games;
|
return games;
|
||||||
})
|
})
|
||||||
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
|
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
|
||||||
|
|
@ -621,10 +485,10 @@ export default new Elysia()
|
||||||
const sourceData = await getSourceGameDetailed(source, id);
|
const sourceData = await getSourceGameDetailed(source, id);
|
||||||
if (!sourceData) return status("Not Found");
|
if (!sourceData) return status("Not Found");
|
||||||
|
|
||||||
const sourceCompaniesSet = new Set(sourceData.metadata.companies);
|
const sourceCompaniesSet = new Set(sourceData.companies);
|
||||||
const sourceGenresSet = new Set(sourceData.metadata.genres);
|
const sourceGenresSet = new Set(sourceData.genres);
|
||||||
|
|
||||||
|
|
||||||
|
const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined;
|
||||||
|
|
||||||
const games: (FrontEndGameType & { metadata?: any; })[] = [];
|
const games: (FrontEndGameType & { metadata?: any; })[] = [];
|
||||||
|
|
||||||
|
|
@ -635,9 +499,37 @@ export default new Elysia()
|
||||||
|
|
||||||
const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`));
|
const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`));
|
||||||
|
|
||||||
games.push(...localGames.map(g => convertLocalToFrontend(g)));
|
games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata })));
|
||||||
|
|
||||||
|
const shuffledGames = await getShuffledStoreGames();
|
||||||
|
const storeGames = await Promise.all(shuffledGames
|
||||||
|
.filter(g =>
|
||||||
|
{
|
||||||
|
const system = path.dirname(g.path);
|
||||||
|
const id = path.basename(g.path, path.extname(g.path));
|
||||||
|
|
||||||
|
if (localGamesSourceSet.has(`${system}@${id}`))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (esSystem)
|
||||||
|
{
|
||||||
|
if (path.dirname(g.path) === esSystem.system) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.map(async (e) =>
|
||||||
|
{
|
||||||
|
const system = path.dirname(e.path);
|
||||||
|
const id = path.basename(e.path, path.extname(e.path));
|
||||||
|
const storeGame = await getStoreGameFromPath(e.path);
|
||||||
|
return convertStoreToFrontend(system, id, storeGame);
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (storeGames)
|
||||||
|
{
|
||||||
|
games.push(...storeGames.slice(0, 3));
|
||||||
|
}
|
||||||
|
|
||||||
const remoteGames: (FrontEndGameType & { metadata?: any; })[] = [];
|
const remoteGames: (FrontEndGameType & { metadata?: any; })[] = [];
|
||||||
plugins.hooks.games.fetchRecommendedGamesForGame.promise({
|
plugins.hooks.games.fetchRecommendedGamesForGame.promise({
|
||||||
|
|
@ -690,57 +582,4 @@ export default new Elysia()
|
||||||
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
|
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
|
||||||
|
|
||||||
return rankedGames.map(g => g.game).slice(0, 10);
|
return rankedGames.map(g => g.game).slice(0, 10);
|
||||||
})
|
|
||||||
.post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) =>
|
|
||||||
{
|
|
||||||
if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running");
|
|
||||||
const data = await taskQueue.enqueue(ImportJob.query({ source, id }), new ImportJob(source, id, gamePath, platformId), {
|
|
||||||
throwOnCancel: true
|
|
||||||
|
|
||||||
});
|
|
||||||
return { source: 'local', id: data.localId };
|
|
||||||
}, {
|
|
||||||
body: z.object({
|
|
||||||
source: z.string(),
|
|
||||||
id: z.string(),
|
|
||||||
gamePath: z.string(),
|
|
||||||
platformId: z.number()
|
|
||||||
})
|
|
||||||
}).get('/downloads/lookup', async ({ query: { search, page, rows, orderBy, sortDirection, source } }) =>
|
|
||||||
{
|
|
||||||
const matches = new Map<string, { count: number, items: DownloadLookupEntry[]; }>();
|
|
||||||
await plugins.hooks.games.downloadsLookup.promise(matches, { search, page, rows, orderBy, sortDirection, source });
|
|
||||||
const allValues = Array.from(matches.values());
|
|
||||||
return { hadMatchers: matches.size > 0, matches: allValues.flatMap(m => m.items), totalCount: allValues.reduce((p, c) => p + c.count, 0) };
|
|
||||||
}, {
|
|
||||||
query: z.object({
|
|
||||||
search: z.string().optional(),
|
|
||||||
page: z.coerce.number().optional(),
|
|
||||||
rows: z.coerce.number().optional(),
|
|
||||||
orderBy: z.string().optional(),
|
|
||||||
sortDirection: z.literal(["desc", "asc"]).optional(),
|
|
||||||
source: z.string().optional()
|
|
||||||
})
|
|
||||||
}).get('/download/lookup/:source/:id', async ({ params: { source, id } }) =>
|
|
||||||
{
|
|
||||||
const match = await plugins.hooks.games.downloadLookup.promise({ source, id });
|
|
||||||
if (!match) return status("Not Found");
|
|
||||||
return match;
|
|
||||||
}).get('/download/file/info', async ({ query: { file_url } }) =>
|
|
||||||
{
|
|
||||||
const response = await fetch(file_url, { method: "HEAD" });
|
|
||||||
if (!response.ok) return status('Internal Server Error', response.statusText);
|
|
||||||
return { size: Number(response.headers.get('content-length')), content_type: response.headers.get('content-type') };
|
|
||||||
}, {
|
|
||||||
query: z.object({ file_url: z.url() })
|
|
||||||
}).get('/download/lookup/filters', async () =>
|
|
||||||
{
|
|
||||||
const filters: DownloadsLookupFilterValues = {
|
|
||||||
source: [],
|
|
||||||
orderBy: []
|
|
||||||
};
|
|
||||||
|
|
||||||
await plugins.hooks.games.downloadsLookupFilters.promise({ filters });
|
|
||||||
|
|
||||||
return filters;
|
|
||||||
});
|
});
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm";
|
import { and, count, eq, getTableColumns, not } from "drizzle-orm";
|
||||||
import { config, db, plugins } from "../app";
|
import { db, plugins } from "../app";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import { findPlatform } from "./services/utils";
|
|
||||||
import { FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default new Elysia()
|
export default new Elysia()
|
||||||
.get('/platforms', async () =>
|
.get('/platforms', async () =>
|
||||||
|
|
@ -93,11 +91,9 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
|
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
|
||||||
if (!remotePlatform) return status("Not Found");
|
if (!remotePlatform) return status("Not Found");
|
||||||
const local = await db.query.platforms.findFirst({ where: or(eq(schema.platforms.slug, remotePlatform?.slug), eq(schema.platforms.name, remotePlatform?.name)) });
|
return remotePlatform;
|
||||||
return { ...remotePlatform, hasLocal: !!local };
|
|
||||||
}
|
}
|
||||||
}, { params: z.object({ source: z.string(), id: z.string() }) })
|
}, { params: z.object({ source: z.string(), id: z.string() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
|
||||||
.get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
|
|
||||||
{
|
{
|
||||||
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||||
|
|
||||||
|
|
@ -116,70 +112,4 @@ export default new Elysia()
|
||||||
set.headers["content-type"] = coverBlob.cover_type;
|
set.headers["content-type"] = coverBlob.cover_type;
|
||||||
}
|
}
|
||||||
return status(200, coverBlob.cover);
|
return status(200, coverBlob.cover);
|
||||||
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) })
|
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) });
|
||||||
.post('/platform/:source/:id/update', async ({ params: { source, id } }) =>
|
|
||||||
{
|
|
||||||
const where: any[] = [];
|
|
||||||
if (source === 'local')
|
|
||||||
{
|
|
||||||
where.push(eq(schema.platforms.id, Number(id)));
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
|
|
||||||
if (remotePlatform)
|
|
||||||
{
|
|
||||||
where.push(eq(schema.platforms.slug, remotePlatform.slug));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const localPlatform = await db.query.platforms.findFirst({
|
|
||||||
where: or(...where)
|
|
||||||
|
|
||||||
});
|
|
||||||
if (!localPlatform) return status("Not Found");
|
|
||||||
|
|
||||||
const platformLookup = await plugins.hooks.games.platformLookup.promise({
|
|
||||||
slug: localPlatform.slug
|
|
||||||
});
|
|
||||||
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${localPlatform.slug}.svg`);
|
|
||||||
if (!platformCover.ok && platformLookup?.url_logo)
|
|
||||||
{
|
|
||||||
platformCover = await fetch(platformLookup.url_logo);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.update(schema.platforms).set({
|
|
||||||
name: platformLookup?.name,
|
|
||||||
cover: Buffer.from(await platformCover.arrayBuffer()),
|
|
||||||
cover_type: platformCover.headers.get('content-type'),
|
|
||||||
}).where(eq(schema.platforms.id, localPlatform.id));
|
|
||||||
})
|
|
||||||
.delete('/platform/local/:id', async ({ params: { id } }) =>
|
|
||||||
{
|
|
||||||
const deleted = await db.delete(schema.platforms).where(and(eq(schema.platforms.id, Number(id)),
|
|
||||||
notExists(
|
|
||||||
db
|
|
||||||
.select()
|
|
||||||
.from(schema.games)
|
|
||||||
.where(eq(schema.games.platform_id, Number(id)))
|
|
||||||
))).returning();
|
|
||||||
if (deleted.length <= 0) return status("Not Found");
|
|
||||||
})
|
|
||||||
.get('/platform/lookup/match/:source/:id', async ({ params: { source, id } }) =>
|
|
||||||
{
|
|
||||||
const platformLookup = await plugins.hooks.games.platformLookup.promise({ source, id });
|
|
||||||
if (!platformLookup) return status("Not Found");
|
|
||||||
const match = await findPlatform({
|
|
||||||
system_slug: platformLookup.slug,
|
|
||||||
platform: {
|
|
||||||
source_slug: platformLookup.slug,
|
|
||||||
source_id: Number(id),
|
|
||||||
source: source,
|
|
||||||
name: platformLookup.name
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { details: platformLookup, match };
|
|
||||||
}, {
|
|
||||||
detail: {
|
|
||||||
description: "Find matches of remote platform lookups. Returns the operations for each platform if it were to be imported. If platform locally exists. Will a new local platform be created from say romm. Unknown is returned if no match is found."
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,23 +1,275 @@
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Glob } from 'bun';
|
import { Glob, which } from 'bun';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { config, taskQueue } from '../../app';
|
import * as schema from '@schema/emulators';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { config, customEmulators, emulatorsDb, taskQueue } from '../../app';
|
||||||
|
import os, { platform } from 'node:os';
|
||||||
|
import { cores } from '../../emulatorjs/emulatorjs';
|
||||||
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
||||||
import { getStoreEmulatorPackage } from '../../store/services/gamesService';
|
import { EmulatorPackageType } from '@/shared/constants';
|
||||||
import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
|
import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService';
|
||||||
import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
|
|
||||||
export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string)
|
export const varRegex = /%([^%]+)%/g;
|
||||||
|
export const assignRegex = /(%\w+%)=(\S+) /g;
|
||||||
|
|
||||||
|
export async function launchCommand (validCommand: CommandEntry, source: string, sourceId: string, id: number)
|
||||||
{
|
{
|
||||||
if (taskQueue.hasActiveOfType(LaunchGameJob))
|
if (taskQueue.hasActiveOfType(LaunchGameJob))
|
||||||
{
|
{
|
||||||
throw new Error(`Game currently running`);
|
throw new Error(`${id} currently running`);
|
||||||
}
|
}
|
||||||
|
|
||||||
taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId));
|
taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the emulators related to the given system
|
||||||
|
* @param systemSlug the ES-DE slug for the system
|
||||||
|
*/
|
||||||
|
export async function getEmulatorsForSystem (systemSlug: string)
|
||||||
|
{
|
||||||
|
const system = await emulatorsDb.query.systems.findFirst({
|
||||||
|
with: { commands: true },
|
||||||
|
where: eq(schema.systems.name, systemSlug)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!system)
|
||||||
|
{
|
||||||
|
throw new Error(`Could not find system '${systemSlug}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emulators = new Set<string>();
|
||||||
|
await Promise.all(system.commands.map(async (command, index) =>
|
||||||
|
{
|
||||||
|
let cmd = command.command;
|
||||||
|
|
||||||
|
const matches = Array.from(cmd.matchAll(varRegex));
|
||||||
|
matches.forEach(([value]) =>
|
||||||
|
{
|
||||||
|
if (value.startsWith("%EMULATOR_"))
|
||||||
|
{
|
||||||
|
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
||||||
|
emulators.add(emulatorName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (cores[systemSlug])
|
||||||
|
{
|
||||||
|
emulators.add('EMULATORJS');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(emulators);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param data Uses es-de system slug
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export async function getValidLaunchCommands (data: {
|
||||||
|
systemSlug: string;
|
||||||
|
gamePath: string;
|
||||||
|
}): Promise<CommandEntry[]>
|
||||||
|
{
|
||||||
|
|
||||||
|
const system = await emulatorsDb.query.systems.findFirst({
|
||||||
|
with: { commands: true },
|
||||||
|
where: eq(schema.systems.name, data.systemSlug)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!system)
|
||||||
|
{
|
||||||
|
throw new Error(`Could not find system '${data.systemSlug}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!system.extension || system.extension.length <= 0)
|
||||||
|
{
|
||||||
|
throw new Error(`No extensions listed for system '${data.systemSlug}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadPath = config.get('downloadPath');
|
||||||
|
const gamePath = path.join(downloadPath, data.gamePath);
|
||||||
|
|
||||||
|
const validFiles: string[] = [];
|
||||||
|
if (!existsSync(gamePath))
|
||||||
|
{
|
||||||
|
throw new Error(`Provided rom path is missing: '${gamePath}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gamePathStat = await fs.stat(gamePath);
|
||||||
|
|
||||||
|
const extensionList = system.extension.join(',');
|
||||||
|
|
||||||
|
if (gamePathStat.isDirectory())
|
||||||
|
{
|
||||||
|
for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`)))
|
||||||
|
{
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length <= 0)
|
||||||
|
{
|
||||||
|
throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`);
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase())))
|
||||||
|
{
|
||||||
|
validFiles.push(gamePath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeWindowsArg (arg: string): string
|
||||||
|
{
|
||||||
|
if (process.platform === 'win32')
|
||||||
|
{
|
||||||
|
return `"${arg
|
||||||
|
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
|
||||||
|
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
|
||||||
|
}"`;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
if (arg.includes(' '))
|
||||||
|
{
|
||||||
|
return `"${arg}"`;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedCommands = await Promise.all(system.commands
|
||||||
|
.filter(c => !c.command.includes(`%ENABLESHORTCUTS%`))
|
||||||
|
.map(async (command, index) =>
|
||||||
|
{
|
||||||
|
const label = command.label;
|
||||||
|
let cmd = command.command;
|
||||||
|
|
||||||
|
let emulator: string | undefined = undefined;
|
||||||
|
let rom = validFiles[0];
|
||||||
|
|
||||||
|
if (cmd.includes('%ESCAPESPECIALS%'))
|
||||||
|
rom = rom.replace(/[&()^=;,]/g, '');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const staticVars: Record<string, string> = {
|
||||||
|
'%ROM%': escapeWindowsArg(rom),
|
||||||
|
'%ROMRAW%': validFiles[0],
|
||||||
|
'%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')),
|
||||||
|
'%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)),
|
||||||
|
'%ROMPATH%': escapeWindowsArg(gamePath),
|
||||||
|
'%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))),
|
||||||
|
'%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])),
|
||||||
|
'%ESCAPESPECIALS%': "",
|
||||||
|
'%HIDEWINDOW%': ""
|
||||||
|
};
|
||||||
|
|
||||||
|
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (_, injectFile: string) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
|
||||||
|
{
|
||||||
|
return staticVars[a] ?? a;
|
||||||
|
});
|
||||||
|
if (existsSync(resolvedInjectFile))
|
||||||
|
{
|
||||||
|
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
|
||||||
|
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const matches = Array.from(cmd.matchAll(varRegex));
|
||||||
|
const varList = await Promise.all(matches.map(async ([value]) =>
|
||||||
|
{
|
||||||
|
if (value.startsWith("%EMULATOR_"))
|
||||||
|
{
|
||||||
|
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
||||||
|
let execs = await findExecsByName(emulatorName);
|
||||||
|
let validExec = execs.find(e => e.exists);
|
||||||
|
|
||||||
|
emulator = emulatorName;
|
||||||
|
return [
|
||||||
|
[value, validExec ? validExec.binPath : undefined] as [string, string | undefined],
|
||||||
|
[`%EMUSOURCE%`, validExec?.type] as [string, string | undefined],
|
||||||
|
['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined],
|
||||||
|
['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined]
|
||||||
|
];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = value[0].substring(1, value.length - 1);
|
||||||
|
return [[value, process.env[key]] as [string, string | undefined]];
|
||||||
|
}));
|
||||||
|
|
||||||
|
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
|
||||||
|
let startDir: string | undefined = undefined;
|
||||||
|
|
||||||
|
if ('%STARTDIR%' in vars)
|
||||||
|
{
|
||||||
|
delete vars['%STARTDIR%'];
|
||||||
|
|
||||||
|
cmd = cmd.replace(assignRegex, (match, p1, p2) =>
|
||||||
|
{
|
||||||
|
if (p1 === '%STARTDIR%')
|
||||||
|
{
|
||||||
|
startDir = varRegex.test(p2) ? staticVars[p2] : p2;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing variable
|
||||||
|
const invalid = Object.entries(vars).find(c => c[1] === undefined);
|
||||||
|
|
||||||
|
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
label: label ?? undefined,
|
||||||
|
command: formattedCommand,
|
||||||
|
startDir,
|
||||||
|
valid: !invalid, emulator,
|
||||||
|
emulatorSource: vars['%EMUSOURCE%'] as any,
|
||||||
|
metadata: {
|
||||||
|
romPath: validFiles[0],
|
||||||
|
emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1],
|
||||||
|
emulatorDir: vars['%EMUDIRRAW%']
|
||||||
|
}
|
||||||
|
} satisfies CommandEntry;
|
||||||
|
}));
|
||||||
|
|
||||||
|
return formattedCommands.filter(c => !!c);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findExecsByName (emulatorName: string)
|
||||||
|
{
|
||||||
|
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
|
||||||
|
if (!emulator)
|
||||||
|
{
|
||||||
|
throw new Error(`Could not find emulator ${emulatorName}`);
|
||||||
|
}
|
||||||
|
return findExecs(emulatorName, emulator);
|
||||||
|
}
|
||||||
|
|
||||||
export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise<EmulatorSourceEntryType | undefined>
|
export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise<EmulatorSourceEntryType | undefined>
|
||||||
{
|
{
|
||||||
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
||||||
|
|
@ -33,27 +285,11 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath
|
||||||
const storeExecName = (await Promise.all(storeEmulator.downloads[`${process.platform}:${process.arch}`].map(async dl =>
|
const storeExecName = (await Promise.all(storeEmulator.downloads[`${process.platform}:${process.arch}`].map(async dl =>
|
||||||
{
|
{
|
||||||
// glob file search causes issues so do manual search
|
// glob file search causes issues so do manual search
|
||||||
|
const glob = new Glob(dl.pattern);
|
||||||
if (await fs.exists(storeEmulatorFolder))
|
if (await fs.exists(storeEmulatorFolder))
|
||||||
{
|
{
|
||||||
const glob = (dl as any).pattern ? new Glob((dl as any).pattern) : undefined;
|
|
||||||
let bin: string | undefined = (dl as any).bin;
|
|
||||||
if (!bin && dl.type === 'scoop')
|
|
||||||
{
|
|
||||||
const data = await getOrCachedScoopPackage(id, dl.url);
|
|
||||||
|
|
||||||
if (data)
|
|
||||||
{
|
|
||||||
bin = data.bin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = (await fs.readdir(storeEmulatorFolder))
|
const files = (await fs.readdir(storeEmulatorFolder))
|
||||||
.filter(f =>
|
.filter(f => glob.match(f));
|
||||||
{
|
|
||||||
if (glob && glob.match(f)) return true;
|
|
||||||
if (bin && f === bin) return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return files.map(f => path.join(storeEmulatorFolder, f));
|
return files.map(f => path.join(storeEmulatorFolder, f));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -70,3 +306,112 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
|
||||||
|
{
|
||||||
|
const execs: EmulatorSourceEntryType[] = [];
|
||||||
|
|
||||||
|
if (customEmulators.has(id))
|
||||||
|
{
|
||||||
|
execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emulator && emulator.systempath.length > 0)
|
||||||
|
{
|
||||||
|
const storePath = await findStoreEmulatorExec(id, emulator);
|
||||||
|
if (storePath) execs.push(storePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emulator && os.platform() === 'win32')
|
||||||
|
{
|
||||||
|
const regValues = emulator.winregistrypath;
|
||||||
|
if (regValues.length > 0)
|
||||||
|
{
|
||||||
|
for (const node of regValues)
|
||||||
|
{
|
||||||
|
const registryValue = await readRegistryValue(node);
|
||||||
|
if (registryValue)
|
||||||
|
{
|
||||||
|
execs.push({ binPath: registryValue, type: 'registry', exists: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emulator && emulator.systempath.length > 0)
|
||||||
|
{
|
||||||
|
const systemPath = await resolveSystemPath(emulator.systempath);
|
||||||
|
if (systemPath)
|
||||||
|
{
|
||||||
|
execs.push({ binPath: systemPath, type: 'system', exists: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emulator && emulator.staticpath.length > 0)
|
||||||
|
{
|
||||||
|
const staticPath = await resolveStaticPath(emulator.staticpath);
|
||||||
|
if (staticPath)
|
||||||
|
{
|
||||||
|
execs.push({ binPath: staticPath, type: 'static', exists: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return execs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRegistryValue (text: string)
|
||||||
|
{
|
||||||
|
const params = text.split('|');
|
||||||
|
const key = path.dirname(params[0]);
|
||||||
|
const value = path.basename(params[0]);
|
||||||
|
const bin = params.length > 1 ? params[1] : undefined;
|
||||||
|
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: ["reg", "QUERY", key, "/v", value],
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = await new Response(proc.stdout).text();
|
||||||
|
await proc.exited;
|
||||||
|
|
||||||
|
if (!output.includes(value)) return null;
|
||||||
|
|
||||||
|
const lines = output.split("\n");
|
||||||
|
for (const line of lines)
|
||||||
|
{
|
||||||
|
if (line.includes(value))
|
||||||
|
{
|
||||||
|
const parts = line.trim().split(/\s{4,}/);
|
||||||
|
return bin ? path.join(parts[2], bin) : parts[2]; // registry value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveStaticPath (entries: string[])
|
||||||
|
{
|
||||||
|
for (const entry of entries)
|
||||||
|
{
|
||||||
|
const resolved = entry.replace("~", os.homedir());
|
||||||
|
if (await fs.exists(resolved))
|
||||||
|
{
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSystemPath (entries: string[])
|
||||||
|
{
|
||||||
|
for (const entry of entries)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const found = which(entry);
|
||||||
|
return found;
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
import { config, db, plugins, taskQueue } from "../../app";
|
import { RPC_URL, } from "@shared/constants";
|
||||||
import { eq } from "drizzle-orm";
|
import { config, customEmulators, db, emulatorsDb, plugins, taskQueue } from "../../app";
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
import { getValidLaunchCommands } from "./launchGameService";
|
||||||
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { getErrorMessage, hashFile } from "@/bun/utils";
|
||||||
|
import { checkFiles, getLocalGameMatch } from "./utils";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { getStoreGameFromId } from "../../store/services/gamesService";
|
||||||
|
import { cores } from "../../emulatorjs/emulatorjs";
|
||||||
|
import { host } from "@/bun/utils/host";
|
||||||
import Elysia from "elysia";
|
import Elysia from "elysia";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
||||||
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
||||||
import * as appSchema from "@schema/app";
|
|
||||||
import { RPC_URL } from "@/shared/constants";
|
|
||||||
import { DownloadSourceSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
import { host } from "@/bun/utils/host";
|
|
||||||
import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export class CommandSearchError extends Error
|
class CommandSearchError extends Error
|
||||||
{
|
{
|
||||||
constructor(status: GameStatusType, message: string)
|
constructor(status: GameStatusType, message: string)
|
||||||
{
|
{
|
||||||
|
|
@ -25,15 +26,7 @@ export class CommandSearchError extends Error
|
||||||
export async function getLocalGame (source: string, id: string)
|
export async function getLocalGame (source: string, id: string)
|
||||||
{
|
{
|
||||||
const localGame = await db.query.games.findFirst({
|
const localGame = await db.query.games.findFirst({
|
||||||
columns: {
|
columns: { id: true, path_fs: true },
|
||||||
id: true,
|
|
||||||
path_fs: true,
|
|
||||||
source: true,
|
|
||||||
source_id: true,
|
|
||||||
igdb_id: true,
|
|
||||||
ra_id: true,
|
|
||||||
main_glob: true
|
|
||||||
},
|
|
||||||
where: getLocalGameMatch(id, source),
|
where: getLocalGameMatch(id, source),
|
||||||
with: {
|
with: {
|
||||||
platform: { columns: { slug: true } }
|
platform: { columns: { slug: true } }
|
||||||
|
|
@ -43,243 +36,62 @@ export async function getLocalGame (source: string, id: string)
|
||||||
return localGame;
|
return localGame;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update local game's metadata from custom source, not the actual source of the game. Say from metadata providers like IGDB */
|
export async function getValidLaunchCommandsForGame (source: string, id: string)
|
||||||
export async function customUpdate (source: string, id: string, destination: string, destinationId: string)
|
|
||||||
{
|
|
||||||
const localGame = await getLocalGame(source, id);
|
|
||||||
if (!localGame) throw new Error("Could not find Local Game");
|
|
||||||
|
|
||||||
const matchesMap = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matchesMap, { source: destination, id: destinationId });
|
|
||||||
const matches = matchesMap.values().next().value;
|
|
||||||
if (!matches || matches?.length <= 0) throw new Error("Could not find destination");
|
|
||||||
const match = matches[0];
|
|
||||||
|
|
||||||
await db.transaction(async (tx) =>
|
|
||||||
{
|
|
||||||
await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id));
|
|
||||||
|
|
||||||
// pre-fetch screenshots
|
|
||||||
const screenshots = await Promise.all(match.screenshotUrls.map(s => fetch(s)));
|
|
||||||
|
|
||||||
if (screenshots.length > 0)
|
|
||||||
{
|
|
||||||
await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
|
||||||
{
|
|
||||||
const screenshot: typeof appSchema.screenshots.$inferInsert = {
|
|
||||||
game_id: localGame.id,
|
|
||||||
content: Buffer.from(await response.arrayBuffer()),
|
|
||||||
type: response.headers.get('content-type')
|
|
||||||
};
|
|
||||||
|
|
||||||
return screenshot;
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
let cover: Buffer<ArrayBuffer> | undefined = undefined;
|
|
||||||
if (match.coverUrl)
|
|
||||||
{
|
|
||||||
const coverResponse = await fetch(match.coverUrl);
|
|
||||||
if (coverResponse.ok)
|
|
||||||
{
|
|
||||||
cover = Buffer.from(await coverResponse.arrayBuffer());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.update(appSchema.games).set({
|
|
||||||
cover,
|
|
||||||
metadata: {
|
|
||||||
age_ratings: match.age_ratings,
|
|
||||||
genres: match.genres,
|
|
||||||
player_count: match.player_count ?? undefined,
|
|
||||||
companies: match.companies,
|
|
||||||
game_modes: match.game_modes,
|
|
||||||
average_rating: match.average_rating ?? undefined,
|
|
||||||
first_release_date: match.first_release_date,
|
|
||||||
}
|
|
||||||
}).where(eq(appSchema.games.id, localGame.id));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function update (source: string, id: string)
|
|
||||||
{
|
|
||||||
const localGame = await getLocalGame(source, id);
|
|
||||||
if (!localGame) throw new Error("Could not find Local Game");
|
|
||||||
if (!localGame.source || !localGame.source_id) throw new Error("Game has not source defined");
|
|
||||||
const sourceGame = await getSourceGameDetailed(localGame.source, localGame.source_id, { sourceOnly: true });
|
|
||||||
if (!sourceGame) throw new Error("Could not find source game");
|
|
||||||
|
|
||||||
await db.transaction(async (tx) =>
|
|
||||||
{
|
|
||||||
await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id));
|
|
||||||
|
|
||||||
const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)];
|
|
||||||
if (paths_screenshots.length <= 0 && sourceGame.igdb_id)
|
|
||||||
{
|
|
||||||
const matches = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(sourceGame.igdb_id) });
|
|
||||||
if (matches.size > 0)
|
|
||||||
{
|
|
||||||
const firstMatches = matches.values().next().value;
|
|
||||||
if (firstMatches && firstMatches.length > 0)
|
|
||||||
{
|
|
||||||
paths_screenshots.push(...firstMatches[0].screenshotUrls);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pre-fetch screenshots
|
|
||||||
const screenshots = await Promise.all(paths_screenshots.map(s => fetch(s)));
|
|
||||||
|
|
||||||
if (screenshots.length > 0)
|
|
||||||
{
|
|
||||||
await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
|
||||||
{
|
|
||||||
const screenshot: typeof appSchema.screenshots.$inferInsert = {
|
|
||||||
game_id: localGame.id,
|
|
||||||
content: Buffer.from(await response.arrayBuffer()),
|
|
||||||
type: response.headers.get('content-type')
|
|
||||||
};
|
|
||||||
|
|
||||||
return screenshot;
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.update(appSchema.games).set({
|
|
||||||
metadata: {
|
|
||||||
age_ratings: sourceGame.metadata.age_ratings,
|
|
||||||
genres: sourceGame.metadata.genres,
|
|
||||||
player_count: sourceGame.metadata.player_count ?? undefined,
|
|
||||||
companies: sourceGame.metadata.companies,
|
|
||||||
game_modes: sourceGame.metadata.game_modes,
|
|
||||||
average_rating: sourceGame.metadata.average_rating ?? undefined,
|
|
||||||
first_release_date: sourceGame.metadata.first_release_date?.getTime() ?? undefined,
|
|
||||||
}
|
|
||||||
}).where(eq(appSchema.games.id, localGame.id));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fixSource (source: string, id: string)
|
|
||||||
{
|
|
||||||
const valid = await validateGameSource(source, id);
|
|
||||||
if (!valid.valid)
|
|
||||||
{
|
|
||||||
if (!valid.localGame) throw new Error("No Local Game");
|
|
||||||
if (!valid.localGame.source) throw new Error("No Valid Source");
|
|
||||||
|
|
||||||
const foundGame = await plugins.hooks.games.searchGame.promise({
|
|
||||||
igdb_id: valid.localGame.igdb_id ?? undefined,
|
|
||||||
ra_id: valid.localGame.ra_id ?? undefined,
|
|
||||||
source: valid.localGame.source
|
|
||||||
});
|
|
||||||
|
|
||||||
if (foundGame)
|
|
||||||
{
|
|
||||||
await db.update(appSchema.games).set({
|
|
||||||
source: foundGame.id.source,
|
|
||||||
source_id: foundGame.id.id,
|
|
||||||
metadata: {
|
|
||||||
age_ratings: foundGame.metadata.age_ratings,
|
|
||||||
genres: foundGame.metadata.genres,
|
|
||||||
player_count: foundGame.metadata.player_count ?? undefined,
|
|
||||||
companies: foundGame.metadata.companies,
|
|
||||||
game_modes: foundGame.metadata.game_modes,
|
|
||||||
average_rating: foundGame.metadata.average_rating ?? undefined,
|
|
||||||
first_release_date: foundGame.metadata.first_release_date?.getTime() ?? undefined,
|
|
||||||
}
|
|
||||||
}).where(eq(appSchema.games.id, valid.localGame.id));
|
|
||||||
return true;
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
throw new Error("Could not find Source Game");
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
throw new Error("Game Source Already Valid");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateGameSource (source: string, id: string): Promise<{
|
|
||||||
valid: boolean,
|
|
||||||
localGame?: { id: number; igdb_id: number | null; ra_id: number | null; source: string | null; },
|
|
||||||
reason?: string;
|
|
||||||
}>
|
|
||||||
{
|
|
||||||
const localGame = await getLocalGame(source, id);
|
|
||||||
if (!localGame) return { valid: true };
|
|
||||||
if (localGame.source && localGame.source_id)
|
|
||||||
{
|
|
||||||
const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id });
|
|
||||||
if (!sourceGame) return { valid: false, reason: "Source Missing", localGame };
|
|
||||||
// Store should be immutable
|
|
||||||
if (localGame.source !== 'store' && sourceGame.igdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined))
|
|
||||||
{
|
|
||||||
return { valid: false, reason: "Metadata Missmatch", localGame };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true, localGame };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateLocalLastPlayed (id: number)
|
|
||||||
{
|
|
||||||
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined>
|
|
||||||
{
|
{
|
||||||
const localGame = await getLocalGame(source, id);
|
const localGame = await getLocalGame(source, id);
|
||||||
if (localGame)
|
if (localGame)
|
||||||
{
|
{
|
||||||
const commands = await plugins.hooks.games.buildLaunchCommands.promise({
|
const rommPlatform = localGame.platform.slug;
|
||||||
source: localGame.source,
|
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) });
|
||||||
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;
|
if (esPlatform)
|
||||||
|
|
||||||
const validCommand = commands.find(c => c.valid);
|
|
||||||
if (validCommand)
|
|
||||||
{
|
{
|
||||||
return {
|
if (localGame.path_fs)
|
||||||
commands: commands.filter(c => c.valid),
|
{
|
||||||
gameId: { id: String(localGame.id), source: 'local' },
|
try
|
||||||
source: localGame.source ?? source,
|
{
|
||||||
sourceId: localGame.source_id ? String(localGame.source_id) : id,
|
const commands = await getValidLaunchCommands({ systemSlug: esPlatform.system, gamePath: localGame.path_fs });
|
||||||
};
|
|
||||||
|
if (cores[esPlatform.system])
|
||||||
|
{
|
||||||
|
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
|
||||||
|
commands.push({
|
||||||
|
id: 'EMULATORJS',
|
||||||
|
label: "Emulator JS",
|
||||||
|
command: `core=${cores[esPlatform.system]}&gameUrl=${encodeURIComponent(gameUrl)}`,
|
||||||
|
valid: true,
|
||||||
|
emulator: 'EMULATORJS',
|
||||||
|
metadata: {
|
||||||
|
romPath: gameUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validCommand = commands.find(c => c.valid);
|
||||||
|
if (validCommand)
|
||||||
|
{
|
||||||
|
return { commands: commands.filter(c => c.valid), gameId: localGame.id, source: source, sourceId: id };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
|
||||||
|
}
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
|
return new CommandSearchError('error', getErrorMessage(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return new CommandSearchError('error', 'Missing Path');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return new CommandSearchError('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 new CommandSearchError('error', `Missing Platform ${localGame.platform.slug}`);
|
||||||
}
|
}
|
||||||
} else if (source === 'emulator')
|
|
||||||
{
|
|
||||||
const commands = await plugins.hooks.games.buildLaunchCommands.promise({
|
|
||||||
source,
|
|
||||||
sourceId: id,
|
|
||||||
id: { source: source, id: id },
|
|
||||||
systemSlug: "",
|
|
||||||
gamePath: null
|
|
||||||
});
|
|
||||||
|
|
||||||
if (commands instanceof Error || !commands) return commands;
|
|
||||||
|
|
||||||
const validCommand = commands.find(c => c.valid);
|
|
||||||
if (validCommand)
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
commands: commands.filter(c => c.valid),
|
|
||||||
gameId: { id, source }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -294,7 +106,7 @@ export default function buildStatusResponse ()
|
||||||
z.object({ status: z.literal('refresh'), localId: z.number().optional() }),
|
z.object({ status: z.literal('refresh'), localId: z.number().optional() }),
|
||||||
z.object({ status: z.literal(['queued']) }),
|
z.object({ status: z.literal(['queued']) }),
|
||||||
z.object({ status: z.literal('playing'), details: z.string() }),
|
z.object({ status: z.literal('playing'), details: z.string() }),
|
||||||
z.object({ status: z.literal('install'), details: z.string(), sources: DownloadSourceSchema.array() }),
|
z.object({ status: z.literal('install'), details: z.string() }),
|
||||||
z.object({ status: z.literal('present'), details: z.string() }),
|
z.object({ status: z.literal('present'), details: z.string() }),
|
||||||
z.object({ status: z.literal(['download', 'extract']), progress: z.number() }),
|
z.object({ status: z.literal(['download', 'extract']), progress: z.number() }),
|
||||||
]),
|
]),
|
||||||
|
|
@ -308,7 +120,7 @@ export default function buildStatusResponse ()
|
||||||
},
|
},
|
||||||
async open (ws)
|
async open (ws)
|
||||||
{
|
{
|
||||||
sendLatests().catch(e => ws.send({ status: 'error', error: JSON.stringify(e) }));
|
sendLatests();
|
||||||
const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id });
|
const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id });
|
||||||
|
|
||||||
async function sendLatests ()
|
async function sendLatests ()
|
||||||
|
|
@ -331,7 +143,6 @@ export default function buildStatusResponse ()
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source) });
|
|
||||||
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
|
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
|
||||||
if (validCommand)
|
if (validCommand)
|
||||||
{
|
{
|
||||||
|
|
@ -348,11 +159,9 @@ export default function buildStatusResponse ()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (!localGame && ws.data.params.source === 'store')
|
} else if (ws.data.params.source === 'store')
|
||||||
{
|
{
|
||||||
const downloads = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id });
|
const storeGame = await getStoreGameFromId(ws.data.params.id);
|
||||||
const sources = downloads?.map(d => ({ id: d.id, name: d.id })) ?? [];
|
|
||||||
/*const storeGame = await getStoreGame(ws.data.params.id);
|
|
||||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||||
const size = Number(fileResponse.headers.get('content-length'));
|
const size = Number(fileResponse.headers.get('content-length'));
|
||||||
const stats = await fs.statfs(config.get('downloadPath'));
|
const stats = await fs.statfs(config.get('downloadPath'));
|
||||||
|
|
@ -363,22 +172,19 @@ export default function buildStatusResponse ()
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
ws.send({ status: 'install', details: 'Install' });
|
ws.send({ status: 'install', details: 'Install' });
|
||||||
}*/
|
}
|
||||||
|
} else
|
||||||
ws.send({ status: 'install', details: 'Install', sources });
|
|
||||||
} else if (!localGame)
|
|
||||||
{
|
{
|
||||||
const files = await plugins.hooks.games.fetchDownloads.promise({
|
const files = await plugins.hooks.games.fetchDownloads.promise({
|
||||||
source: ws.data.params.source,
|
source: ws.data.params.source,
|
||||||
id: ws.data.params.id
|
id: ws.data.params.id
|
||||||
});
|
});
|
||||||
const sources = files?.map(d => ({ id: d.id, name: d.id })) ?? [];
|
|
||||||
|
|
||||||
let filesChecked: LocalDownloadFileEntry[] | undefined;
|
let filesChecked: LocalDownloadFileEntry[] | undefined;
|
||||||
|
|
||||||
if (files && files.length)
|
if (files)
|
||||||
{
|
{
|
||||||
filesChecked = await checkFiles(files[0].files, !!files[0].extract_path);
|
filesChecked = await checkFiles(files.files, !!files.extract_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false))
|
if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false))
|
||||||
|
|
@ -393,16 +199,15 @@ export default function buildStatusResponse ()
|
||||||
ws.send({ status: 'error', error: "Not Enough Free Space" });
|
ws.send({ status: 'error', error: "Not Enough Free Space" });
|
||||||
} else if (filesChecked?.some(f => f.exists === true && f.matches === false))
|
} else if (filesChecked?.some(f => f.exists === true && f.matches === false))
|
||||||
{
|
{
|
||||||
ws.send({ status: 'install', details: 'Some Files Present, Install', sources });
|
ws.send({ status: 'install', details: 'Some Files Present, Install' });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ws.send({ status: 'install', details: 'Install', sources });
|
ws.send({ status: 'install', details: 'Install' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else
|
|
||||||
{
|
|
||||||
ws.send({ status: 'error', error: "No Way To Launch" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,24 @@ import getFolderSize from "get-folder-size";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { config, db, emulatorsDb, plugins } from "../../app";
|
import { config, db, emulatorsDb, plugins } from "../../app";
|
||||||
import { and, eq, or } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import { RPC_URL } from "@shared/constants";
|
import { StoreGameType } from "@shared/constants";
|
||||||
import { hashFile } from "@/bun/utils";
|
import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm";
|
||||||
import { host } from "@/bun/utils/host";
|
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared";
|
import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService";
|
||||||
|
import { hashFile, isSteamDeck, isSteamDeckGameMode } from "@/bun/utils";
|
||||||
|
|
||||||
export async function calculateSize (installPath: string | null)
|
export async function calculateSize (installPath: string | null)
|
||||||
{
|
{
|
||||||
if (!installPath) return null;
|
if (!installPath) return null;
|
||||||
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
|
return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size;
|
||||||
return (await getFolderSize(finalPath)).size;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkInstalled (installPath: string | null)
|
export async function checkInstalled (installPath: string | null)
|
||||||
{
|
{
|
||||||
if (!installPath) return false;
|
if (!installPath) return false;
|
||||||
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
|
return fs.exists(path.join(config.get('downloadPath'), installPath));
|
||||||
return fs.exists(finalPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getScreenshotLocalGameMatch (id: string, source: string)
|
|
||||||
{
|
|
||||||
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalGameMatch (id: string, source: string)
|
export function getLocalGameMatch (id: string, source: string)
|
||||||
|
|
@ -40,10 +33,10 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const game: FrontEndGameType = {
|
const game: FrontEndGameType = {
|
||||||
platform_display_name: g.platform?.name ?? null,
|
platform_display_name: g.platform?.name ?? "Local",
|
||||||
id: { id: String(g.id), source: 'local' },
|
id: { id: String(g.id), source: 'local' },
|
||||||
updated_at: g.created_at,
|
updated_at: g.created_at,
|
||||||
path_covers: [`/api/romm/game/local/${g.id}/cover`],
|
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
||||||
source_id: g.source_id,
|
source_id: g.source_id,
|
||||||
source: g.source,
|
source: g.source,
|
||||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
||||||
|
|
@ -53,29 +46,22 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
|
||||||
slug: g.slug,
|
slug: g.slug,
|
||||||
name: g.name,
|
name: g.name,
|
||||||
platform_id: g.platform_id,
|
platform_id: g.platform_id,
|
||||||
platform_slug: g.platform?.slug ?? null,
|
platform_slug: g.platform?.slug ?? null
|
||||||
metadata: {
|
|
||||||
first_release_date: g.metadata?.first_release_date !== undefined ? new Date(g.metadata?.first_release_date) : null
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
|
export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
|
||||||
platform?: { name: string | null, slug: string | null; } | null;
|
platform?: typeof schema.platforms.$inferSelect | null;
|
||||||
screenshotIds?: number[];
|
screenshotIds?: number[];
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
|
|
||||||
const exists = await checkInstalled(g.path_fs);
|
|
||||||
const fileSize = await calculateSize(g.path_fs);
|
|
||||||
|
|
||||||
const game: FrontEndGameTypeDetailed = {
|
const game: FrontEndGameTypeDetailed = {
|
||||||
platform_display_name: g.platform?.name ?? "Local",
|
platform_display_name: g.platform?.name ?? "Local",
|
||||||
id: { id: String(g.id), source: 'local' },
|
id: { id: String(g.id), source: 'local' },
|
||||||
updated_at: g.created_at,
|
updated_at: g.created_at,
|
||||||
path_covers: [`/api/romm/game/local/${g.id}/cover`],
|
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
||||||
source_id: g.source_id,
|
source_id: g.source_id,
|
||||||
source: g.source,
|
source: g.source,
|
||||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
||||||
|
|
@ -87,28 +73,70 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in
|
||||||
platform_id: g.platform_id,
|
platform_id: g.platform_id,
|
||||||
platform_slug: g.platform?.slug ?? null,
|
platform_slug: g.platform?.slug ?? null,
|
||||||
summary: g.summary,
|
summary: g.summary,
|
||||||
fs_size_bytes: fileSize,
|
fs_size_bytes: 0,
|
||||||
missing: !exists,
|
missing: false,
|
||||||
local: true,
|
local: true
|
||||||
ra_id: g.ra_id,
|
|
||||||
version: g.version,
|
|
||||||
version_source: g.version_source,
|
|
||||||
version_system: g.version_system,
|
|
||||||
igdb_id: g.igdb_id,
|
|
||||||
metadata: {
|
|
||||||
genres: g.metadata.genres ?? [],
|
|
||||||
companies: g.metadata.companies ?? [],
|
|
||||||
game_modes: g.metadata.game_modes ?? [],
|
|
||||||
age_ratings: g.metadata.age_ratings ?? [],
|
|
||||||
player_count: g.metadata.player_count ?? null,
|
|
||||||
average_rating: g.metadata.average_rating ?? null,
|
|
||||||
first_release_date: g.metadata.first_release_date ? new Date(g.metadata.first_release_date) : null
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
|
||||||
|
{
|
||||||
|
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
|
||||||
|
where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm'))
|
||||||
|
});
|
||||||
|
|
||||||
|
const platformDef = await emulatorsDb.query.systems.findFirst({
|
||||||
|
where: eq(emulatorSchema.systems.name, system),
|
||||||
|
columns: { fullname: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const gameId = `${system}@${id}`;
|
||||||
|
|
||||||
|
const game: FrontEndGameType = {
|
||||||
|
platform_display_name: platformDef?.fullname ?? system,
|
||||||
|
path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`,
|
||||||
|
id: { source: 'store', id: gameId },
|
||||||
|
source: null,
|
||||||
|
source_id: null,
|
||||||
|
path_fs: null,
|
||||||
|
path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`,
|
||||||
|
last_played: null,
|
||||||
|
updated_at: new Date(),
|
||||||
|
slug: null,
|
||||||
|
name: storeGame.title,
|
||||||
|
platform_id: null,
|
||||||
|
platform_slug: rommSystem?.sourceSlug ?? system,
|
||||||
|
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? []
|
||||||
|
};
|
||||||
|
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed>
|
||||||
|
{
|
||||||
|
let size: number | null = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||||
|
size = Number(fileResponse.headers.get('content-length'));
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailed: FrontEndGameTypeDetailed = {
|
||||||
|
...await convertStoreToFrontend(system, id, storeGame),
|
||||||
|
summary: storeGame.description,
|
||||||
|
fs_size_bytes: size,
|
||||||
|
missing: false,
|
||||||
|
local: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return detailed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLocalGameDetailed (match: any)
|
export async function getLocalGameDetailed (match: any)
|
||||||
{
|
{
|
||||||
const localGame = await db.query.games.findFirst({
|
const localGame = await db.query.games.findFirst({
|
||||||
|
|
@ -121,13 +149,35 @@ export async function getLocalGameDetailed (match: any)
|
||||||
|
|
||||||
if (localGame)
|
if (localGame)
|
||||||
{
|
{
|
||||||
return convertLocalToFrontendDetailed({ ...localGame, screenshotIds: localGame.screenshots.map(s => s.id) });
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSourceGameDetailed (source: string, id: string, options?: { sourceOnly?: boolean; })
|
export async function getSourceGameDetailed (source: string, id: string)
|
||||||
{
|
{
|
||||||
if (source === 'local')
|
if (source === 'local')
|
||||||
{
|
{
|
||||||
|
|
@ -139,13 +189,30 @@ export async function getSourceGameDetailed (source: string, id: string, options
|
||||||
{
|
{
|
||||||
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
||||||
|
|
||||||
const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame });
|
if (source === 'store')
|
||||||
if (localGame && options?.sourceOnly !== true)
|
|
||||||
{
|
{
|
||||||
return localGame;
|
const gameId = extractStoreGameSourceId(id);
|
||||||
|
const storeGame = await getStoreGame(gameId.system, gameId.id);
|
||||||
|
if (!storeGame) return undefined;
|
||||||
|
const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
|
||||||
|
if (localGame)
|
||||||
|
{
|
||||||
|
return { ...storeFrontendGame, ...localGame };
|
||||||
|
}
|
||||||
|
return storeFrontendGame;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame });
|
||||||
|
if (remoteGame)
|
||||||
|
{
|
||||||
|
return remoteGame;
|
||||||
|
} else if (localGame)
|
||||||
|
{
|
||||||
|
return localGame;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return remoteGame;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,333 +241,4 @@ export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean
|
||||||
}
|
}
|
||||||
return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry;
|
return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry;
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
export async function findPlatform (info: {
|
|
||||||
system_slug: string; platform: {
|
|
||||||
igdb_id?: number;
|
|
||||||
igdb_slug?: string;
|
|
||||||
ra_id?: number;
|
|
||||||
moby_id?: number;
|
|
||||||
source: string;
|
|
||||||
source_id?: number;
|
|
||||||
source_slug?: string;
|
|
||||||
family_name?: string;
|
|
||||||
name?: string;
|
|
||||||
} | undefined;
|
|
||||||
}):
|
|
||||||
Promise<{
|
|
||||||
type: string | null;
|
|
||||||
slug?: string | null;
|
|
||||||
name?: string | null;
|
|
||||||
family_name?: string | null;
|
|
||||||
es_slug?: string | null;
|
|
||||||
coverUrl?: string | null;
|
|
||||||
}>
|
|
||||||
{
|
|
||||||
// Search for existing platform
|
|
||||||
const platformSearch = [eq(schema.platforms.slug, info.system_slug)];
|
|
||||||
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)];
|
|
||||||
|
|
||||||
if (info.platform)
|
|
||||||
{
|
|
||||||
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
|
|
||||||
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
|
|
||||||
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
|
|
||||||
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
|
|
||||||
|
|
||||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source));
|
|
||||||
if (info.platform.source_slug)
|
|
||||||
{
|
|
||||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug));
|
|
||||||
} else if (info.platform.source_id)
|
|
||||||
{
|
|
||||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id));
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
throw new Error("Must Provide at least one source id or slug");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
|
|
||||||
with: { system: true },
|
|
||||||
where: and(...esPlatformSearch)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (esPlatform)
|
|
||||||
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
|
|
||||||
|
|
||||||
let existingPlatform = await db.query.platforms.findFirst({ where: or(...platformSearch) });
|
|
||||||
|
|
||||||
if (!existingPlatform)
|
|
||||||
{
|
|
||||||
// TODO: use something else than the romm demo as CDN
|
|
||||||
|
|
||||||
const platformLookup = await plugins.hooks.games.platformLookup.promise({
|
|
||||||
slug: info.platform?.source_slug ?? info.system_slug
|
|
||||||
});
|
|
||||||
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`, { method: "HEAD" });
|
|
||||||
if (!platformCover.ok && platformLookup?.url_logo)
|
|
||||||
{
|
|
||||||
platformCover = await fetch(platformLookup.url_logo, { method: "HEAD" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!esPlatform && !info.platform)
|
|
||||||
{
|
|
||||||
// go to unknown platform
|
|
||||||
existingPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
|
|
||||||
|
|
||||||
if (existingPlatform)
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
type: "existing",
|
|
||||||
slug: existingPlatform.slug,
|
|
||||||
name: existingPlatform.name,
|
|
||||||
family_name: existingPlatform.family_name,
|
|
||||||
es_slug: existingPlatform.es_slug,
|
|
||||||
coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover`
|
|
||||||
};
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
return { type: "unknown" };
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
type: "new",
|
|
||||||
slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '',
|
|
||||||
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
|
|
||||||
family_name: info.platform?.family_name,
|
|
||||||
es_slug: esPlatform?.system.name ?? undefined,
|
|
||||||
coverUrl: platformCover.url
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
type: "existing",
|
|
||||||
slug: existingPlatform.slug,
|
|
||||||
name: existingPlatform.name,
|
|
||||||
family_name: existingPlatform.family_name,
|
|
||||||
es_slug: existingPlatform.es_slug,
|
|
||||||
coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createLocalGame (info: {
|
|
||||||
name: string;
|
|
||||||
system_slug: string | undefined;
|
|
||||||
source: string | undefined;
|
|
||||||
source_id: string | undefined;
|
|
||||||
slug: string | null | undefined;
|
|
||||||
path_fs: string | null | undefined;
|
|
||||||
summary: string | null | undefined;
|
|
||||||
igdb_id: number | undefined;
|
|
||||||
ra_id: number | undefined;
|
|
||||||
main_glob: string | undefined;
|
|
||||||
cover: Buffer<ArrayBufferLike> | undefined;
|
|
||||||
coverType: string | null | undefined;
|
|
||||||
version: string | undefined;
|
|
||||||
version_source: string | undefined;
|
|
||||||
screenshotUrls: string[];
|
|
||||||
version_system: string | undefined;
|
|
||||||
last_played?: Date;
|
|
||||||
metadata: LocalGameMetadata | undefined,
|
|
||||||
platform: {
|
|
||||||
igdb_id?: number;
|
|
||||||
igdb_slug?: string;
|
|
||||||
ra_id?: number;
|
|
||||||
moby_id?: number;
|
|
||||||
source: string;
|
|
||||||
source_id?: number;
|
|
||||||
source_slug?: string;
|
|
||||||
family_name?: string;
|
|
||||||
name?: string;
|
|
||||||
} | undefined;
|
|
||||||
})
|
|
||||||
{
|
|
||||||
const id = await db.transaction(async (tx) =>
|
|
||||||
{
|
|
||||||
// Search for existing platform
|
|
||||||
const platformSearch = [];
|
|
||||||
const esPlatformSearch = [];
|
|
||||||
if (info.system_slug)
|
|
||||||
{
|
|
||||||
platformSearch.push(eq(schema.platforms.slug, info.system_slug));
|
|
||||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.system, info.system_slug));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.platform)
|
|
||||||
{
|
|
||||||
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
|
|
||||||
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
|
|
||||||
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
|
|
||||||
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
|
|
||||||
|
|
||||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source));
|
|
||||||
if (info.platform.source_slug)
|
|
||||||
{
|
|
||||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug));
|
|
||||||
} else if (info.platform.source_id)
|
|
||||||
{
|
|
||||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id));
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
throw new Error("Must Provide at least one source id or slug");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
|
|
||||||
with: { system: true },
|
|
||||||
where: and(...esPlatformSearch)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (esPlatform)
|
|
||||||
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
|
|
||||||
|
|
||||||
let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
|
|
||||||
let platformId: number;
|
|
||||||
if (!existingPlatform)
|
|
||||||
{
|
|
||||||
// TODO: use something else than the romm demo as CDN
|
|
||||||
|
|
||||||
const platformLookup = await plugins.hooks.games.platformLookup.promise({
|
|
||||||
slug: info.platform?.source_slug ?? info.system_slug
|
|
||||||
});
|
|
||||||
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`);
|
|
||||||
if (!platformCover.ok && platformLookup?.url_logo)
|
|
||||||
{
|
|
||||||
platformCover = await fetch(platformLookup.url_logo);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!esPlatform && !info.platform)
|
|
||||||
{
|
|
||||||
// go to unknown platform
|
|
||||||
existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
|
|
||||||
|
|
||||||
if (existingPlatform)
|
|
||||||
{
|
|
||||||
platformId = existingPlatform.id;
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
const [{ id }] = await tx.insert(schema.platforms).values({
|
|
||||||
slug: 'unknown',
|
|
||||||
name: "Unknown"
|
|
||||||
}).returning({ id: schema.platforms.id });
|
|
||||||
platformId = id;
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
// Create new local platform
|
|
||||||
const platform: typeof schema.platforms.$inferInsert = {
|
|
||||||
slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '',
|
|
||||||
igdb_id: info.platform?.igdb_id,
|
|
||||||
igdb_slug: info.platform?.igdb_slug,
|
|
||||||
ra_id: info.platform?.ra_id,
|
|
||||||
cover: Buffer.from(await platformCover.arrayBuffer()),
|
|
||||||
cover_type: platformCover.headers.get('content-type'),
|
|
||||||
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
|
|
||||||
family_name: info.platform?.family_name,
|
|
||||||
es_slug: esPlatform?.system.name ?? undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: add ES slug once I have better way to query ES
|
|
||||||
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
|
|
||||||
platformId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
platformId = existingPlatform.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// create the rom
|
|
||||||
const game: typeof schema.games.$inferInsert = {
|
|
||||||
source_id: info.source_id,
|
|
||||||
source: info.source,
|
|
||||||
slug: info.slug,
|
|
||||||
path_fs: info.path_fs,
|
|
||||||
last_played: info.last_played,
|
|
||||||
platform_id: platformId,
|
|
||||||
igdb_id: info.igdb_id,
|
|
||||||
ra_id: info.ra_id,
|
|
||||||
summary: info.summary,
|
|
||||||
name: info.name,
|
|
||||||
cover: info.cover,
|
|
||||||
cover_type: info.coverType,
|
|
||||||
metadata: info.metadata,
|
|
||||||
main_glob: info.main_glob,
|
|
||||||
version: info.version,
|
|
||||||
version_source: info.version_source,
|
|
||||||
version_system: info.version_system
|
|
||||||
};
|
|
||||||
|
|
||||||
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
|
|
||||||
|
|
||||||
if (info.screenshotUrls.length <= 0 && info.igdb_id)
|
|
||||||
{
|
|
||||||
const matches = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(info.igdb_id) });
|
|
||||||
info.screenshotUrls.push(...(matches.values().next().value?.[0].screenshotUrls ?? []));
|
|
||||||
}
|
|
||||||
|
|
||||||
// pre-fetch screenshots
|
|
||||||
const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s)));
|
|
||||||
|
|
||||||
if (screenshots.length > 0)
|
|
||||||
{
|
|
||||||
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
|
||||||
{
|
|
||||||
const screenshot: typeof schema.screenshots.$inferInsert = {
|
|
||||||
game_id: id,
|
|
||||||
content: Buffer.from(await response.arrayBuffer()),
|
|
||||||
type: response.headers.get('content-type')
|
|
||||||
};
|
|
||||||
|
|
||||||
return screenshot;
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
return id;
|
|
||||||
});
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadGame (ctx: {
|
|
||||||
downloads: DownloadFileEntry[],
|
|
||||||
auth?: string,
|
|
||||||
id: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
setProgress?: (progress: number, state: "download" | "extract", info: Partial<Omit<ProgressStats, 'progress'>>) => void,
|
|
||||||
extract_path?: string;
|
|
||||||
path_fs?: string;
|
|
||||||
|
|
||||||
}): Promise<string[] | undefined>
|
|
||||||
{
|
|
||||||
const downloadedFiles = await plugins.hooks.downloadFiles.promise({
|
|
||||||
id: ctx.id,
|
|
||||||
auth: ctx.auth,
|
|
||||||
files: ctx.downloads,
|
|
||||||
downloadPath: config.get('downloadPath'),
|
|
||||||
abortSignal: ctx.abortSignal,
|
|
||||||
updateProgress: (stats) => ctx.setProgress?.(stats.progress, 'download', stats)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!downloadedFiles)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalFiles = await plugins.hooks.postDownloadFiles.promise({
|
|
||||||
files: downloadedFiles.files,
|
|
||||||
source: downloadedFiles.source,
|
|
||||||
extract_path: ctx.extract_path,
|
|
||||||
downloadPath: config.get('downloadPath'),
|
|
||||||
path_fs: ctx.path_fs
|
|
||||||
}) ?? downloadedFiles.files;
|
|
||||||
|
|
||||||
return finalFiles;
|
|
||||||
}
|
}
|
||||||
10
src/bun/api/hooks/app.ts
Normal file
10
src/bun/api/hooks/app.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { AuthHooks } from "./auth";
|
||||||
|
import { EmulatorHooks } from "./emulators";
|
||||||
|
import { GameHooks } from "./games";
|
||||||
|
|
||||||
|
export class GameflowHooks
|
||||||
|
{
|
||||||
|
games = new GameHooks();
|
||||||
|
emulators = new EmulatorHooks();
|
||||||
|
auth = new AuthHooks();
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
|
|
||||||
import { AsyncSeriesHook } from "tapable";
|
import { AsyncSeriesHook } from "tapable";
|
||||||
import { DownloadFileEntry } from "../shared";
|
|
||||||
|
|
||||||
export default class AuthHooks
|
export class AuthHooks
|
||||||
{
|
{
|
||||||
loginComplete = new AsyncSeriesHook<[ctx: {
|
loginComplete = new AsyncSeriesHook<[ctx: {
|
||||||
service: string;
|
service: string;
|
||||||
10
src/bun/api/hooks/emulators.ts
Normal file
10
src/bun/api/hooks/emulators.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { AsyncSeriesBailHook } from "tapable";
|
||||||
|
|
||||||
|
export class EmulatorHooks
|
||||||
|
{
|
||||||
|
fetchBiosDownload = new AsyncSeriesBailHook<[ctx: {
|
||||||
|
emulator: string;
|
||||||
|
systems: EmulatorSystem[];
|
||||||
|
biosFolder: string;
|
||||||
|
}], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']);
|
||||||
|
}
|
||||||
64
src/bun/api/hooks/games.ts
Normal file
64
src/bun/api/hooks/games.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { EmulatorPackageType, GameListFilterType } from '@/shared/constants';
|
||||||
|
import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook, AsyncHook, AsyncParallelHook, SyncHook, AsyncSeriesWaterfallHook } from 'tapable';
|
||||||
|
|
||||||
|
export class GameHooks
|
||||||
|
{
|
||||||
|
/** override the launch command for an emulator
|
||||||
|
* @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing
|
||||||
|
* @param ctx.emulator The emulator ID if any
|
||||||
|
* @param ctx.game.source The source of the game
|
||||||
|
* @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was
|
||||||
|
* @returns The argument list to be used when running the emulator.
|
||||||
|
* If no emulator bin in the command entry is found the actual command will be used as the bin.
|
||||||
|
*/
|
||||||
|
emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
|
||||||
|
autoValidCommand: CommandEntry;
|
||||||
|
game: {
|
||||||
|
source: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
}], string[] | undefined>(['ctx']);
|
||||||
|
/**
|
||||||
|
* Fetches and returns a list of games converted to frontend.
|
||||||
|
* @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>'
|
||||||
|
*/
|
||||||
|
fetchGames = new AsyncSeriesHook<[ctx: {
|
||||||
|
query: GameListFilterType;
|
||||||
|
games: FrontEndGameType[];
|
||||||
|
}]>(['ctx']);
|
||||||
|
fetchGame = new AsyncSeriesBailHook<[ctx: {
|
||||||
|
source: string;
|
||||||
|
localGame?: FrontEndGameTypeDetailed;
|
||||||
|
id: string;
|
||||||
|
}], FrontEndGameTypeDetailed | undefined>(['ctx']);
|
||||||
|
/** Get download file URLs
|
||||||
|
* @param ctx.checksum Check if file already exists using checksums
|
||||||
|
*/
|
||||||
|
fetchDownloads = new AsyncSeriesBailHook<[ctx: {
|
||||||
|
source: string;
|
||||||
|
id: string;
|
||||||
|
}], DownloadInfo | undefined>(['ctx']);
|
||||||
|
fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: {
|
||||||
|
game: FrontEndGameTypeDetailed,
|
||||||
|
games: (FrontEndGameType & { metadata?: any; })[];
|
||||||
|
}]>(['ctx']);
|
||||||
|
fetchRecommendedGamesForEmulator = new AsyncSeriesHook<[cts: {
|
||||||
|
emulator: EmulatorPackageType;
|
||||||
|
systems: EmulatorSystem[];
|
||||||
|
games: FrontEndGameType[];
|
||||||
|
}]>(['ctx']);
|
||||||
|
fetchPlatform = new AsyncSeriesBailHook<[ctx: {
|
||||||
|
source: string;
|
||||||
|
id: string;
|
||||||
|
}], FrontEndPlatformType | undefined>(['ctx']);
|
||||||
|
platformLookup = new AsyncSeriesBailHook<[ctx: {
|
||||||
|
source: string;
|
||||||
|
id: string;
|
||||||
|
}], { slug: string; } | undefined>(['ctx']);
|
||||||
|
fetchPlatforms = new AsyncSeriesHook<[ctx: {
|
||||||
|
platforms: FrontEndPlatformType[];
|
||||||
|
}]>(['ctx']);
|
||||||
|
updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]);
|
||||||
|
fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']);
|
||||||
|
fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']);
|
||||||
|
}
|
||||||
|
|
@ -1,44 +1,35 @@
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import z from "zod";
|
||||||
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { config, plugins } from "../app";
|
import { config, plugins } from "../app";
|
||||||
import { simulateProgress } from "@/bun/utils";
|
import { simulateProgress } from "@/bun/utils";
|
||||||
import { Downloader } from "@/bun/utils/downloader";
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||||
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
interface BiosDownloadJobData extends DownloadJobData
|
export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">
|
||||||
{
|
|
||||||
emulator: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
|
|
||||||
{
|
{
|
||||||
static id = "bios-download-job" as const;
|
static id = "bios-download-job" as const;
|
||||||
|
static dataSchema = z.object({ emulator: z.string() });
|
||||||
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
|
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
|
||||||
group: string = "bios-download";
|
group: string = "bios-download";
|
||||||
data: BiosDownloadJobData;
|
emulator: string;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
|
|
||||||
constructor(emulator: string, init?: { dryRun?: boolean; })
|
constructor(emulator: string, init?: { dryRun?: boolean; })
|
||||||
{
|
{
|
||||||
this.data = {
|
this.emulator = emulator;
|
||||||
emulator,
|
|
||||||
name: "Download Emulator Bios"
|
|
||||||
};
|
|
||||||
this.dryRun = init?.dryRun ?? false;
|
this.dryRun = init?.dryRun ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start (context: JobContext<IJob<BiosDownloadJobData, "download">, BiosDownloadJobData, "download">)
|
async start (context: JobContext<IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">, z.infer<typeof BiosDownloadJob.dataSchema>, "download">)
|
||||||
{
|
{
|
||||||
const emulator = await getStoreEmulatorPackage(this.data.emulator);
|
const emulator = await getStoreEmulatorPackage(this.emulator);
|
||||||
if (!emulator) throw new Error("Could Not Find Emulator");
|
if (!emulator) throw new Error("Could Not Find Emulator");
|
||||||
this.data.name = `${emulator.name} Bios`;
|
|
||||||
this.data.preview_url = emulator.logo;
|
|
||||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||||
const biosFolder = path.join(config.get('downloadPath'), "bios", this.data.emulator);
|
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
|
||||||
await ensureDir(biosFolder);
|
await ensureDir(biosFolder);
|
||||||
const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.data.emulator, systems, biosFolder });
|
const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.emulator, systems, biosFolder });
|
||||||
|
|
||||||
if (!files) throw new Error("Could not find source to download from");
|
if (!files) throw new Error("Could not find source to download from");
|
||||||
|
|
||||||
|
|
@ -54,12 +45,9 @@ export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
|
||||||
const downloader = new Downloader('bios-download', files.files, biosFolder, {
|
const downloader = new Downloader('bios-download', files.files, biosFolder, {
|
||||||
signal: context.abortSignal,
|
signal: context.abortSignal,
|
||||||
headers,
|
headers,
|
||||||
onProgress: (stats) =>
|
onProgress (stats)
|
||||||
{
|
{
|
||||||
context.setProgress(stats.progress, "download");
|
context.setProgress(stats.progress, "download");
|
||||||
this.data.downloaded = stats.downloaded;
|
|
||||||
this.data.speed = stats.speed;
|
|
||||||
this.data.total = stats.total;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -69,6 +57,6 @@ export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
|
||||||
|
|
||||||
exposeData ()
|
exposeData ()
|
||||||
{
|
{
|
||||||
return this.data;
|
return { emulator: this.emulator };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,54 +1,66 @@
|
||||||
import { DownloadJobData, EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared';
|
import { EmulatorPackageType } from "@/shared/constants";
|
||||||
import { getStoreEmulatorPackage } from "../store/services/gamesService";
|
import { getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { config, plugins } from "../app";
|
import z from "zod";
|
||||||
|
import { Glob } from "bun";
|
||||||
|
import { config } from "../app";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { getOrCachedGithubRelease } from "../cache";
|
||||||
import Seven from 'node-7z';
|
import Seven from 'node-7z';
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { Downloader } from "@/bun/utils/downloader";
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
import { ensureDir, move } from "fs-extra";
|
import { ensureDir, move } from "fs-extra";
|
||||||
import { isArchive, simulateProgress } from "@/bun/utils";
|
import { simulateProgress } from "@/bun/utils";
|
||||||
import { path7za } from "7zip-bin";
|
import { path7za } from "7zip-bin";
|
||||||
import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService";
|
|
||||||
import { $ } from "bun";
|
|
||||||
import { EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
type EmulatorDownloadStates = "download" | "extract";
|
type EmulatorDownloadStates = "download" | "extract";
|
||||||
|
|
||||||
interface EmulatorDownloadJobData extends DownloadJobData
|
export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>
|
||||||
{
|
|
||||||
emulator: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, EmulatorDownloadStates>
|
|
||||||
{
|
{
|
||||||
static id = "download-emulator" as const;
|
static id = "download-emulator" as const;
|
||||||
|
static dataSchema = z.object({ emulator: z.string() });
|
||||||
|
emulator: string;
|
||||||
downloadSource: string;
|
downloadSource: string;
|
||||||
emulatorPackage?: EmulatorPackageType;
|
emulatorPackage?: EmulatorPackageType;
|
||||||
dryRun: boolean;
|
dryRun?: boolean;
|
||||||
isUpdate: boolean;
|
|
||||||
data: EmulatorDownloadJobData = {
|
|
||||||
name: "Download Emulator",
|
|
||||||
emulator: ""
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; })
|
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; })
|
||||||
{
|
{
|
||||||
this.data.emulator = emulator;
|
this.emulator = emulator;
|
||||||
this.downloadSource = downloadSource;
|
this.downloadSource = downloadSource;
|
||||||
this.dryRun = init?.dryRun ?? false;
|
this.dryRun = init?.dryRun ?? false;
|
||||||
this.isUpdate = init?.isUpdate ?? false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async start (context: JobContext<EmulatorDownloadJob, EmulatorDownloadJobData, EmulatorDownloadStates>)
|
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
|
||||||
{
|
{
|
||||||
this.emulatorPackage = await getStoreEmulatorPackage(this.data.emulator);
|
this.emulatorPackage = await getStoreEmulatorPackage(this.emulator);
|
||||||
if (!this.emulatorPackage) throw new Error("Emulator not found");
|
if (!this.emulatorPackage) throw new Error("Emulator not found");
|
||||||
this.data.name = this.emulatorPackage.name;
|
if (!this.emulatorPackage.downloads) throw new Error("Emulator has no downloads");
|
||||||
this.data.preview_url = this.emulatorPackage.logo;
|
|
||||||
const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource);
|
|
||||||
|
|
||||||
const emulatorsFolder = getEmulatorPath(this.data.emulator);
|
const validDownloads = this.emulatorPackage.downloads[`${process.platform}:${process.arch}`];
|
||||||
|
if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`);
|
||||||
|
|
||||||
|
const validDownload = validDownloads.find(d => d.type === this.downloadSource);
|
||||||
|
if (!validDownload) throw new Error(`Download type ${this.downloadSource} not found`);
|
||||||
|
|
||||||
|
let downloadUrl: URL;
|
||||||
|
if (validDownload.type === 'github')
|
||||||
|
{
|
||||||
|
console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`);
|
||||||
|
const latestRelease = await getOrCachedGithubRelease(validDownload.path);
|
||||||
|
const glob = new Glob(validDownload.pattern);
|
||||||
|
const validAsset = latestRelease.assets.find(a => glob.match(a.name));
|
||||||
|
if (!validAsset) throw new Error("Could Not Find Valid Asset");
|
||||||
|
downloadUrl = new URL(validAsset.browser_download_url);
|
||||||
|
} else if (validDownload.type === 'direct')
|
||||||
|
{
|
||||||
|
downloadUrl = new URL(validDownload.url);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
throw new Error("Download Type Unsupported");
|
||||||
|
}
|
||||||
|
|
||||||
|
const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator);
|
||||||
|
|
||||||
if (this.dryRun)
|
if (this.dryRun)
|
||||||
{
|
{
|
||||||
|
|
@ -57,54 +69,41 @@ export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, Emulat
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
||||||
const downloader = new Downloader(this.data.emulator,
|
const downloader = new Downloader(this.emulator,
|
||||||
[{ url, file_name: path.basename(url.pathname), file_path: this.data.emulator }],
|
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl.pathname), file_path: this.emulator }],
|
||||||
tmpFolder,
|
tmpFolder,
|
||||||
{
|
{
|
||||||
signal: context.abortSignal,
|
signal: context.abortSignal,
|
||||||
onProgress: (stats) =>
|
onProgress (stats)
|
||||||
{
|
{
|
||||||
context.setProgress(stats.progress, 'download');
|
context.setProgress(stats.progress, 'download');
|
||||||
this.data.total = stats.total;
|
|
||||||
this.data.downloaded = stats.downloaded;
|
|
||||||
this.data.speed = stats.speed;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const destinationPaths = await downloader.start();
|
const destinationPaths = await downloader.start();
|
||||||
context.abortSignal.throwIfAborted();
|
|
||||||
if (destinationPaths)
|
if (destinationPaths)
|
||||||
{
|
{
|
||||||
const archive = isArchive(destinationPaths[0]);
|
const isArchive = destinationPaths[0].endsWith('.7z') || destinationPaths[0].endsWith('.zip');
|
||||||
const isAppImage = destinationPaths[0].endsWith(".AppImage");
|
const isAppImage = destinationPaths[0].endsWith(".AppImage");
|
||||||
|
|
||||||
if (!archive && !isAppImage)
|
if (!isArchive && !isAppImage)
|
||||||
{
|
{
|
||||||
throw new Error("Invalid Download Type");
|
throw new Error("Invalid Download Type");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (archive)
|
if (isArchive)
|
||||||
{
|
{
|
||||||
if (destinationPaths[0])
|
if (destinationPaths[0])
|
||||||
{
|
{
|
||||||
let destinationPath = destinationPaths[0];
|
let destinationPath = destinationPaths[0];
|
||||||
if (destinationPath.endsWith('.tar'))
|
await new Promise((resolve, reject) =>
|
||||||
{
|
{
|
||||||
context.setProgress(0, "extract");
|
const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true });
|
||||||
await ensureDir(emulatorsFolder);
|
seven.on('progress', p => context.setProgress(p.percent, "extract"));
|
||||||
await $`tar -xf ${destinationPath} -C ${emulatorsFolder}`;
|
seven.on('error', e => reject(e));
|
||||||
await fs.rm(destinationPath, { recursive: true });
|
seven.on('end', () => resolve(true));
|
||||||
} else
|
});
|
||||||
{
|
await fs.rm(destinationPath, { recursive: true });
|
||||||
await new Promise((resolve, reject) =>
|
|
||||||
{
|
|
||||||
const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true });
|
|
||||||
seven.on('progress', p => context.setProgress(p.percent, "extract"));
|
|
||||||
seven.on('error', e => reject(e));
|
|
||||||
seven.on('end', () => resolve(true));
|
|
||||||
});
|
|
||||||
await fs.rm(destinationPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if 1 root folder we need to get rid of
|
// check if 1 root folder we need to get rid of
|
||||||
const contents = await fs.readdir(emulatorsFolder);
|
const contents = await fs.readdir(emulatorsFolder);
|
||||||
|
|
@ -128,19 +127,6 @@ export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, Emulat
|
||||||
await fs.rename(destPath, path.join(emulatorsFolder, path.basename(destPath)));
|
await fs.rename(destPath, path.join(emulatorsFolder, path.basename(destPath)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3));
|
|
||||||
|
|
||||||
const execs: EmulatorSourceEntryType[] = [];
|
|
||||||
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: this.data.emulator, sources: execs });
|
|
||||||
|
|
||||||
await plugins.hooks.emulators.emulatorPostInstall.promise({
|
|
||||||
emulator: this.data.emulator,
|
|
||||||
emulatorPackage: this.emulatorPackage,
|
|
||||||
path: execs.find(e => e.type === 'store')?.binPath ?? emulatorsFolder,
|
|
||||||
info,
|
|
||||||
update: this.isUpdate
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +134,7 @@ export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, Emulat
|
||||||
|
|
||||||
exposeData ()
|
exposeData ()
|
||||||
{
|
{
|
||||||
return this.data;
|
return { emulator: this.emulator };
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { ensureDir } from "fs-extra";
|
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
|
||||||
import { getStoreRootFolder } from "../store/services/gamesService";
|
|
||||||
import z from "zod";
|
|
||||||
import { runBunPackageCommand } from "../plugins/services";
|
|
||||||
import { PluginRegistry } from "@/shared/constants";
|
|
||||||
import path from "node:path";
|
|
||||||
import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json';
|
|
||||||
import { IsPluginAllowed } from "@/bun/utils";
|
|
||||||
|
|
||||||
export default class EnsureStore implements IJob<never, string>
|
|
||||||
{
|
|
||||||
static id = "update-store" as const;
|
|
||||||
static dataSchema = z.never();
|
|
||||||
packageName: string;
|
|
||||||
storeVersion: string;
|
|
||||||
|
|
||||||
constructor()
|
|
||||||
{
|
|
||||||
this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store";
|
|
||||||
this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0";
|
|
||||||
}
|
|
||||||
|
|
||||||
async start (context: JobContext<EnsureStore, never, string>)
|
|
||||||
{
|
|
||||||
const storeFolder = getStoreRootFolder();
|
|
||||||
await ensureDir(storeFolder);
|
|
||||||
const storePackageFile = Bun.file(path.join(storeFolder, "package.json"));
|
|
||||||
if (!await storePackageFile.exists())
|
|
||||||
{
|
|
||||||
await storePackageFile.write(JSON.stringify({ dependencies: {} }, null, 3));
|
|
||||||
}
|
|
||||||
|
|
||||||
const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json();
|
|
||||||
|
|
||||||
if (IsPluginAllowed(sdkPkg.name))
|
|
||||||
{
|
|
||||||
if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
|
|
||||||
{
|
|
||||||
let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']);
|
|
||||||
console.log(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
// probably just means we couldn't find a version of the sdk, just install latest
|
|
||||||
if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
|
|
||||||
{
|
|
||||||
let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']);
|
|
||||||
console.log(response);
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
console.log("Ignoring SDK package");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.CUSTOM_STORE_PATH) return;
|
|
||||||
|
|
||||||
if (!storePackage.dependencies?.['@simeonradivoev/gameflow-store'])
|
|
||||||
{
|
|
||||||
context.setProgress(0.5, "Adding Store");
|
|
||||||
let response = await runBunPackageCommand(["add", `${this.packageName}@${this.storeVersion}`, "--registry", PluginRegistry, '--omit', 'peer']);
|
|
||||||
console.log(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { eq, inArray, or } from "drizzle-orm";
|
|
||||||
import { db, plugins } from "../app";
|
|
||||||
import { createLocalGame, downloadGame } from "../games/services/utils";
|
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
|
||||||
import * as schema from "@schema/app";
|
|
||||||
import { DownloadJobData, GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import { isUrl } from "@/shared/utils";
|
|
||||||
import { basename } from "node:path";
|
|
||||||
import path from 'node:path';
|
|
||||||
import { isArchive } from "@/bun/utils";
|
|
||||||
|
|
||||||
interface ImportJobData extends DownloadJobData
|
|
||||||
{
|
|
||||||
localId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ImportJob implements IJob<ImportJobData, string>
|
|
||||||
{
|
|
||||||
static id = "import-job" as const;
|
|
||||||
static query = (q: { source: string; id: string; }) => `${ImportJob.id}-${q.source}-${q.id}`;
|
|
||||||
data: ImportJobData = {
|
|
||||||
localId: null,
|
|
||||||
name: "Import Game"
|
|
||||||
};
|
|
||||||
group?: 'import-job';
|
|
||||||
gamePath: string;
|
|
||||||
source: string;
|
|
||||||
id: string;
|
|
||||||
platformId: number;
|
|
||||||
|
|
||||||
constructor(source: string, id: string, gamePath: string, platformId: number)
|
|
||||||
{
|
|
||||||
this.gamePath = gamePath;
|
|
||||||
this.source = source;
|
|
||||||
this.id = id;
|
|
||||||
this.platformId = platformId;
|
|
||||||
}
|
|
||||||
|
|
||||||
exposeData ()
|
|
||||||
{
|
|
||||||
return this.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start (context: JobContext<IJob<ImportJobData, string>, ImportJobData, string>): Promise<any>
|
|
||||||
{
|
|
||||||
const matchesMap = new Map<string, GameLookup[]>();
|
|
||||||
await plugins.hooks.games.gameLookup.promise(matchesMap, { source: this.source, id: this.id });
|
|
||||||
const matches = matchesMap.values().next().value;
|
|
||||||
if (!matches || matches.length <= 0) throw Error("Could not Find Game");
|
|
||||||
const match = matches[0];
|
|
||||||
this.data.name = match.name;
|
|
||||||
this.data.preview_url = match.coverUrl;
|
|
||||||
|
|
||||||
let cover: Buffer<ArrayBufferLike> | undefined = undefined;
|
|
||||||
let coverType: string | undefined = undefined;
|
|
||||||
if (match.coverUrl)
|
|
||||||
{
|
|
||||||
const coverResponse = await fetch(match.coverUrl);
|
|
||||||
if (coverResponse.ok)
|
|
||||||
{
|
|
||||||
cover = Buffer.from(await coverResponse.arrayBuffer());
|
|
||||||
coverType = coverResponse.headers.get('content-type') ?? undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformMatch = match.platforms.find(p => p.id === this.platformId);
|
|
||||||
|
|
||||||
const finalFiles: string[] = [];
|
|
||||||
|
|
||||||
if (isUrl(this.gamePath))
|
|
||||||
{
|
|
||||||
const archive = isArchive(this.gamePath);
|
|
||||||
const downloadedFiles = await downloadGame({
|
|
||||||
downloads: [{
|
|
||||||
file_path: this.id,
|
|
||||||
file_name: basename(this.gamePath),
|
|
||||||
url: new URL(this.gamePath)
|
|
||||||
}],
|
|
||||||
extract_path: archive ? '.tmp' : undefined,
|
|
||||||
path_fs: path.join('roms', platformMatch?.slug ?? this.source, this.id),
|
|
||||||
abortSignal: context.abortSignal,
|
|
||||||
id: `game-${this.source}-${this.id}`,
|
|
||||||
setProgress: (progress, state, info) =>
|
|
||||||
{
|
|
||||||
context.setProgress(progress, state);
|
|
||||||
this.data.speed = info.speed;
|
|
||||||
this.data.total = info.total;
|
|
||||||
this.data.downloaded = info.downloaded;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (downloadedFiles)
|
|
||||||
finalFiles.push(...downloadedFiles);
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
finalFiles.push(this.gamePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const localSearchFilters: any[] = [];
|
|
||||||
if (match.igdb_id) localSearchFilters.push(eq(schema.games.igdb_id, match.igdb_id));
|
|
||||||
if (match.slug) localSearchFilters.push(eq(schema.games.slug, match.slug));
|
|
||||||
localSearchFilters.push(eq(schema.games.name, match.name));
|
|
||||||
localSearchFilters.push(inArray(schema.games.path_fs, finalFiles));
|
|
||||||
const existingLocalGame = await db.query.games.findFirst({ where: or(...localSearchFilters) });
|
|
||||||
context.abortSignal.throwIfAborted();
|
|
||||||
|
|
||||||
if (existingLocalGame) throw new Error("Game Already Exists");
|
|
||||||
|
|
||||||
this.data.localId = await createLocalGame({
|
|
||||||
name: match.name,
|
|
||||||
system_slug: platformMatch?.slug,
|
|
||||||
source: undefined,
|
|
||||||
source_id: undefined,
|
|
||||||
slug: match.slug,
|
|
||||||
path_fs: finalFiles[0],
|
|
||||||
summary: match.summary,
|
|
||||||
igdb_id: match.igdb_id,
|
|
||||||
ra_id: undefined,
|
|
||||||
main_glob: undefined,
|
|
||||||
cover,
|
|
||||||
coverType,
|
|
||||||
version: undefined,
|
|
||||||
version_source: undefined,
|
|
||||||
screenshotUrls: match.screenshotUrls,
|
|
||||||
version_system: undefined,
|
|
||||||
platform: platformMatch ? {
|
|
||||||
source_slug: platformMatch.slug,
|
|
||||||
source_id: platformMatch.id,
|
|
||||||
source: this.source,
|
|
||||||
name: platformMatch.displayName
|
|
||||||
} : undefined,
|
|
||||||
metadata: {
|
|
||||||
game_modes: match.game_modes,
|
|
||||||
companies: match.companies,
|
|
||||||
first_release_date: match.first_release_date ?? undefined,
|
|
||||||
player_count: match.player_count,
|
|
||||||
age_ratings: match.age_ratings,
|
|
||||||
average_rating: match.average_rating,
|
|
||||||
genres: match.genres,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +1,30 @@
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
|
import { and, eq, or } from 'drizzle-orm';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import * as schema from "@schema/app";
|
||||||
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { config, events, plugins } from "../app";
|
import { config, db, emulatorsDb, events, plugins } from "../app";
|
||||||
|
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||||
|
import * as igdb from 'ts-igdb-client';
|
||||||
|
import secrets from "../secrets";
|
||||||
import { simulateProgress } from "@/bun/utils";
|
import { simulateProgress } from "@/bun/utils";
|
||||||
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
|
import Seven from 'node-7z';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils";
|
import { checkFiles } from "../games/services/utils";
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
|
import { path7za } from "7zip-bin";
|
||||||
|
|
||||||
interface JobConfig
|
interface JobConfig
|
||||||
{
|
{
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
dryDownload?: boolean;
|
dryDownload?: boolean;
|
||||||
downloadId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InstallJobStates = 'download' | 'extract';
|
export type InstallJobStates = 'download' | 'extract';
|
||||||
|
|
||||||
export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
|
export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
{
|
{
|
||||||
static id = "install-job" as const;
|
static id = "install-job" as const;
|
||||||
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
|
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
|
||||||
|
|
@ -28,10 +35,6 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
|
||||||
// The local game ID of newly created entry, if successful
|
// The local game ID of newly created entry, if successful
|
||||||
public localGameId?: number;
|
public localGameId?: number;
|
||||||
public group = InstallJob.id;
|
public group = InstallJob.id;
|
||||||
public localPath?: string;
|
|
||||||
data: DownloadJobData = {
|
|
||||||
name: "Install Game"
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(id: string, source: string, config?: JobConfig)
|
constructor(id: string, source: string, config?: JobConfig)
|
||||||
{
|
{
|
||||||
|
|
@ -40,47 +43,91 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
|
||||||
this.source = source;
|
this.source = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start (cx: JobContext<InstallJob, DownloadJobData, InstallJobStates>)
|
public async start (cx: JobContext<InstallJob, never, InstallJobStates>)
|
||||||
{
|
{
|
||||||
cx.setProgress(0, 'download');
|
cx.setProgress(0, 'download');
|
||||||
await fs.mkdir(config.get('downloadPath'), { recursive: true });
|
await fs.mkdir(config.get('downloadPath'), { recursive: true });
|
||||||
|
|
||||||
const downloadPath = config.get('downloadPath');
|
const downloadPath = config.get('downloadPath');
|
||||||
const finalFiles: string[] = [];
|
|
||||||
let info: DownloadInfo | undefined;
|
let info: DownloadInfo | undefined;
|
||||||
|
|
||||||
|
switch (this.source)
|
||||||
|
{
|
||||||
|
case 'store':
|
||||||
|
const game = await getStoreGameFromId(this.gameId);
|
||||||
|
const gameId = extractStoreGameSourceId(this.gameId);
|
||||||
|
info = {
|
||||||
|
coverUrl: game.pictures.titlescreens[0],
|
||||||
|
screenshotUrls: game.pictures.screenshots,
|
||||||
|
files: [{
|
||||||
|
url: new URL(game.file),
|
||||||
|
file_path: `roms/${game.system}`,
|
||||||
|
file_name: path.basename(decodeURI(game.file)),
|
||||||
|
size: 0
|
||||||
|
}],
|
||||||
|
slug: this.gameId,
|
||||||
|
source_id: this.gameId,
|
||||||
|
name: game.title,
|
||||||
|
summary: game.description,
|
||||||
|
system_slug: gameId.system,
|
||||||
|
extract_path: path.join('roms', gameId.system),
|
||||||
|
};
|
||||||
|
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
|
||||||
|
|
||||||
|
const files = await checkFiles(info.files, !!info.extract_path);
|
||||||
|
|
||||||
if (this.config?.dryRun !== true)
|
if (this.config?.dryRun !== true)
|
||||||
{
|
{
|
||||||
const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
|
|
||||||
info = allDownloads?.[0];
|
|
||||||
|
|
||||||
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
|
|
||||||
|
|
||||||
this.data.name = info.name;
|
|
||||||
this.data.preview_url = info.coverUrl;
|
|
||||||
|
|
||||||
const files = await checkFiles(info.files, !!info.extract_path);
|
|
||||||
|
|
||||||
if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches))
|
if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches))
|
||||||
{
|
{
|
||||||
const downloadedFiles = await downloadGame({
|
const headers: Record<string, string> = {};
|
||||||
downloads: files.filter(f => !f.exists || !f.matches),
|
if (info.auth)
|
||||||
extract_path: info.extract_path,
|
headers['Authorization'] = info.auth;
|
||||||
path_fs: info.path_fs,
|
const downloader = new Downloader(`game-${this.source}-${this.gameId}`,
|
||||||
abortSignal: cx.abortSignal,
|
files.filter(f => !f.exists || !f.matches),
|
||||||
auth: info.auth,
|
config.get('downloadPath'),
|
||||||
id: `game-${this.source}-${this.gameId}`,
|
|
||||||
setProgress: (process, state, info) =>
|
|
||||||
{
|
{
|
||||||
cx.setProgress(process, state);
|
signal: cx.abortSignal,
|
||||||
this.data.downloaded = info.downloaded;
|
headers,
|
||||||
this.data.speed = info.speed;
|
onProgress (stats)
|
||||||
this.data.total = info.total;
|
{
|
||||||
},
|
cx.setProgress(stats.progress, 'download');
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (downloadedFiles)
|
const downloadedFiles = await downloader.start();
|
||||||
finalFiles.push(...downloadedFiles);
|
if (info.extract_path && downloadedFiles)
|
||||||
|
{
|
||||||
|
let progress = 0;
|
||||||
|
const progressDelta = 1 / downloadedFiles.length;
|
||||||
|
for (const filePath of downloadedFiles)
|
||||||
|
{
|
||||||
|
const extractPath = path.join(config.get('downloadPath'), info.extract_path);
|
||||||
|
await new Promise((resolve, reject) =>
|
||||||
|
{
|
||||||
|
const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true });
|
||||||
|
seven.on('progress', p =>
|
||||||
|
{
|
||||||
|
cx.setProgress(progress + p.percent * progressDelta, "extract");
|
||||||
|
});
|
||||||
|
|
||||||
|
seven.on('error', e => reject(e));
|
||||||
|
seven.on('end', async () =>
|
||||||
|
{
|
||||||
|
await fs.rm(filePath);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
progress += progressDelta * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config?.dryDownload === true && info.extract_path)
|
if (this.config?.dryDownload === true && info.extract_path)
|
||||||
|
|
@ -91,34 +138,138 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
|
||||||
const coverResponse = await fetch(info.coverUrl);
|
const coverResponse = await fetch(info.coverUrl);
|
||||||
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
||||||
|
|
||||||
cx.abortSignal.throwIfAborted();
|
if (cx.abortSignal.aborted) return;
|
||||||
|
|
||||||
this.localGameId = await createLocalGame({
|
await db.transaction(async (tx) =>
|
||||||
cover,
|
{
|
||||||
coverType: coverResponse.headers.get('content-type'),
|
// Search for existing platform
|
||||||
system_slug: info.system_slug,
|
const platformSearch = [eq(schema.platforms.slug, info.system_slug)];
|
||||||
source_id: info.source_id,
|
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)];
|
||||||
source: this.source,
|
|
||||||
slug: info.slug,
|
if (info.platform)
|
||||||
path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
|
{
|
||||||
summary: info.summary,
|
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
|
||||||
igdb_id: info.igdb_id,
|
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
|
||||||
ra_id: info.ra_id,
|
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
|
||||||
name: info.name,
|
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
|
||||||
main_glob: info.main_glob,
|
|
||||||
version: info.version,
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm'));
|
||||||
version_source: info.version_source,
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.slug));
|
||||||
screenshotUrls: info.screenshotUrls,
|
}
|
||||||
version_system: info.version_system,
|
|
||||||
metadata: info.metadata,
|
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
|
||||||
platform: info.platform
|
with: { system: true },
|
||||||
|
where: and(...esPlatformSearch)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (esPlatform)
|
||||||
|
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
|
||||||
|
|
||||||
|
let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
|
||||||
|
let platformId: number;
|
||||||
|
if (!existingPlatform)
|
||||||
|
{
|
||||||
|
// TODO: use something else than the romm demo as CDN
|
||||||
|
const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.system_slug}.svg`);
|
||||||
|
|
||||||
|
if (!esPlatform && !info.platform)
|
||||||
|
{
|
||||||
|
// go to unknown platform
|
||||||
|
existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
|
||||||
|
|
||||||
|
if (existingPlatform)
|
||||||
|
{
|
||||||
|
platformId = existingPlatform.id;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const [{ id }] = await tx.insert(schema.platforms).values({
|
||||||
|
slug: 'unknown',
|
||||||
|
name: "Unknown"
|
||||||
|
}).returning({ id: schema.platforms.id });
|
||||||
|
platformId = id;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
// Create new local platform
|
||||||
|
const platform: typeof schema.platforms.$inferInsert = {
|
||||||
|
slug: info.platform?.slug ?? esPlatform?.system.name ?? '',
|
||||||
|
igdb_id: info.platform?.igdb_id,
|
||||||
|
igdb_slug: info.platform?.igdb_slug,
|
||||||
|
ra_id: info.platform?.ra_id,
|
||||||
|
cover: Buffer.from(await platformCover.arrayBuffer()),
|
||||||
|
cover_type: platformCover.headers.get('content-type'),
|
||||||
|
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
|
||||||
|
family_name: info.platform?.family_name,
|
||||||
|
es_slug: esPlatform?.system.name ?? undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: add ES slug once I have better way to query ES
|
||||||
|
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
|
||||||
|
platformId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
platformId = existingPlatform.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create the rom
|
||||||
|
const game: typeof schema.games.$inferInsert = {
|
||||||
|
source_id: info.source_id,
|
||||||
|
source: this.source,
|
||||||
|
slug: info.slug,
|
||||||
|
path_fs: info.path_fs,
|
||||||
|
last_played: info.last_played,
|
||||||
|
platform_id: platformId,
|
||||||
|
igdb_id: info.igdb_id,
|
||||||
|
ra_id: info.ra_id,
|
||||||
|
summary: info.summary,
|
||||||
|
name: info.name,
|
||||||
|
cover,
|
||||||
|
cover_type: coverResponse.headers.get('content-type'),
|
||||||
|
metadata: info.metadata
|
||||||
|
};
|
||||||
|
|
||||||
|
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
|
||||||
|
|
||||||
|
if (info.screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID)
|
||||||
|
{
|
||||||
|
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
||||||
|
if (access_token)
|
||||||
|
{
|
||||||
|
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
|
||||||
|
|
||||||
|
const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', info.igdb_id)).execute();
|
||||||
|
|
||||||
|
info.screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pre-fetch screenshots
|
||||||
|
const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s)));
|
||||||
|
|
||||||
|
if (screenshots.length > 0)
|
||||||
|
{
|
||||||
|
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
||||||
|
{
|
||||||
|
const screenshot: typeof schema.screenshots.$inferInsert = {
|
||||||
|
game_id: id,
|
||||||
|
content: Buffer.from(await response.arrayBuffer()),
|
||||||
|
type: response.headers.get('content-type')
|
||||||
|
};
|
||||||
|
|
||||||
|
return screenshot;
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.localGameId = id;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.source && this.gameId) await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info });
|
|
||||||
events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 });
|
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal);
|
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,24 +3,21 @@ import z, { _ZodType } from "zod";
|
||||||
import { taskQueue } from "../app";
|
import { taskQueue } from "../app";
|
||||||
import { LoginJob } from "./login-job";
|
import { LoginJob } from "./login-job";
|
||||||
import TwitchLoginJob from "./twitch-login-job";
|
import TwitchLoginJob from "./twitch-login-job";
|
||||||
import EnsureStore from "./ensure-store";
|
import UpdateStoreJob from "./update-store";
|
||||||
import { EmulatorDownloadJob } from "./emulator-download-job";
|
import { EmulatorDownloadJob } from "./emulator-download-job";
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
import { getErrorMessage } from "@/bun/utils";
|
||||||
import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import { IJob } from "../task-queue";
|
||||||
import { LaunchGameJob } from "./launch-game-job";
|
import { LaunchGameJob } from "./launch-game-job";
|
||||||
import { BiosDownloadJob } from "./bios-download-job";
|
import { BiosDownloadJob } from "./bios-download-job";
|
||||||
import { InstallJob } from "./install-job";
|
import { InstallJob } from "./install-job";
|
||||||
import ReloadPluginsJob from "./reload-plugins-job";
|
|
||||||
import { FrontEndJob } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
function registerJob<
|
function registerJob<
|
||||||
const Path extends string,
|
const Path extends string,
|
||||||
Schema,
|
const Schema extends z.ZodTypeAny,
|
||||||
|
const Query extends z.ZodTypeAny,
|
||||||
const States extends string,
|
const States extends string,
|
||||||
> (_job: {
|
T extends IJob<z.infer<Schema>, States>
|
||||||
id: Path;
|
> (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T))
|
||||||
query?: (q: any) => string;
|
|
||||||
} & (new (...args: any[]) => IJob<Schema, States>))
|
|
||||||
{
|
{
|
||||||
return new Elysia().ws(_job.id, {
|
return new Elysia().ws(_job.id, {
|
||||||
body: z.discriminatedUnion('type', [
|
body: z.discriminatedUnion('type', [
|
||||||
|
|
@ -32,10 +29,9 @@ function registerJob<
|
||||||
type: z.literal(['data', 'started', 'progress']),
|
type: z.literal(['data', 'started', 'progress']),
|
||||||
state: z.string().optional(),
|
state: z.string().optional(),
|
||||||
progress: z.number(),
|
progress: z.number(),
|
||||||
data: z.custom<Schema>()
|
data: _job.dataSchema
|
||||||
}),
|
}),
|
||||||
z.object({ type: z.literal(['completed', 'ended']), data: z.custom<Schema>() }),
|
z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }),
|
||||||
z.object({ type: z.literal('waiting') }),
|
|
||||||
z.object({ type: z.literal('error'), error: z.string() })
|
z.object({ type: z.literal('error'), error: z.string() })
|
||||||
]),
|
]),
|
||||||
open (ws)
|
open (ws)
|
||||||
|
|
@ -44,10 +40,7 @@ function registerJob<
|
||||||
const job = taskQueue.findJob(jobId, _job);
|
const job = taskQueue.findJob(jobId, _job);
|
||||||
if (job)
|
if (job)
|
||||||
{
|
{
|
||||||
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() as Schema });
|
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||||
} else
|
|
||||||
{
|
|
||||||
ws.send({ type: 'waiting' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(ws.data as any).cleanup = [
|
(ws.data as any).cleanup = [
|
||||||
|
|
@ -104,88 +97,10 @@ function registerJob<
|
||||||
}
|
}
|
||||||
|
|
||||||
export const jobs = new Elysia({ prefix: '/api/jobs' })
|
export const jobs = new Elysia({ prefix: '/api/jobs' })
|
||||||
.ws('/list', {
|
|
||||||
response: z.discriminatedUnion('type', [
|
|
||||||
z.object({ type: z.literal("allJobs"), active: z.custom<FrontEndJob>().array(), queued: z.custom<FrontEndJob>().array() }),
|
|
||||||
z.object({ type: z.literal("started"), job: z.custom<FrontEndJob>() }),
|
|
||||||
z.object({ type: z.literal("progress"), job: z.custom<FrontEndJob>() }),
|
|
||||||
z.object({ type: z.literal("queued"), job: z.custom<FrontEndJob>() }),
|
|
||||||
z.object({ type: z.literal("aborted"), id: z.string() }),
|
|
||||||
z.object({ type: z.literal("ended"), id: z.string() }),
|
|
||||||
]),
|
|
||||||
body: z.discriminatedUnion('type', [
|
|
||||||
z.object({ type: z.literal("cancel"), id: z.string() })
|
|
||||||
]),
|
|
||||||
message (ws, message)
|
|
||||||
{
|
|
||||||
switch (message.type)
|
|
||||||
{
|
|
||||||
case "cancel":
|
|
||||||
taskQueue.cancelJob(message.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
open (ws)
|
|
||||||
{
|
|
||||||
ws.send({
|
|
||||||
type: 'allJobs',
|
|
||||||
active: taskQueue.getActiveJobs().map(j =>
|
|
||||||
{
|
|
||||||
const job: FrontEndJob = {
|
|
||||||
id: j.id,
|
|
||||||
data: j.job.exposeData?.(),
|
|
||||||
progress: j.progress,
|
|
||||||
state: j.state,
|
|
||||||
status: j.status
|
|
||||||
};
|
|
||||||
|
|
||||||
return job;
|
|
||||||
}),
|
|
||||||
queued: taskQueue.getQueuedJobs()?.map(j =>
|
|
||||||
{
|
|
||||||
const job: FrontEndJob = {
|
|
||||||
id: j.id,
|
|
||||||
data: j.job.exposeData?.(),
|
|
||||||
progress: j.progress,
|
|
||||||
state: j.state,
|
|
||||||
status: j.status
|
|
||||||
};
|
|
||||||
|
|
||||||
return job;
|
|
||||||
}) ?? []
|
|
||||||
});
|
|
||||||
|
|
||||||
(ws.data as any).dispose = [taskQueue.on('started', (e: BaseEvent) =>
|
|
||||||
{
|
|
||||||
ws.send({ type: "started", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
|
|
||||||
}),
|
|
||||||
taskQueue.on('progress', (e: BaseEvent) =>
|
|
||||||
{
|
|
||||||
ws.send({ type: "progress", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
|
|
||||||
}),
|
|
||||||
taskQueue.on('queued', (e: BaseEvent) =>
|
|
||||||
{
|
|
||||||
ws.send({ type: "queued", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
|
|
||||||
}),
|
|
||||||
taskQueue.on('abort', (e: BaseEvent) =>
|
|
||||||
{
|
|
||||||
ws.send({ type: "aborted", id: e.id });
|
|
||||||
}),
|
|
||||||
taskQueue.on('ended', (e: BaseEvent) =>
|
|
||||||
{
|
|
||||||
ws.send({ type: "ended", id: e.id });
|
|
||||||
})];
|
|
||||||
},
|
|
||||||
close (ws, code, reason)
|
|
||||||
{
|
|
||||||
(ws.data as any).dispose.forEach((d: any) => d());
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.use(registerJob(LaunchGameJob))
|
|
||||||
.use(registerJob(LoginJob))
|
.use(registerJob(LoginJob))
|
||||||
.use(registerJob(TwitchLoginJob))
|
.use(registerJob(TwitchLoginJob))
|
||||||
.use(registerJob(EnsureStore))
|
.use(registerJob(UpdateStoreJob))
|
||||||
|
.use(registerJob(LaunchGameJob))
|
||||||
.use(registerJob(BiosDownloadJob))
|
.use(registerJob(BiosDownloadJob))
|
||||||
.use(registerJob(InstallJob))
|
.use(registerJob(InstallJob))
|
||||||
.use(registerJob(ReloadPluginsJob))
|
|
||||||
.use(registerJob(EmulatorDownloadJob));
|
.use(registerJob(EmulatorDownloadJob));
|
||||||
|
|
|
||||||
|
|
@ -1,272 +1,143 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk";
|
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema";
|
||||||
import { config, db, events, plugins } from "../app";
|
import { db, events, plugins } from "../app";
|
||||||
import * as appSchema from "@schema/app";
|
import * as appSchema from "@schema/app";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { spawn } from 'node:child_process';
|
import { ChildProcessWithoutNullStreams, 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>
|
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">
|
||||||
{
|
{
|
||||||
static id = "launch-game" as const;
|
static id = "launch-game" as const;
|
||||||
static dataSchema = z.nullable(ActiveGameSchema);
|
static dataSchema = z.optional(ActiveGameSchema);
|
||||||
group = "launch-game";
|
group = "launch-game";
|
||||||
activeGame: ActiveGameType | null;
|
activeGame?: ActiveGameType;
|
||||||
gameId: FrontEndId;
|
gameId: number;
|
||||||
validCommand: CommandEntry;
|
validCommand: CommandEntry;
|
||||||
gameSource?: string;
|
gameSource: string;
|
||||||
gameSourceId?: string;
|
gameSourceId: string;
|
||||||
changedSaveFiles: Map<string, { subPath: string, cwd: string; }>;
|
|
||||||
saveSlots: SaveSlots = {};
|
|
||||||
|
|
||||||
constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
|
constructor(gameId: number, validCommand: CommandEntry, source: string, sourceId: string)
|
||||||
{
|
{
|
||||||
this.gameId = gameId;
|
this.gameId = gameId;
|
||||||
this.validCommand = validCommand;
|
this.validCommand = validCommand;
|
||||||
this.gameSource = source;
|
this.gameSource = source;
|
||||||
this.gameSourceId = sourceId;
|
this.gameSourceId = sourceId;
|
||||||
this.activeGame = null;
|
|
||||||
this.changedSaveFiles = new Map();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async postPlay (gameInfo: { platformSlug?: string; })
|
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">, z.infer<typeof LaunchGameJob.dataSchema>, "playing">)
|
||||||
{
|
{
|
||||||
if (this.gameId.source === 'local')
|
const localGame = await db.query.games.findFirst({
|
||||||
{
|
where: eq(appSchema.games.id, this.gameId), columns: {
|
||||||
await updateLocalLastPlayed(Number(this.gameId.id));
|
name: true,
|
||||||
}
|
source_id: true,
|
||||||
|
source: true
|
||||||
const source = this.gameSource ?? this.gameId.source;
|
|
||||||
const id = this.gameSourceId ?? this.gameId.id;
|
|
||||||
|
|
||||||
await new Promise(async (resolve) =>
|
|
||||||
{
|
|
||||||
await plugins.hooks.games.postPlay.promise(
|
|
||||||
{
|
|
||||||
source,
|
|
||||||
id,
|
|
||||||
command: this.validCommand,
|
|
||||||
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
|
|
||||||
validChangedSaveFiles: {},
|
|
||||||
saveFolderSlots: this.saveSlots,
|
|
||||||
gameInfo
|
|
||||||
}).catch(e =>
|
|
||||||
{
|
|
||||||
console.error(e);
|
|
||||||
events.emit('notification', { message: getErrorMessage(e), type: 'error' });
|
|
||||||
}).then(() => resolve(false));
|
|
||||||
const timeoutHandler = () => resolve(false);
|
|
||||||
setTimeout(timeoutHandler, 5000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; })
|
|
||||||
{
|
|
||||||
return plugins.hooks.games.prePlay.promise({
|
|
||||||
source: this.gameSource ?? this.gameId.source,
|
|
||||||
id: this.gameSourceId ?? this.gameId.id,
|
|
||||||
saveFolderSlots: this.saveSlots,
|
|
||||||
command: this.validCommand,
|
|
||||||
setProgress: setProgress,
|
|
||||||
gameInfo
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>, z.infer<typeof LaunchGameJob.dataSchema>, string>)
|
|
||||||
{
|
|
||||||
let gameInfo: { name?: string, source_id?: string, source?: string; platformSlug?: string; } | undefined = undefined;
|
|
||||||
if (this.gameId.source === 'emulator')
|
|
||||||
{
|
|
||||||
gameInfo = { name: this.gameId.id };
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
const localGame = await db.query.games.findFirst({
|
|
||||||
where: eq(appSchema.games.id, Number(this.gameId.id)), columns: {
|
|
||||||
name: true,
|
|
||||||
source_id: true,
|
|
||||||
source: true,
|
|
||||||
},
|
|
||||||
with: {
|
|
||||||
platform: {
|
|
||||||
columns: {
|
|
||||||
es_slug: true,
|
|
||||||
slug: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (localGame)
|
|
||||||
gameInfo = {
|
|
||||||
name: localGame.name ?? undefined,
|
|
||||||
source_id: localGame.source_id ?? undefined,
|
|
||||||
source: localGame.source ?? undefined,
|
|
||||||
platformSlug: localGame.platform.es_slug ?? localGame.platform.slug
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
|
|
||||||
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 });
|
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
|
||||||
|
autoValidCommand: this.validCommand,
|
||||||
|
game: { source: this.gameSource, id: this.gameId }
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) =>
|
||||||
|
{
|
||||||
|
let game: any;
|
||||||
|
if (!commandArgs)
|
||||||
|
{
|
||||||
|
// ES-DE commands require shell execution. Some emulators fail otherwise.
|
||||||
|
const spawnGame = spawn(this.validCommand.command, {
|
||||||
|
shell: true,
|
||||||
|
cwd: this.validCommand.startDir,
|
||||||
|
signal: context.abortSignal
|
||||||
|
});
|
||||||
|
|
||||||
|
spawnGame.stdout.on('data', data => console.log(data));
|
||||||
|
spawnGame.on('close', (code) =>
|
||||||
|
{
|
||||||
|
resolve(code);
|
||||||
|
});
|
||||||
|
spawnGame.on('error', e =>
|
||||||
|
{
|
||||||
|
console.error(e);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
game = spawnGame;
|
||||||
|
}
|
||||||
|
else if (this.validCommand.metadata.emulatorBin)
|
||||||
|
{
|
||||||
|
// We have full control over launching integrated emulators better to use bun spawn
|
||||||
|
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], {
|
||||||
|
cwd: this.validCommand.startDir,
|
||||||
|
signal: context.abortSignal
|
||||||
|
});
|
||||||
|
|
||||||
|
bunGame.exited.then(resolve).catch(e =>
|
||||||
|
{
|
||||||
|
console.error(e);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
game = bunGame;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
reject(new Error("No Emulator Bin"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeGame = {
|
||||||
|
process: game,
|
||||||
|
name: localGame?.name ?? "Unknown",
|
||||||
|
gameId: this.gameId,
|
||||||
|
command: this.validCommand
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePlayed = async (source: string, id: string) =>
|
||||||
|
{
|
||||||
|
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, this.gameId));
|
||||||
|
await plugins.hooks.games.updatePlayed.promise({ source, id }).then(v =>
|
||||||
|
{
|
||||||
|
if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.gameSource !== 'local')
|
||||||
|
{
|
||||||
|
updatePlayed(this.gameSource, this.gameSourceId);
|
||||||
|
}
|
||||||
|
else if (localGame?.source && localGame?.source !== 'local' && localGame.source_id)
|
||||||
|
{
|
||||||
|
updatePlayed(localGame.source, localGame.source_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Old spawn lanching, cases issues, needs to be ran as shell
|
||||||
|
|
||||||
|
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
|
||||||
|
const game = setActiveGame({
|
||||||
|
process: Bun.spawn({
|
||||||
|
cmd,
|
||||||
|
env: {
|
||||||
|
...process.env
|
||||||
|
},
|
||||||
|
onExit (subprocess, exitCode, signalCode, error)
|
||||||
|
{
|
||||||
|
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
|
||||||
|
},
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
}),
|
||||||
|
name: localGame?.name ?? "Unknown",
|
||||||
|
gameId: validCommand.gameId,
|
||||||
|
command: validCommand.command.command
|
||||||
|
});
|
||||||
|
|
||||||
|
await game.process.exited;
|
||||||
|
if (game.process.exitCode && game.process.exitCode > 0)
|
||||||
|
{
|
||||||
|
return status('Internal Server Error');
|
||||||
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
exposeData ()
|
exposeData ()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
||||||
import { host, localIp } from "@/bun/utils/host";
|
import { host, localIp } from "@/bun/utils/host";
|
||||||
import cors from "@elysiajs/cors";
|
import cors from "@elysiajs/cors";
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import z from "zod";
|
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
|
||||||
import { plugins } from "../app";
|
|
||||||
import { canUninstall, runBunPackageCommand } from "../plugins/services";
|
|
||||||
import { getPlugin, registerPlugin, unregisterPlugin } from "../plugins/register-plugins";
|
|
||||||
import { PluginRegistry } from "@/shared/constants";
|
|
||||||
|
|
||||||
export default class PluginOperationJob implements IJob<never, string>
|
|
||||||
{
|
|
||||||
static id = "plugin-operation-job" as const;
|
|
||||||
static dataSchema = z.never();
|
|
||||||
group = "plugin-operations";
|
|
||||||
operation: "add" | "update" | "remove";
|
|
||||||
plugin: string;
|
|
||||||
|
|
||||||
constructor(operation: "add" | "update" | "remove", plugin: string)
|
|
||||||
{
|
|
||||||
this.plugin = plugin;
|
|
||||||
this.operation = operation;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start (context: JobContext<IJob<never, string>, never, string>)
|
|
||||||
{
|
|
||||||
switch (this.operation)
|
|
||||||
{
|
|
||||||
case "add":
|
|
||||||
//TODO: find the latest compatible version with the current sdk version
|
|
||||||
const addResponse = await runBunPackageCommand(["add", this.plugin, '--omit', 'peer', "--registry", PluginRegistry]);
|
|
||||||
console.log(addResponse);
|
|
||||||
const addPlugin = await getPlugin(this.plugin, plugins);
|
|
||||||
if (!addPlugin) throw new Error(`${this.plugin} Not Found`);
|
|
||||||
await registerPlugin(addPlugin, 'store', plugins);
|
|
||||||
break;
|
|
||||||
case "update":
|
|
||||||
const existingPlugin = plugins.plugins[this.plugin];
|
|
||||||
if (!existingPlugin) throw new Error(`${this.plugin} Not Found`);
|
|
||||||
if (!existingPlugin.update?.new) throw new Error(`No Update Found`);
|
|
||||||
let updatePlugin = await getPlugin(this.plugin, plugins);
|
|
||||||
if (!updatePlugin) throw new Error(`${this.plugin} Not Found`);
|
|
||||||
await unregisterPlugin(this.plugin, plugins);
|
|
||||||
const updateResponse = await runBunPackageCommand(["update", `${this.plugin}@${existingPlugin.update?.new}`, '--omit', 'peer', "--registry", PluginRegistry, '--latest']);
|
|
||||||
console.log(updateResponse);
|
|
||||||
updatePlugin = await getPlugin(this.plugin, plugins);
|
|
||||||
if (!updatePlugin) throw new Error(`Something Went Wrong during update. Missing Plugin: ${this.plugin}`);
|
|
||||||
await registerPlugin(updatePlugin, existingPlugin.source, plugins);
|
|
||||||
break;
|
|
||||||
case "remove":
|
|
||||||
const removePlugin = plugins.plugins[this.plugin];
|
|
||||||
if (!removePlugin) throw new Error(`${this.plugin} Not Found`);
|
|
||||||
if (!canUninstall(removePlugin.description, removePlugin.source))
|
|
||||||
{
|
|
||||||
throw new Error("Uninstall Not Allowed");
|
|
||||||
}
|
|
||||||
const response = await runBunPackageCommand(['remove', this.plugin, "--registry", PluginRegistry, '--omit', 'peer']);
|
|
||||||
console.log(response);
|
|
||||||
await unregisterPlugin(this.plugin, plugins);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
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" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
|
||||||
import { sleep } from "bun";
|
|
||||||
|
|
||||||
export class TestDownloadJob implements IJob<DownloadJobData, string>
|
|
||||||
{
|
|
||||||
data: DownloadJobData = {
|
|
||||||
speed: 1686,
|
|
||||||
downloaded: 0,
|
|
||||||
total: 6615841,
|
|
||||||
name: "Test Download Job"
|
|
||||||
};
|
|
||||||
|
|
||||||
group = "test-download";
|
|
||||||
|
|
||||||
async start (context: JobContext<IJob<DownloadJobData, string>, DownloadJobData, string>): Promise<any>
|
|
||||||
{
|
|
||||||
for (let i = 0; i < 10; i++)
|
|
||||||
{
|
|
||||||
await sleep(1000);
|
|
||||||
context.setProgress(i / 10 * 100, 'download');
|
|
||||||
if (context.abortSignal.aborted) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exposeData (): DownloadJobData
|
|
||||||
{
|
|
||||||
return this.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import secrets from "../secrets";
|
import secrets from "../secrets";
|
||||||
import open from "open";
|
import open from "open";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
|
||||||
50
src/bun/api/jobs/update-store.ts
Normal file
50
src/bun/api/jobs/update-store.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import { IJob, JobContext } from "../task-queue";
|
||||||
|
import { getStoreRootFolder } from "../store/services/gamesService";
|
||||||
|
import { STORE_VERSION } from "@/shared/constants";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export default class UpdateStoreJob implements IJob<never, never>
|
||||||
|
{
|
||||||
|
static id = "update-store" as const;
|
||||||
|
static dataSchema = z.never();
|
||||||
|
packageName: string;
|
||||||
|
registry: URL;
|
||||||
|
storeVersion: string;
|
||||||
|
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store";
|
||||||
|
this.registry = new URL(process.env.STORE_REGISTRY ?? "https://registry.npmjs.org");
|
||||||
|
this.storeVersion = process.env.STORE_VERSION ?? STORE_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<UpdateStoreJob, never, never>)
|
||||||
|
{
|
||||||
|
if (process.env.CUSTOM_STORE_PATH) return;
|
||||||
|
|
||||||
|
const tempCache = path.join(tmpdir(), "gameflow-bun-cache");
|
||||||
|
const storeFolder = getStoreRootFolder();
|
||||||
|
await ensureDir(storeFolder);
|
||||||
|
|
||||||
|
console.log("Updating Store");
|
||||||
|
const proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--production", "--registry", this.registry.href], {
|
||||||
|
cwd: storeFolder,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
env: {
|
||||||
|
BUN_BE_BUN: "1",
|
||||||
|
BUN_INSTALL_CACHE_DIR: tempCache
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stdout = await new Response(proc.stdout).text();
|
||||||
|
console.log(stdout);
|
||||||
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
if (stderr)
|
||||||
|
console.error(stderr);
|
||||||
|
await proc.exited;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
|
|
||||||
import { FrontendNotification } from '@simeonradivoev/gameflow-sdk/shared';
|
|
||||||
import { events } from './app';
|
import { events } from './app';
|
||||||
|
|
||||||
export default function buildNotificationsStream ()
|
export default function buildNotificationsStream ()
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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 } } };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,6 +21,7 @@ CdvdShareWrite = false
|
||||||
EnablePatches = true
|
EnablePatches = true
|
||||||
EnableCheats = false
|
EnableCheats = false
|
||||||
EnablePINE = false
|
EnablePINE = false
|
||||||
|
EnableWideScreenPatches = false
|
||||||
EnableNoInterlacingPatches = false
|
EnableNoInterlacingPatches = false
|
||||||
EnableRecordingTools = true
|
EnableRecordingTools = true
|
||||||
EnableGameFixes = true
|
EnableGameFixes = true
|
||||||
|
|
@ -91,7 +92,7 @@ VsyncEnable = 0
|
||||||
FramerateNTSC = 59.94
|
FramerateNTSC = 59.94
|
||||||
FrameratePAL = 50
|
FrameratePAL = 50
|
||||||
SyncToHostRefreshRate = false
|
SyncToHostRefreshRate = false
|
||||||
AspectRatio = {{ASPECT_RATIO}}
|
AspectRatio = Auto 4:3/3:2
|
||||||
FMVAspectRatioSwitch = Off
|
FMVAspectRatioSwitch = Off
|
||||||
ScreenshotSize = 0
|
ScreenshotSize = 0
|
||||||
ScreenshotFormat = 0
|
ScreenshotFormat = 0
|
||||||
|
|
@ -167,6 +168,7 @@ linear_present_mode = 1
|
||||||
deinterlace_mode = 0
|
deinterlace_mode = 0
|
||||||
OsdScale = 100
|
OsdScale = 100
|
||||||
Renderer = 14
|
Renderer = 14
|
||||||
|
upscale_multiplier = 1
|
||||||
mipmap_hw = -1
|
mipmap_hw = -1
|
||||||
accurate_blending_unit = 1
|
accurate_blending_unit = 1
|
||||||
crc_hack_level = -1
|
crc_hack_level = -1
|
||||||
|
|
@ -369,6 +371,18 @@ Multitap2_Slot4_Enable = false
|
||||||
Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2
|
Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2
|
||||||
|
|
||||||
|
|
||||||
|
[Folders]
|
||||||
|
Bios = {{{BIOS_PATH}}}
|
||||||
|
Snapshots = {{{SNAPSHOTS_PATH}}}
|
||||||
|
SaveStates = {{{SAVE_STATES_PATH}}}
|
||||||
|
MemoryCards = {{{MEMORY_CARDS_PATH}}}
|
||||||
|
Cache = {{{CACHE_PATH}}}
|
||||||
|
Covers = {{{COVERS_PATH}}}
|
||||||
|
Logs = logs
|
||||||
|
Textures = {{{TEXTURES_PATH}}}
|
||||||
|
Videos = videos
|
||||||
|
|
||||||
|
|
||||||
[InputSources]
|
[InputSources]
|
||||||
Keyboard = true
|
Keyboard = true
|
||||||
Mouse = true
|
Mouse = true
|
||||||
|
|
@ -474,3 +488,6 @@ RDown = SDL-1/+RightY
|
||||||
RLeft = SDL-1/-RightX
|
RLeft = SDL-1/-RightX
|
||||||
LargeMotor = SDL-1/LargeMotor
|
LargeMotor = SDL-1/LargeMotor
|
||||||
SmallMotor = SDL-1/SmallMotor
|
SmallMotor = SDL-1/SmallMotor
|
||||||
|
|
||||||
|
[GameList]
|
||||||
|
RecursivePaths = {{{RECURSIVE_PATHS}}}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
"description": "PCSX2 Emulator Integration",
|
"description": "PCSX2 Emulator Integration",
|
||||||
"main": "./pcsx2.ts",
|
"main": "./pcsx2.ts",
|
||||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png",
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png",
|
||||||
"category": "emulators",
|
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
"emulator",
|
"emulator",
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,34 @@
|
||||||
|
|
||||||
import { config } from "@/bun/api/app";
|
import { config, db } from "@/bun/api/app";
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import defaultConfig from './PCSX2.ini' with { type: 'file' };
|
import configFile from './PCSX2.ini' with { type: 'file' };
|
||||||
|
import Mustache from 'mustache';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import ini from 'ini';
|
|
||||||
import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default class PCSX2Integration implements PluginType
|
export default class PCSX2Integration implements PluginType
|
||||||
{
|
{
|
||||||
emulator = "PCSX2";
|
load (ctx: PluginContextType)
|
||||||
|
|
||||||
async load (ctx: PluginLoadingContextType)
|
|
||||||
{
|
{
|
||||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
||||||
{
|
{
|
||||||
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"];
|
if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||||
|
|
||||||
if (ctx.source?.type === 'store')
|
|
||||||
{
|
{
|
||||||
return {
|
const args = ["-batch"];
|
||||||
id: desc.name,
|
if (config.get('launchInFullscreen'))
|
||||||
supportLevel: "full",
|
{
|
||||||
capabilities: [...baseCapabilities, "config", "resolution"]
|
args.push("-fullscreen");
|
||||||
};
|
}
|
||||||
}
|
args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]);
|
||||||
else
|
|
||||||
{
|
|
||||||
return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
|
const configFileContents = await Bun.file(configFile).text();
|
||||||
{
|
|
||||||
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 biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2');
|
||||||
{
|
const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2');
|
||||||
const args: string[] = [];
|
const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2');
|
||||||
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)
|
const view = {
|
||||||
{
|
|
||||||
let pscx2Path = '';
|
|
||||||
if (process.platform === 'win32')
|
|
||||||
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
|
|
||||||
else
|
|
||||||
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis');
|
|
||||||
|
|
||||||
const configPath = path.join(pscx2Path, 'PCSX2.ini');
|
|
||||||
const existingConfigFile = Bun.file(configPath);
|
|
||||||
|
|
||||||
const configFile = await existingConfigFile.exists() ? ini.parse(await existingConfigFile.text()) : ini.parse(await Bun.file(defaultConfig).text());
|
|
||||||
|
|
||||||
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
|
|
||||||
const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator);
|
|
||||||
const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator);
|
|
||||||
const resolutionMapping = {
|
|
||||||
"720p": 2,
|
|
||||||
"1080p": 3,
|
|
||||||
"1440p": 4,
|
|
||||||
"4k": 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
const paths = {
|
|
||||||
BIOS_PATH: biosFolder,
|
BIOS_PATH: biosFolder,
|
||||||
SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'),
|
SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'),
|
||||||
SAVE_STATES_PATH: path.join(savesFolder, 'states'),
|
SAVE_STATES_PATH: path.join(savesFolder, 'states'),
|
||||||
|
|
@ -89,37 +36,21 @@ export default class PCSX2Integration implements PluginType
|
||||||
CACHE_PATH: path.join(storageFolder, 'cache'),
|
CACHE_PATH: path.join(storageFolder, 'cache'),
|
||||||
COVERS_PATH: path.join(storageFolder, 'covers'),
|
COVERS_PATH: path.join(storageFolder, 'covers'),
|
||||||
TEXTURES_PATH: path.join(storageFolder, 'textures'),
|
TEXTURES_PATH: path.join(storageFolder, 'textures'),
|
||||||
VIDEOS_PATH: path.join(storageFolder, 'videos'),
|
|
||||||
LOGS_PATH: path.join(storageFolder, 'logs'),
|
|
||||||
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'),
|
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'),
|
||||||
};
|
};
|
||||||
|
|
||||||
await Promise.all(Object.values(paths).map(p => ensureDir(p)));
|
await Promise.all(Object.values(view).map(p => ensureDir(p)));
|
||||||
|
|
||||||
configFile.EmuCore ??= {};
|
let pscx2Path = '';
|
||||||
configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen');
|
if (process.platform === 'win32')
|
||||||
configFile['EmuCore/GS'] ??= {};
|
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
|
||||||
configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2";
|
else
|
||||||
configFile['EmuCore/GS'].upscale_multiplier = resolutionMapping[config.get('emulatorResolution')] ?? 1;
|
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis');
|
||||||
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));
|
await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view));
|
||||||
|
|
||||||
return { args, savesPath: { [this.emulator]: { cwd: paths.MEMORY_CARDS_PATH } } };
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { args };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,6 +96,7 @@ HardwareTransform = True
|
||||||
SoftwareSkinning = True
|
SoftwareSkinning = True
|
||||||
TextureFiltering = 1
|
TextureFiltering = 1
|
||||||
BufferFiltering = 1
|
BufferFiltering = 1
|
||||||
|
InternalResolution = 3
|
||||||
AndroidHwScale = 1
|
AndroidHwScale = 1
|
||||||
HighQualityDepth = 1
|
HighQualityDepth = 1
|
||||||
FrameSkip = 0
|
FrameSkip = 0
|
||||||
|
|
@ -108,6 +109,7 @@ AnisotropyLevel = 4
|
||||||
VertexDecCache = False
|
VertexDecCache = False
|
||||||
TextureBackoffCache = False
|
TextureBackoffCache = False
|
||||||
TextureSecondaryCache = False
|
TextureSecondaryCache = False
|
||||||
|
FullScreen = True
|
||||||
FullScreenMulti = False
|
FullScreenMulti = False
|
||||||
SmallDisplayZoomType = 2
|
SmallDisplayZoomType = 2
|
||||||
SmallDisplayOffsetX = 0.500000
|
SmallDisplayOffsetX = 0.500000
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
"description": "PPSSPP Emulator Integration",
|
"description": "PPSSPP Emulator Integration",
|
||||||
"main": "./ppsspp.ts",
|
"main": "./ppsspp.ts",
|
||||||
"icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png",
|
"icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png",
|
||||||
"category": "emulators",
|
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
"emulator",
|
"emulator",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' };
|
import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' };
|
||||||
|
|
@ -9,93 +9,40 @@ import path from "node:path";
|
||||||
import Mustache from "mustache";
|
import Mustache from "mustache";
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import ini from 'ini';
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default class PPSSPPIntegration implements PluginType
|
export default class PCSX2Integration implements PluginType
|
||||||
{
|
{
|
||||||
emulator = "PPSSPP";
|
load (ctx: PluginContextType)
|
||||||
|
|
||||||
async load (ctx: PluginLoadingContextType)
|
|
||||||
{
|
{
|
||||||
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) =>
|
||||||
{
|
{
|
||||||
const stat = await fs.stat(ctx.path);
|
if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir)
|
||||||
if (stat.isDirectory())
|
|
||||||
{
|
{
|
||||||
await Bun.write(path.join(ctx.path, "portable.txt"), "");
|
const args = [ctx.autoValidCommand.metadata.romPath, "--escape-exit", "--pause-menu-exit"];
|
||||||
if (process.platform === 'win32')
|
if (config.get('launchInFullscreen'))
|
||||||
{
|
{
|
||||||
await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator));
|
args.push("--fullscreen");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
let confPath: string | undefined = undefined;
|
||||||
{
|
let controlsPath: string | undefined = undefined;
|
||||||
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"];
|
|
||||||
|
|
||||||
if (ctx.source?.type === 'store')
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
id: desc.name,
|
|
||||||
supportLevel: "full",
|
|
||||||
capabilities: [...baseCapabilities, "config", "resolution"]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
|
|
||||||
{
|
|
||||||
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
|
||||||
validChangedSaveFiles[this.emulator] = {
|
|
||||||
cwd: saveFolderSlots[this.emulator].cwd,
|
|
||||||
shared: true,
|
|
||||||
subPath: '*.{SFO,sfo,PNG,png}',
|
|
||||||
isGlob: true
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
|
||||||
{
|
|
||||||
const args: string[] = [];
|
|
||||||
if (ctx.autoValidCommand.metadata.romPath)
|
|
||||||
{
|
|
||||||
args.push(ctx.autoValidCommand.metadata.romPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push("--escape-exit", "--pause-menu-exit");
|
|
||||||
if (config.get('launchInFullscreen'))
|
|
||||||
{
|
|
||||||
args.push("--fullscreen");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
|
|
||||||
{
|
|
||||||
let defaultConfigPath: string | undefined = undefined;
|
|
||||||
let defaultControlsPath: string | undefined = undefined;
|
|
||||||
|
|
||||||
switch (process.platform)
|
switch (process.platform)
|
||||||
{
|
{
|
||||||
case "win32":
|
case "win32":
|
||||||
defaultConfigPath = configFilePathWin32;
|
confPath = configFilePathWin32;
|
||||||
defaultControlsPath = configControlsFilePathWin32;
|
controlsPath = configControlsFilePathWin32;
|
||||||
break;
|
break;
|
||||||
case 'linux':
|
case 'linux':
|
||||||
defaultConfigPath = configFilePathLinux;
|
confPath = configFilePathLinux;
|
||||||
defaultControlsPath = configControlsFilePathLinux;
|
controlsPath = configControlsFilePathLinux;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ppssppPath = '';
|
let ppssppPath = '';
|
||||||
if (process.platform === 'win32')
|
if (process.platform === 'win32')
|
||||||
{
|
{
|
||||||
ppssppPath = path.join(config.get('downloadPath'), 'saves', this.emulator, 'PSP', 'SYSTEM');
|
ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM');
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
//TODO: Use way to set custom memstick path when they support it
|
//TODO: Use way to set custom memstick path when they support it
|
||||||
|
|
@ -105,43 +52,20 @@ export default class PPSSPPIntegration implements PluginType
|
||||||
|
|
||||||
ensureDir(ppssppPath);
|
ensureDir(ppssppPath);
|
||||||
|
|
||||||
if (defaultConfigPath)
|
if (confPath)
|
||||||
{
|
{
|
||||||
const resolutionMapping: Record<string, number> = {
|
const configFileContents = await Bun.file(confPath).text();
|
||||||
"720p": 2,
|
await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {}));
|
||||||
"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)
|
if (controlsPath)
|
||||||
{
|
{
|
||||||
const controlsFileContents = await Bun.file(defaultControlsPath).text();
|
const controlsFileContents = await Bun.file(controlsPath).text();
|
||||||
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
|
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return args;
|
||||||
args,
|
|
||||||
savesPath: {
|
|
||||||
[this.emulator]: {
|
|
||||||
cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { args };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,6 +96,7 @@ HardwareTransform = True
|
||||||
SoftwareSkinning = True
|
SoftwareSkinning = True
|
||||||
TextureFiltering = 1
|
TextureFiltering = 1
|
||||||
BufferFiltering = 1
|
BufferFiltering = 1
|
||||||
|
InternalResolution = 3
|
||||||
AndroidHwScale = 1
|
AndroidHwScale = 1
|
||||||
HighQualityDepth = 1
|
HighQualityDepth = 1
|
||||||
FrameSkip = 0
|
FrameSkip = 0
|
||||||
|
|
@ -108,6 +109,7 @@ AnisotropyLevel = 4
|
||||||
VertexDecCache = False
|
VertexDecCache = False
|
||||||
TextureBackoffCache = False
|
TextureBackoffCache = False
|
||||||
TextureSecondaryCache = False
|
TextureSecondaryCache = False
|
||||||
|
FullScreen = True
|
||||||
FullScreenMulti = False
|
FullScreenMulti = False
|
||||||
SmallDisplayZoomType = 2
|
SmallDisplayZoomType = 2
|
||||||
SmallDisplayOffsetX = 0.500000
|
SmallDisplayOffsetX = 0.500000
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"name": "com.simeonradivoev.gameflow.xemu",
|
|
||||||
"displayName": "XEMU Integration",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "XEMU Emulator Integration",
|
|
||||||
"main": "./xemu.ts",
|
|
||||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/8e/Xemu_logo_green.svg",
|
|
||||||
"category": "emulators",
|
|
||||||
"keywords": [
|
|
||||||
"integration",
|
|
||||||
"emulator",
|
|
||||||
"xbox",
|
|
||||||
"xemu"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
|
||||||
import desc from './package.json';
|
|
||||||
import { config } from "@/bun/api/app";
|
|
||||||
import path from "node:path";
|
|
||||||
import toml, { TomlTable } from 'smol-toml';
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import bin from './eeprom.bin' with { type: 'file' };
|
|
||||||
|
|
||||||
export default class XEMUIntegration implements PluginType
|
|
||||||
{
|
|
||||||
emulator = 'XEMU';
|
|
||||||
|
|
||||||
async load (ctx: PluginLoadingContextType)
|
|
||||||
{
|
|
||||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
|
||||||
{
|
|
||||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] };
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
|
||||||
{
|
|
||||||
const args: string[] = [];
|
|
||||||
|
|
||||||
if (config.get('launchInFullscreen'))
|
|
||||||
{
|
|
||||||
args.push("-full-screen");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.autoValidCommand.metadata.romPath)
|
|
||||||
{
|
|
||||||
args.push("-dvd_path");
|
|
||||||
args.push(ctx.autoValidCommand.metadata.romPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configPath = path.join(config.get('downloadPath'), 'storage', this.emulator, 'xemu.toml');
|
|
||||||
let configFile: { general: TomlTable & { misc: TomlTable; }, sys: TomlTable & { files: TomlTable; }; } = { general: { misc: {} }, sys: { files: {} } };
|
|
||||||
if (await Bun.file(configPath).exists())
|
|
||||||
{
|
|
||||||
configFile = toml.parse(await Bun.file(configPath).text()) as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
configFile.general.misc ??= {};
|
|
||||||
configFile.general.misc.skip_boot_anim = true;
|
|
||||||
configFile.general.show_welcome = false;
|
|
||||||
configFile.general.games_dir = path.join(config.get('downloadPath'), 'roms', 'xbox');
|
|
||||||
configFile.sys.mem_limit = '128';
|
|
||||||
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
|
|
||||||
if (await fs.exists(biosFolder))
|
|
||||||
{
|
|
||||||
const biosPaths = (await fs.readdir(biosFolder));
|
|
||||||
const flash = biosPaths.find(f => f.endsWith('.bin') && !f.includes('mcpx'));
|
|
||||||
const bootrom = biosPaths.find(f => f.endsWith('.bin') && f.includes('mcpx'));
|
|
||||||
const hardDrive = biosPaths.find(f => f.endsWith('qcow2'));
|
|
||||||
if (flash) configFile.sys.files.flashrom_path = path.join(biosFolder, flash);
|
|
||||||
if (bootrom) configFile.sys.files.bootrom_path = path.join(biosFolder, bootrom);
|
|
||||||
if (hardDrive) configFile.sys.files.hdd_path = path.join(biosFolder, hardDrive);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ctx.dryRun)
|
|
||||||
{
|
|
||||||
const eepromPath = path.join(config.get('downloadPath'), "storage", this.emulator, 'eeprom.bin');
|
|
||||||
await Bun.write(eepromPath, await Bun.file(bin).arrayBuffer());
|
|
||||||
configFile.sys.files.eeprom_path = eepromPath;
|
|
||||||
|
|
||||||
await Bun.write(configPath, toml.stringify(configFile));
|
|
||||||
args.push("-config_path");
|
|
||||||
args.push(configPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return { args };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"name": "com.simeonradivoev.gameflow.xenia",
|
|
||||||
"displayName": "XENIA Integration",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "XENIA Emulator Integration",
|
|
||||||
"main": "./xenia.ts",
|
|
||||||
"icon": "https://xenia.jp/images/logo-256x256.png",
|
|
||||||
"category": "emulators",
|
|
||||||
"keywords": [
|
|
||||||
"integration",
|
|
||||||
"emulator",
|
|
||||||
"xbox360",
|
|
||||||
"xenia",
|
|
||||||
"xenia-edge"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
import { join } from "path";
|
|
||||||
|
|
||||||
const SECTOR_SIZE = 0x800;
|
|
||||||
const MAGIC = "MICROSOFT*XBOX*MEDIA";
|
|
||||||
|
|
||||||
const PARTITION_OFFSETS: Record<string, number> = {
|
|
||||||
XSF: 0x0,
|
|
||||||
GDF: 0xFD90000,
|
|
||||||
XGD3: 0x2080000,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function readBytes (file: ReturnType<typeof Bun.file>, offset: number, length: number): Promise<Buffer>
|
|
||||||
{
|
|
||||||
return Buffer.from(await file.slice(offset, offset + length).arrayBuffer());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseTitleIdFromXexReader (
|
|
||||||
read: (offset: number, length: number) => Promise<Buffer>
|
|
||||||
): Promise<string>
|
|
||||||
{
|
|
||||||
// Read just the fixed header (magic + flags + offsets + header count)
|
|
||||||
const header = await read(0, 0x18);
|
|
||||||
if (header.toString("ascii", 0, 4) !== "XEX2")
|
|
||||||
{
|
|
||||||
throw new Error("Not a valid XEX2 file");
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerCount = header.readUInt32BE(0x14);
|
|
||||||
const EXEC_INFO_KEY = 0x40006;
|
|
||||||
|
|
||||||
// Read the optional header table
|
|
||||||
const table = await read(0x18, headerCount * 8);
|
|
||||||
|
|
||||||
for (let i = 0; i < headerCount; i++)
|
|
||||||
{
|
|
||||||
const key = table.readUInt32BE(i * 8);
|
|
||||||
const valueOrOffset = table.readUInt32BE(i * 8 + 4);
|
|
||||||
|
|
||||||
if (key === EXEC_INFO_KEY)
|
|
||||||
{
|
|
||||||
// valueOrOffset is a file offset — read the exec info struct there
|
|
||||||
// TitleID is at +0x0C within it
|
|
||||||
const execInfo = await read(valueOrOffset, 0x18);
|
|
||||||
return execInfo.readUInt32BE(0x0C)
|
|
||||||
.toString(16).toUpperCase().padStart(8, "0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Execution info header not found in XEX");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function titleIdFromXexFile (xexPath: string): Promise<string>
|
|
||||||
{
|
|
||||||
const file = Bun.file(xexPath);
|
|
||||||
return parseTitleIdFromXexReader((offset, length) =>
|
|
||||||
readBytes(file, offset, length)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function titleIdFromIso (isoPath: string): Promise<string>
|
|
||||||
{
|
|
||||||
const file = Bun.file(isoPath);
|
|
||||||
const fileSize = file.size;
|
|
||||||
|
|
||||||
for (const partitionOffset of Object.values(PARTITION_OFFSETS))
|
|
||||||
{
|
|
||||||
const vdOffset = partitionOffset + 0x20 * SECTOR_SIZE;
|
|
||||||
if (vdOffset + 28 > fileSize) continue;
|
|
||||||
|
|
||||||
const vd = await readBytes(file, vdOffset, 28);
|
|
||||||
if (vd.toString("ascii", 0, 20) !== MAGIC) continue;
|
|
||||||
|
|
||||||
const rootSector = vd.readUInt32LE(20);
|
|
||||||
const rootSize = vd.readUInt32LE(24);
|
|
||||||
const rootOffset = partitionOffset + rootSector * SECTOR_SIZE;
|
|
||||||
const dir = await readBytes(file, rootOffset, rootSize);
|
|
||||||
|
|
||||||
let pos = 0;
|
|
||||||
while (pos < dir.length)
|
|
||||||
{
|
|
||||||
if (dir[pos] === 0xFF) break;
|
|
||||||
if (pos + 14 > dir.length) break;
|
|
||||||
|
|
||||||
const nameLen = dir[pos + 13];
|
|
||||||
if (nameLen === 0 || nameLen === 0xFF) break;
|
|
||||||
if (pos + 14 + nameLen > dir.length) break;
|
|
||||||
|
|
||||||
const name = dir.toString("ascii", pos + 14, pos + 14 + nameLen);
|
|
||||||
const fileSector = dir.readUInt32LE(pos + 4);
|
|
||||||
|
|
||||||
if (name.toLowerCase() === "default.xex")
|
|
||||||
{
|
|
||||||
const xexBase = partitionOffset + fileSector * SECTOR_SIZE;
|
|
||||||
// Reader that translates relative XEX offsets to absolute ISO offsets
|
|
||||||
return parseTitleIdFromXexReader((offset, length) =>
|
|
||||||
readBytes(file, xexBase + offset, length)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entryLen = 14 + nameLen;
|
|
||||||
pos += (entryLen + 3) & ~3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Not a valid Xbox 360 ISO or default.xex not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function titleIdFromFolder (folderPath: string): Promise<string>
|
|
||||||
{
|
|
||||||
return titleIdFromXexFile(join(folderPath, "default.xex"));
|
|
||||||
}
|
|
||||||
|
|
||||||
type XeniaRomType = "iso" | "xex" | "folder";
|
|
||||||
|
|
||||||
function detectRomType (romPath: string): XeniaRomType
|
|
||||||
{
|
|
||||||
const lower = romPath.toLowerCase();
|
|
||||||
if (lower.endsWith(".iso")) return "iso";
|
|
||||||
if (lower.endsWith(".xex")) return "xex";
|
|
||||||
return "folder"; // extracted game folder containing default.xex
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTitleId (romPath: string): Promise<string>
|
|
||||||
{
|
|
||||||
switch (detectRomType(romPath))
|
|
||||||
{
|
|
||||||
case "iso": return titleIdFromIso(romPath);
|
|
||||||
case "xex": return titleIdFromXexFile(romPath);
|
|
||||||
case "folder": return titleIdFromFolder(romPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getXeniaSavePaths (
|
|
||||||
romPath: string,
|
|
||||||
xeniaDir: string
|
|
||||||
): Promise<string>
|
|
||||||
{
|
|
||||||
const titleId = await getTitleId(romPath);
|
|
||||||
return join(xeniaDir, titleId);
|
|
||||||
};
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
|
||||||
import desc from './package.json';
|
|
||||||
import { GameflowHooks } from "@simeonradivoev/gameflow-sdk";
|
|
||||||
import { config } from "@/bun/api/app";
|
|
||||||
import path from "node:path";
|
|
||||||
import { ensureDir } from "fs-extra";
|
|
||||||
import toml, { TomlTable } from 'smol-toml';
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import { getXeniaSavePaths } from "./utils";
|
|
||||||
|
|
||||||
export default class XENIAIntegration implements PluginType
|
|
||||||
{
|
|
||||||
emulator = 'XENIA';
|
|
||||||
emulatorEdge = 'XENIA-EDGE';
|
|
||||||
|
|
||||||
async handlePostInstall (ctx: Parameters<typeof GameflowHooks.prototype.emulators.emulatorPostInstall.callAsync>['0'])
|
|
||||||
{
|
|
||||||
await Bun.write(path.join(ctx.path, "portable.txt"), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleLaunch (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunch.callAsync>['0']):
|
|
||||||
ReturnType<typeof GameflowHooks.prototype.games.emulatorLaunch.promise>
|
|
||||||
{
|
|
||||||
const args: string[] = [];
|
|
||||||
|
|
||||||
if (ctx.autoValidCommand.metadata.romPath)
|
|
||||||
{
|
|
||||||
args.push(ctx.autoValidCommand.metadata.romPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`);
|
|
||||||
|
|
||||||
args.push(`--config`, configPath);
|
|
||||||
|
|
||||||
if (config.get('launchInFullscreen'))
|
|
||||||
{
|
|
||||||
args.push(`--fullscreen`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ctx.dryRun)
|
|
||||||
{
|
|
||||||
await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!));
|
|
||||||
let configFile: TomlTable & { Storage: TomlTable, GPU: TomlTable, Display: TomlTable; } = { Storage: {}, GPU: {}, Display: {} };
|
|
||||||
if (await fs.exists(configPath))
|
|
||||||
{
|
|
||||||
configFile = toml.parse(await Bun.file(configPath).text()) as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolutionMapping = {
|
|
||||||
"720p": 1,
|
|
||||||
"1080p": 2,
|
|
||||||
"1440p": 3,
|
|
||||||
"4k": 3
|
|
||||||
};
|
|
||||||
|
|
||||||
configFile.Display.fullscreen = config.get('launchInFullscreen');
|
|
||||||
configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1;
|
|
||||||
configFile.GPU.draw_resolution_scale_y = resolutionMapping[config.get('emulatorResolution')] ?? 1;
|
|
||||||
const savesPath = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!);
|
|
||||||
await ensureDir(savesPath);
|
|
||||||
configFile.Storage.content_root = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!);
|
|
||||||
configFile.Storage.storage_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'config');
|
|
||||||
configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache');
|
|
||||||
|
|
||||||
await Bun.write(configPath, toml.stringify(configFile));
|
|
||||||
|
|
||||||
let finalSavesPath: string | undefined = undefined;
|
|
||||||
if (ctx.autoValidCommand.metadata.romPath)
|
|
||||||
{
|
|
||||||
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
|
|
||||||
return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { args };
|
|
||||||
};
|
|
||||||
|
|
||||||
return { args };
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEmulatorLaunchSupport (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.callAsync>['0']):
|
|
||||||
ReturnType<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.call>
|
|
||||||
{
|
|
||||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
async load (ctx: PluginLoadingContextType)
|
|
||||||
{
|
|
||||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, this.handleEmulatorLaunchSupport);
|
|
||||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, this.handleEmulatorLaunchSupport);
|
|
||||||
|
|
||||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch);
|
|
||||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch);
|
|
||||||
|
|
||||||
ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) =>
|
|
||||||
{
|
|
||||||
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
|
||||||
const files = await fs.readdir(saveFolderSlots[this.emulator].cwd, { recursive: true });
|
|
||||||
validChangedSaveFiles.xenia = { cwd: saveFolderSlots[this.emulator].cwd, subPath: files, shared: false };
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,521 +0,0 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
|
||||||
import desc from './package.json';
|
|
||||||
import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app";
|
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { cores } from "@/bun/api/emulatorjs/emulatorjs";
|
|
||||||
import { RPC_URL } from "@/shared/constants";
|
|
||||||
import { host } from "@/bun/utils/host";
|
|
||||||
import path from 'node:path';
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import { findStoreEmulatorExec } from "@/bun/api/games/services/launchGameService";
|
|
||||||
import { which } from "bun";
|
|
||||||
import os from 'node:os';
|
|
||||||
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
|
|
||||||
import { CommandEntry, EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default class IgdbIntegration implements PluginType
|
|
||||||
{
|
|
||||||
varRegex = /%([^%]+)%/g;
|
|
||||||
assignRegex = /(%\w+%)=(\S+) /g;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the emulators related to the given system
|
|
||||||
* @param systemSlug the ES-DE slug for the system
|
|
||||||
*/
|
|
||||||
async getEmulatorsForSystem (systemSlug: string)
|
|
||||||
{
|
|
||||||
const system = await emulatorsDb.query.systems.findFirst({
|
|
||||||
with: { commands: true },
|
|
||||||
where: eq(emulatorSchema.systems.name, systemSlug)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!system)
|
|
||||||
{
|
|
||||||
throw new Error(`Could not find system '${systemSlug}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emulators = new Set<string>();
|
|
||||||
await Promise.all(system.commands.map(async (command, index) =>
|
|
||||||
{
|
|
||||||
let cmd = command.command;
|
|
||||||
|
|
||||||
const matches = Array.from(cmd.matchAll(this.varRegex));
|
|
||||||
matches.forEach(([value]) =>
|
|
||||||
{
|
|
||||||
if (value.startsWith("%EMULATOR_"))
|
|
||||||
{
|
|
||||||
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
|
||||||
emulators.add(emulatorName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (cores[systemSlug])
|
|
||||||
{
|
|
||||||
emulators.add('EMULATORJS');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(emulators);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
|
|
||||||
{
|
|
||||||
const execs: EmulatorSourceEntryType[] = [];
|
|
||||||
|
|
||||||
if (customEmulators.has(id))
|
|
||||||
{
|
|
||||||
execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emulator && emulator.systempath.length > 0)
|
|
||||||
{
|
|
||||||
const storePath = await findStoreEmulatorExec(id, emulator);
|
|
||||||
if (storePath) execs.push(storePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emulator && process.platform === 'win32')
|
|
||||||
{
|
|
||||||
const regValues = emulator.winregistrypath;
|
|
||||||
if (regValues.length > 0)
|
|
||||||
{
|
|
||||||
for (const node of regValues)
|
|
||||||
{
|
|
||||||
const registryValue = await this.readRegistryValue(node);
|
|
||||||
if (registryValue)
|
|
||||||
{
|
|
||||||
execs.push({ binPath: registryValue, type: 'registry', exists: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emulator && emulator.systempath.length > 0)
|
|
||||||
{
|
|
||||||
const systemPath = await this.resolveSystemPath(emulator.systempath);
|
|
||||||
if (systemPath)
|
|
||||||
{
|
|
||||||
execs.push({ binPath: systemPath, type: 'system', exists: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emulator && emulator.staticpath.length > 0)
|
|
||||||
{
|
|
||||||
const staticPath = await this.resolveStaticPath(emulator.staticpath);
|
|
||||||
if (staticPath)
|
|
||||||
{
|
|
||||||
execs.push({ binPath: staticPath, type: 'static', exists: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return execs;
|
|
||||||
}
|
|
||||||
|
|
||||||
async readRegistryValue (text: string)
|
|
||||||
{
|
|
||||||
const params = text.split('|');
|
|
||||||
const key = path.dirname(params[0]);
|
|
||||||
const value = path.basename(params[0]);
|
|
||||||
const bin = params.length > 1 ? params[1] : undefined;
|
|
||||||
|
|
||||||
const proc = Bun.spawn({
|
|
||||||
cmd: ["reg", "QUERY", key, "/v", value],
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
|
|
||||||
const output = await new Response(proc.stdout).text();
|
|
||||||
await proc.exited;
|
|
||||||
|
|
||||||
if (!output.includes(value)) return null;
|
|
||||||
|
|
||||||
const lines = output.split("\n");
|
|
||||||
for (const line of lines)
|
|
||||||
{
|
|
||||||
if (line.includes(value))
|
|
||||||
{
|
|
||||||
const parts = line.trim().split(/\s{4,}/);
|
|
||||||
return bin ? path.join(parts[2], bin) : parts[2]; // registry value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async resolveStaticPath (entries: string[])
|
|
||||||
{
|
|
||||||
for (const entry of entries)
|
|
||||||
{
|
|
||||||
const resolved = entry.replace("~", os.homedir());
|
|
||||||
if (await fs.exists(resolved))
|
|
||||||
{
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async resolveSystemPath (entries: string[])
|
|
||||||
{
|
|
||||||
for (const entry of entries)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const found = which(entry);
|
|
||||||
return found;
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findExecsByName (emulatorName: string)
|
|
||||||
{
|
|
||||||
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorName) });
|
|
||||||
if (!emulator)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return this.findExecs(emulatorName, emulator);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRomFilePaths (gamePath: string, config: { systemSlug?: string; mainGlob?: string | null; })
|
|
||||||
{
|
|
||||||
if (!existsSync(gamePath))
|
|
||||||
{
|
|
||||||
throw new Error(`Provided rom path is missing: '${gamePath}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const gamePathStat = await fs.stat(gamePath);
|
|
||||||
const validFiles: string[] = [];
|
|
||||||
|
|
||||||
if (gamePathStat.isDirectory())
|
|
||||||
{
|
|
||||||
if (config.mainGlob)
|
|
||||||
{
|
|
||||||
const files = await Array.fromAsync(fs.glob(config.mainGlob, { cwd: gamePath }));
|
|
||||||
if (files.length > 1)
|
|
||||||
{
|
|
||||||
throw new Error("Found multiple rom files");
|
|
||||||
} else if (files.length === 0)
|
|
||||||
{
|
|
||||||
throw new Error("Found no valid roms");
|
|
||||||
}
|
|
||||||
|
|
||||||
validFiles.push(path.join(gamePath, files[0]));
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
if (!config.systemSlug) throw new Error("Needs system to find valid file");
|
|
||||||
|
|
||||||
const system = await emulatorsDb.query.systems.findFirst({
|
|
||||||
with: { commands: true },
|
|
||||||
where: eq(emulatorSchema.systems.name, config.systemSlug)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!system)
|
|
||||||
{
|
|
||||||
throw new Error(`Could not find system '${config.systemSlug}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionList = system.extension.join(',');
|
|
||||||
|
|
||||||
for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`)))
|
|
||||||
{
|
|
||||||
validFiles.push(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validFiles.length <= 0)
|
|
||||||
{
|
|
||||||
throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (config.systemSlug)
|
|
||||||
{
|
|
||||||
const system = await emulatorsDb.query.systems.findFirst({
|
|
||||||
with: { commands: true },
|
|
||||||
where: eq(emulatorSchema.systems.name, config.systemSlug)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!system)
|
|
||||||
{
|
|
||||||
throw new Error(`Could not find system '${config.systemSlug}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase())))
|
|
||||||
{
|
|
||||||
validFiles.push(gamePath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
const extensionList = system.extension.join(',');
|
|
||||||
throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`);
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
validFiles.push(gamePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return validFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param data Uses es-de system slug
|
|
||||||
* @param mainGlob The main file glob supported pattern to search for if game path is a directory
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
async getValidLaunchCommands (data: {
|
|
||||||
systemSlug: string;
|
|
||||||
gamePath: string;
|
|
||||||
mainGlob?: string | null;
|
|
||||||
}): Promise<CommandEntry[]>
|
|
||||||
{
|
|
||||||
|
|
||||||
const system = await emulatorsDb.query.systems.findFirst({
|
|
||||||
with: { commands: true },
|
|
||||||
where: eq(emulatorSchema.systems.name, data.systemSlug)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!system)
|
|
||||||
{
|
|
||||||
throw new Error(`Could not find system '${data.systemSlug}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!system.extension || system.extension.length <= 0)
|
|
||||||
{
|
|
||||||
throw new Error(`No extensions listed for system '${data.systemSlug}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadPath = config.get('downloadPath');
|
|
||||||
const gamePath = path.isAbsolute(data.gamePath) ? data.gamePath : path.join(downloadPath, data.gamePath);
|
|
||||||
|
|
||||||
const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob });
|
|
||||||
|
|
||||||
function escapeWindowsArg (arg: string): string
|
|
||||||
{
|
|
||||||
if (process.platform === 'win32')
|
|
||||||
{
|
|
||||||
return `"${arg
|
|
||||||
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
|
|
||||||
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
|
|
||||||
}"`;
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
if (arg.includes(' '))
|
|
||||||
{
|
|
||||||
return `"${arg}"`;
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
return arg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedCommands = await Promise.all(system.commands
|
|
||||||
.filter(c => !c.command.includes(`%ENABLESHORTCUTS%`))
|
|
||||||
.map(async (command, index) =>
|
|
||||||
{
|
|
||||||
const label = command.label;
|
|
||||||
let cmd = command.command;
|
|
||||||
|
|
||||||
let emulator: string | undefined = undefined;
|
|
||||||
let rom = validFiles[0];
|
|
||||||
|
|
||||||
if (cmd.includes('%ESCAPESPECIALS%'))
|
|
||||||
rom = rom.replace(/[&()^=;,]/g, '');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const staticVars: Record<string, string> = {
|
|
||||||
'%ROM%': escapeWindowsArg(rom),
|
|
||||||
'%ROMRAW%': validFiles[0],
|
|
||||||
'%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')),
|
|
||||||
'%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)),
|
|
||||||
'%ROMPATH%': escapeWindowsArg(gamePath),
|
|
||||||
'%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))),
|
|
||||||
'%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])),
|
|
||||||
'%ESCAPESPECIALS%': "",
|
|
||||||
'%HIDEWINDOW%': ""
|
|
||||||
};
|
|
||||||
|
|
||||||
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (_, injectFile: string) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const resolvedInjectFile = injectFile.replace(this.varRegex, (a) =>
|
|
||||||
{
|
|
||||||
return staticVars[a] ?? a;
|
|
||||||
});
|
|
||||||
if (existsSync(resolvedInjectFile))
|
|
||||||
{
|
|
||||||
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
|
|
||||||
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
} catch (error)
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const matches = Array.from(cmd.matchAll(this.varRegex));
|
|
||||||
const varList = await Promise.all(matches.map(async ([value]) =>
|
|
||||||
{
|
|
||||||
if (value.startsWith("%EMULATOR_"))
|
|
||||||
{
|
|
||||||
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
|
||||||
let execs = await this.findExecsByName(emulatorName);
|
|
||||||
let validExec = execs.find(e => e.exists);
|
|
||||||
|
|
||||||
emulator = emulatorName;
|
|
||||||
return [
|
|
||||||
[value, validExec ? validExec.binPath : undefined] as [string, string | undefined],
|
|
||||||
[`%EMUSOURCE%`, validExec?.type] as [string, string | undefined],
|
|
||||||
['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined],
|
|
||||||
['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined]
|
|
||||||
];
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = value[0].substring(1, value.length - 1);
|
|
||||||
return [[value, process.env[key]] as [string, string | undefined]];
|
|
||||||
}));
|
|
||||||
|
|
||||||
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
|
|
||||||
let startDir: string | undefined = undefined;
|
|
||||||
|
|
||||||
if ('%STARTDIR%' in vars)
|
|
||||||
{
|
|
||||||
delete vars['%STARTDIR%'];
|
|
||||||
|
|
||||||
cmd = cmd.replace(this.assignRegex, (match, p1, p2) =>
|
|
||||||
{
|
|
||||||
if (p1 === '%STARTDIR%')
|
|
||||||
{
|
|
||||||
startDir = this.varRegex.test(p2) ? staticVars[p2] : p2;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// missing variable
|
|
||||||
const invalid = Object.entries(vars).find(c => c[1] === undefined);
|
|
||||||
|
|
||||||
const formattedCommand = cmd.replace(this.varRegex, (s) => vars[s] ?? '').trim();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: index,
|
|
||||||
label: label ?? undefined,
|
|
||||||
command: formattedCommand,
|
|
||||||
startDir,
|
|
||||||
valid: !invalid, emulator,
|
|
||||||
emulatorSource: vars['%EMUSOURCE%'] as any,
|
|
||||||
metadata: {
|
|
||||||
romPath: validFiles[0],
|
|
||||||
emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1],
|
|
||||||
emulatorDir: vars['%EMUDIRRAW%']
|
|
||||||
}
|
|
||||||
} satisfies CommandEntry;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return formattedCommands.filter(c => !!c);
|
|
||||||
}
|
|
||||||
|
|
||||||
async load (ctx: PluginLoadingContextType)
|
|
||||||
{
|
|
||||||
ctx.hooks.emulators.findEmulatorSource.tapPromise(desc.name, async ({ sources, emulator }) =>
|
|
||||||
{
|
|
||||||
sources.push(...await this.findExecsByName(emulator));
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.emulators.findEmulatorForSystem.tapPromise(desc.name, async ({ system, emulators }) =>
|
|
||||||
{
|
|
||||||
emulators.push(...await this.getEmulatorsForSystem(system));
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.fetchRomFiles.tapPromise(desc.name, async ({ source, id }) =>
|
|
||||||
{
|
|
||||||
const localGame = await db.query.games.findFirst({
|
|
||||||
where: getLocalGameMatch(id, source),
|
|
||||||
columns: { path_fs: true, main_glob: true },
|
|
||||||
with: { platform: { columns: { es_slug: true } } }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!localGame?.path_fs)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadPath = config.get('downloadPath');
|
|
||||||
const path_fs = path.isAbsolute(localGame.path_fs) ? localGame.path_fs : path.join(downloadPath, localGame.path_fs);
|
|
||||||
|
|
||||||
return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob });
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.buildLaunchCommands.tapPromise(desc.name, async ({ systemSlug, source, id, gamePath, mainGlob }) =>
|
|
||||||
{
|
|
||||||
if (source === 'emulator')
|
|
||||||
{
|
|
||||||
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id.id) });
|
|
||||||
const allExecs = await this.findExecs(id.id, esEmulator);
|
|
||||||
return allExecs.map(exec => ({
|
|
||||||
command: exec.binPath,
|
|
||||||
id: exec.type,
|
|
||||||
emulator: id.id,
|
|
||||||
emulatorSource: exec.type,
|
|
||||||
metadata: {
|
|
||||||
emulatorBin: exec.binPath,
|
|
||||||
emulatorDir: exec.rootPath
|
|
||||||
},
|
|
||||||
valid: true
|
|
||||||
} satisfies CommandEntry));
|
|
||||||
}
|
|
||||||
|
|
||||||
const rommPlatform = systemSlug;
|
|
||||||
let esSystem: string | undefined = undefined;
|
|
||||||
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({
|
|
||||||
where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm'))
|
|
||||||
});
|
|
||||||
|
|
||||||
if (systemMapping) esSystem = systemMapping.system;
|
|
||||||
|
|
||||||
if (!esSystem)
|
|
||||||
{
|
|
||||||
const system = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.systems.name, systemSlug), columns: { name: true } });
|
|
||||||
if (system) esSystem = system.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (esSystem && gamePath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const commands = await this.getValidLaunchCommands({ systemSlug: esSystem, gamePath, mainGlob });
|
|
||||||
|
|
||||||
if (cores[esSystem])
|
|
||||||
{
|
|
||||||
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${id.source}/${id.id}`;
|
|
||||||
commands.push({
|
|
||||||
id: 'EMULATORJS',
|
|
||||||
label: "Emulator JS",
|
|
||||||
command: `core=${cores[esSystem]}&gameUrl=${encodeURIComponent(gameUrl)}`,
|
|
||||||
valid: true,
|
|
||||||
emulator: 'EMULATORJS',
|
|
||||||
metadata: {
|
|
||||||
romPath: gameUrl
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return commands;
|
|
||||||
} catch (error)
|
|
||||||
{
|
|
||||||
console.error(error);
|
|
||||||
if (error instanceof Error) return error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"name": "com.simeonradivoev.gameflow.es",
|
|
||||||
"displayName": "ES-DE Launcher",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "ES-DE Launch Configurations. Used as fallback",
|
|
||||||
"main": "./es-de.ts",
|
|
||||||
"icon": "https://impro.usercontent.one/appid/oneComWsb/domain/es-de.org/media/es-de.org/onewebmedia/ES-DE_logo.png",
|
|
||||||
"canDisable": false,
|
|
||||||
"category": "launchers",
|
|
||||||
"keywords": [
|
|
||||||
"integration",
|
|
||||||
"es-de"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"name": "com.simeonradivoev.gameflow.rclone",
|
|
||||||
"displayName": "Rclone Integration",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "Rclone integration for syncing saves",
|
|
||||||
"main": "./rclone.ts",
|
|
||||||
"icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3ERclone%3C%2Ftitle%3E%3Cpath%20d%3D%22M11.842.6258C9.3647.6813%206.9754%201.9906%205.646%204.2933c-.7593%201.3144-1.0647%202.7662-.966%204.1745a7.99%207.99%200%200%201%202.6568-.4541l1.4705-.0013c-.0093-.5594.1245-1.1284.4245-1.6482.8827-1.5284%202.837-2.0522%204.3654-1.1695%201.5284.8824%202.0519%202.8366%201.1695%204.365l-1.4782%202.5647%201.1955%202.0714%202.3914-.0004%201.4775-2.5655c2.0262-3.5088.8239-7.9959-2.6853-10.0217C14.4614.9118%2013.1396.5967%2011.842.6258m-1.5451%208.073-2.9605.0029C3.2844%208.7017%200%2011.9867%200%2016.0383c0%204.052%203.2844%207.3367%207.3364%207.3367%201.5174%200%202.9267-.4609%204.0967-1.2497a8%208%200%200%201-1.72-2.0748l-.7368-1.273c-.4799.288-1.0392.4565-1.6395.4565-1.765%200-3.1958-1.4307-3.1958-3.1958%200-1.7647%201.4307-3.1954%203.1958-3.1954l2.96-.0022%201.1962-2.0708zm9.587.7475a7.99%207.99%200%200%201-.935%202.5278l-.7344%201.2745c.4892.2717.915.6719%201.2153%201.192.8823%201.528.3585%203.4826-1.1699%204.365-1.528.8823-3.4828.3588-4.3651-1.1696l-1.482-2.5628h-2.3915L8.8256%2017.144l1.483%202.5626c2.0262%203.5091%206.513%204.7112%2010.022%202.685%203.5089-2.0257%204.7112-6.5125%202.6853-10.0216-.7588-1.3144-1.863-2.3052-3.132-2.9237%22%20%2F%3E%3C%2Fsvg%3E",
|
|
||||||
"category": "saves",
|
|
||||||
"keywords": [
|
|
||||||
"integration",
|
|
||||||
"rclone"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,473 +0,0 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
|
||||||
import desc from './package.json';
|
|
||||||
import { config, db, events } from "@/bun/api/app";
|
|
||||||
import path from 'node:path';
|
|
||||||
import unzip from 'unzip-stream';
|
|
||||||
import { ensureDir } from "fs-extra";
|
|
||||||
import { Readable } from "node:stream";
|
|
||||||
import { pipeline } from "node:stream/promises";
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import { randomUUIDv7 } from "bun";
|
|
||||||
import z from "zod";
|
|
||||||
import { createInterface } from "node:readline";
|
|
||||||
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
|
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
|
||||||
|
|
||||||
const DefaultLocalName = "Default_Local";
|
|
||||||
|
|
||||||
const SettingsSchema = z.object({
|
|
||||||
runWebGui: z.boolean()
|
|
||||||
.default(false)
|
|
||||||
.describe("Run the Web GUI that can be accessed at http://localhost:5572")
|
|
||||||
.meta({ title: "Run Web GUI" }),
|
|
||||||
globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"),
|
|
||||||
webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"),
|
|
||||||
remoteName: z.string().default(DefaultLocalName),
|
|
||||||
verboseLog: z.boolean()
|
|
||||||
.default(false)
|
|
||||||
.describe("Show detailed log of operation for debugging")
|
|
||||||
.meta({ $comment: JSON.stringify({ category: "debug" }) }),
|
|
||||||
importSaves: z.boolean().default(true).describe("Import Saves From the Destination. This will override local saves"),
|
|
||||||
exportSaves: z.boolean().default(true).describe("Export saves to remove. This will sync current saves with remote")
|
|
||||||
});
|
|
||||||
|
|
||||||
type SettingsType = z.infer<typeof SettingsSchema>;
|
|
||||||
const loginTokenUrlRegex = /http:\/\/[\w\d:\-@\[\]\.\/?=]+/gm;
|
|
||||||
|
|
||||||
export default class RcloneIntegration implements PluginType<SettingsType>
|
|
||||||
{
|
|
||||||
settingsSchema = SettingsSchema;
|
|
||||||
rclonePath: string | undefined;
|
|
||||||
server: Bun.Subprocess | undefined;
|
|
||||||
password: string;
|
|
||||||
user = "gameflow";
|
|
||||||
loginUrl: string | undefined = undefined;
|
|
||||||
eventsNames = [{
|
|
||||||
id: "open-web-gui",
|
|
||||||
title: "Open Web GUI",
|
|
||||||
description: "Open Web GUI",
|
|
||||||
action: "Open"
|
|
||||||
}, {
|
|
||||||
id: "refresh",
|
|
||||||
title: "Refresh Sources",
|
|
||||||
action: "Refresh"
|
|
||||||
}];
|
|
||||||
|
|
||||||
constructor()
|
|
||||||
{
|
|
||||||
this.password = randomUUIDv7();
|
|
||||||
}
|
|
||||||
|
|
||||||
async onEvent (id: string)
|
|
||||||
{
|
|
||||||
switch (id)
|
|
||||||
{
|
|
||||||
case "open-web-gui":
|
|
||||||
return { openTab: this.loginUrl };
|
|
||||||
break;
|
|
||||||
case "refresh":
|
|
||||||
await this.refresh();
|
|
||||||
return { reload: true };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async setup (ctx: PluginLoadingContextType<SettingsType>)
|
|
||||||
{
|
|
||||||
ctx.zodRegistry.add(SettingsSchema.shape.runWebGui, { requiresRestart: true });
|
|
||||||
ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true });
|
|
||||||
|
|
||||||
const toolsPath = path.join(config.get('downloadPath'), "tools");
|
|
||||||
await ensureDir(toolsPath);
|
|
||||||
const binaryMap: Record<string, string> = {
|
|
||||||
win32: '**/rclone.exe',
|
|
||||||
linux: 'rclone-*/rclone',
|
|
||||||
darwin: 'rclone-*/rclone'
|
|
||||||
};
|
|
||||||
const existingRclones = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath }));
|
|
||||||
if (existingRclones[0])
|
|
||||||
{
|
|
||||||
this.rclonePath = path.join(toolsPath, existingRclones[0]);
|
|
||||||
await this.startServer(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.setProgress(0.5, "Downloading RClone");
|
|
||||||
const platformMap: Record<string, string> = {
|
|
||||||
linux: "linux",
|
|
||||||
win32: "windows",
|
|
||||||
darwin: "osx"
|
|
||||||
};
|
|
||||||
const archMap: Record<string, string> = {
|
|
||||||
x64: "amd64",
|
|
||||||
arm64: "arm64"
|
|
||||||
};
|
|
||||||
const downloadUrl = `https://downloads.rclone.org/rclone-current-${platformMap[process.platform]}-${archMap[process.arch]}.zip`;
|
|
||||||
console.log("Starting Download", downloadUrl);
|
|
||||||
const rcCloseZip = await fetch(downloadUrl);
|
|
||||||
|
|
||||||
await ensureDir(toolsPath);
|
|
||||||
await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath }));
|
|
||||||
const dests = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath }));
|
|
||||||
if (dests[0])
|
|
||||||
{
|
|
||||||
this.rclonePath = path.join(toolsPath, dests[0]);
|
|
||||||
await fs.chmod(this.rclonePath, 0o755);
|
|
||||||
await this.startServer(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async refresh ()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const data = await this.request('/config/listremotes', {});
|
|
||||||
z.globalRegistry.add(SettingsSchema.shape.remoteName, {
|
|
||||||
examples: [''].concat(...data.remotes),
|
|
||||||
description: "The name of the remote to sync with"
|
|
||||||
});
|
|
||||||
} catch (error)
|
|
||||||
{
|
|
||||||
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
|
|
||||||
z.globalRegistry.add(SettingsSchema.shape.remoteName, {
|
|
||||||
examples: [''],
|
|
||||||
description: "The name of the remote to sync with"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async startServer (ctx: PluginLoadingContextType<SettingsType>)
|
|
||||||
{
|
|
||||||
const args: string[] = [];
|
|
||||||
if (ctx.config.get('runWebGui'))
|
|
||||||
{
|
|
||||||
args.push("--rc-web-gui");
|
|
||||||
args.push("--rc-web-gui-no-open-browser");
|
|
||||||
}
|
|
||||||
if (ctx.config.get(''))
|
|
||||||
{
|
|
||||||
args.push('-vv');
|
|
||||||
}
|
|
||||||
let env: Record<string, string> | undefined = undefined;
|
|
||||||
if (!ctx.config.get('globalConfig'))
|
|
||||||
{
|
|
||||||
env = { RCLONE_CONFIG: path.join(config.get('downloadPath'), 'tools', 'config', 'rclone', 'rclone.conf') };
|
|
||||||
}
|
|
||||||
ctx.config.set('webGuiPassword', this.password);
|
|
||||||
this.server = Bun.spawn([this.rclonePath!, "rcd", '--use-json-log', `--rc-user=${this.user}`, ...args, `--rc-pass=${this.password}`, "--rc-addr", "localhost:5572"], {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
env
|
|
||||||
});
|
|
||||||
const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) });
|
|
||||||
rl.on('line', e =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const data = JSON.parse(e);
|
|
||||||
|
|
||||||
if (data.level === 'error')
|
|
||||||
{
|
|
||||||
console.error(data.msg);
|
|
||||||
} else if (data.level === 'critical')
|
|
||||||
{
|
|
||||||
console.error(data.msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
else
|
|
||||||
{
|
|
||||||
console.log(e);
|
|
||||||
if (loginTokenUrlRegex.test(data.msg))
|
|
||||||
{
|
|
||||||
this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error)
|
|
||||||
{
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) =>
|
|
||||||
{
|
|
||||||
const handleResolve = (line: string) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const data = JSON.parse(line);
|
|
||||||
if (!loginTokenUrlRegex.test(data.msg)) return;
|
|
||||||
rl.off('line', handleResolve);
|
|
||||||
resolve(data);
|
|
||||||
} catch (error)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
};
|
|
||||||
rl.on('line', handleResolve);
|
|
||||||
setTimeout(() => { reject("Timeout"); }, 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
async request (path: string, body: any)
|
|
||||||
{
|
|
||||||
const response = await fetch(`http://localhost:5572${path}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Basic ${Buffer.from(`${this.user}:${this.password}`).toString('base64')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (response.ok)
|
|
||||||
{
|
|
||||||
return data;
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
throw new Error(response.statusText, { cause: data });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanup ()
|
|
||||||
{
|
|
||||||
await new Promise((resolve) =>
|
|
||||||
{
|
|
||||||
this.request('/core/quit', {}).catch(e =>
|
|
||||||
{
|
|
||||||
this.server?.kill("SIGKILL");
|
|
||||||
this.server = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() =>
|
|
||||||
{
|
|
||||||
this.request('/core/quit', { exitCode: 9 }).then(e =>
|
|
||||||
{
|
|
||||||
resolve(false);
|
|
||||||
this.server = undefined;
|
|
||||||
}).catch(e =>
|
|
||||||
{
|
|
||||||
resolve(false);
|
|
||||||
this.server?.kill("SIGKILL");
|
|
||||||
this.server = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
this.server?.exited.then(() => resolve(true));
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async load (ctx: PluginLoadingContextType<SettingsType>)
|
|
||||||
{
|
|
||||||
await this.setup(ctx);
|
|
||||||
|
|
||||||
ctx.hooks.games.prePlay.tapPromise({
|
|
||||||
name: desc.name,
|
|
||||||
stage: 10,
|
|
||||||
}, async ({ source, id, setProgress, saveFolderSlots, command }) =>
|
|
||||||
{
|
|
||||||
if (!this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return;
|
|
||||||
|
|
||||||
const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined;
|
|
||||||
if (!destination) return;
|
|
||||||
|
|
||||||
const remoteName = ctx.config.get('remoteName');
|
|
||||||
|
|
||||||
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
|
|
||||||
{
|
|
||||||
let supportsMetadata = true;
|
|
||||||
let src: string;
|
|
||||||
|
|
||||||
if (remoteName && remoteName !== DefaultLocalName)
|
|
||||||
{
|
|
||||||
src = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`;
|
|
||||||
|
|
||||||
const exists = await this.request('/operations/stat', {
|
|
||||||
fs: `${remoteName}:`,
|
|
||||||
remote: `gameflow/saves/${destination.join('/')}/${slot}`
|
|
||||||
}).catch(e => undefined);
|
|
||||||
if (!exists || !exists.item) return;
|
|
||||||
const remote = await this.request('/operations/fsinfo', {
|
|
||||||
fs: `${remoteName}:`
|
|
||||||
});
|
|
||||||
supportsMetadata = !remote.ReadMetadata;
|
|
||||||
if (supportsMetadata)
|
|
||||||
{
|
|
||||||
console.warn("Remote", remoteName, "does not support metadata");
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
src = path.join(config.get('downloadPath'), 'saves', ...destination, slot);
|
|
||||||
if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', ...destination, slot))) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const job = await this.request('/sync/copy', {
|
|
||||||
srcFs: src,
|
|
||||||
dstFs: cwd,
|
|
||||||
createEmptySrcDirs: true,
|
|
||||||
_async: true,
|
|
||||||
_config: {
|
|
||||||
CheckFirst: true,
|
|
||||||
Metadata: true,
|
|
||||||
NoCheckDest: supportsMetadata
|
|
||||||
}
|
|
||||||
}).catch(e =>
|
|
||||||
{
|
|
||||||
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
|
||||||
return undefined;
|
|
||||||
});;
|
|
||||||
|
|
||||||
await new Promise(async (resolve, reject) =>
|
|
||||||
{
|
|
||||||
setProgress(0, "RClone: Syncing Saves");
|
|
||||||
|
|
||||||
const checkInterval = setInterval(async () =>
|
|
||||||
{
|
|
||||||
const stat = await this.request('/job/status', { jobid: job.jobid });
|
|
||||||
if (stat.finished)
|
|
||||||
{
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
console.log(stat.output);
|
|
||||||
resolve(true);
|
|
||||||
|
|
||||||
} else if (stat.error)
|
|
||||||
{
|
|
||||||
reject(stat.error);
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
setProgress(stat.progress, "RClone: Syncing Saves");
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) =>
|
|
||||||
{
|
|
||||||
if (!this.rclonePath || !ctx.config.get('exportSaves')) return;
|
|
||||||
const local = await db.query.games.findFirst({ where: getLocalGameMatch(id, source) });
|
|
||||||
console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(","));
|
|
||||||
|
|
||||||
const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined;
|
|
||||||
if (!destination) return;
|
|
||||||
|
|
||||||
const remoteName = ctx.config.get('remoteName');
|
|
||||||
|
|
||||||
await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) =>
|
|
||||||
{
|
|
||||||
let suportsMetadata = false;
|
|
||||||
let dest: string;
|
|
||||||
if (remoteName && remoteName !== DefaultLocalName)
|
|
||||||
{
|
|
||||||
dest = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`;
|
|
||||||
const remote = await this.request('/operations/fsinfo', {
|
|
||||||
fs: `${remoteName}:`
|
|
||||||
});
|
|
||||||
suportsMetadata = !remote.ReadMetadata;
|
|
||||||
if (suportsMetadata)
|
|
||||||
{
|
|
||||||
console.warn("Remote", remoteName, "does not support metadata");
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
dest = path.join(config.get('downloadPath'), 'saves', ...destination, slot);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filter = {
|
|
||||||
IncludeRule: Array.isArray(change.subPath) ?
|
|
||||||
change.subPath.map(s =>
|
|
||||||
{
|
|
||||||
if (change.isGlob) return s;
|
|
||||||
else s.replaceAll('\\', '/');
|
|
||||||
}) :
|
|
||||||
[change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/')]
|
|
||||||
};
|
|
||||||
|
|
||||||
let jobid: number | undefined = undefined;
|
|
||||||
|
|
||||||
if (change.fixedSize)
|
|
||||||
{
|
|
||||||
await this.request('/sync/copy', {
|
|
||||||
srcFs: change.cwd,
|
|
||||||
dstFs: dest,
|
|
||||||
createEmptySrcDirs: true,
|
|
||||||
_async: true,
|
|
||||||
_config: {
|
|
||||||
NoCheckDest: true
|
|
||||||
},
|
|
||||||
_filter: filter
|
|
||||||
})
|
|
||||||
.then(job => jobid = job.jobid)
|
|
||||||
.catch(e =>
|
|
||||||
{
|
|
||||||
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
await this.request('/sync/sync', {
|
|
||||||
srcFs: change.cwd,
|
|
||||||
dstFs: dest,
|
|
||||||
createEmptySrcDirs: true,
|
|
||||||
_async: true,
|
|
||||||
_config: {
|
|
||||||
CheckSum: true,
|
|
||||||
CheckFirst: true,
|
|
||||||
Metadata: true,
|
|
||||||
MetadataSet: {
|
|
||||||
igdb_id: local?.igdb_id ? String(local?.igdb_id) : undefined,
|
|
||||||
ra_id: local?.ra_id ? String(local?.ra_id) : undefined
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_filter: filter
|
|
||||||
})
|
|
||||||
.then(job => jobid = job.jobid)
|
|
||||||
.catch(e =>
|
|
||||||
{
|
|
||||||
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!jobid) return;
|
|
||||||
await new Promise(async (resolve, reject) =>
|
|
||||||
{
|
|
||||||
const checkInterval = setInterval(async () =>
|
|
||||||
{
|
|
||||||
const stat = await this.request('/job/status', { jobid });
|
|
||||||
if (stat.finished)
|
|
||||||
{
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
console.log(stat.output);
|
|
||||||
resolve(true);
|
|
||||||
|
|
||||||
} else if (stat.error)
|
|
||||||
{
|
|
||||||
reject(stat.error);
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
|
|
||||||
const stats = await this.request('/core/stats', {
|
|
||||||
group: `job/${jobid}`
|
|
||||||
});
|
|
||||||
|
|
||||||
if (stats.transfers > 0)
|
|
||||||
{
|
|
||||||
events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' });
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
|
||||||
import desc from './package.json';
|
|
||||||
import secrets from "@/bun/api/secrets";
|
|
||||||
import PQueue from 'p-queue';
|
|
||||||
import * as igdb from '@phalcode/ts-igdb-client';
|
|
||||||
import { checkLoginAndRefreshTwitch } from "@/bun/api/auth";
|
|
||||||
import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
|
|
||||||
export default class IgdbIntegration implements PluginType
|
|
||||||
{
|
|
||||||
queue: PQueue;
|
|
||||||
|
|
||||||
constructor()
|
|
||||||
{
|
|
||||||
this.queue = new PQueue({ concurrency: 8, interval: 1000, intervalCap: 4, strict: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async apiCall<T> (subPath: string, query: string)
|
|
||||||
{
|
|
||||||
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
|
||||||
const headers = new Headers({
|
|
||||||
"Client-ID": process.env.TWITCH_CLIENT_ID ?? '',
|
|
||||||
Authorization: `Bearer ${access_token}`,
|
|
||||||
Accept: "application/json"
|
|
||||||
});
|
|
||||||
const response = await this.queue.add(() => fetch(`https://api.igdb.com/v4${subPath}`, {
|
|
||||||
headers: headers,
|
|
||||||
method: "POST",
|
|
||||||
body: query
|
|
||||||
}));
|
|
||||||
if (response.ok)
|
|
||||||
{
|
|
||||||
return response.json() as T;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanup ()
|
|
||||||
{
|
|
||||||
this.queue.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
async load (ctx: PluginLoadingContextType)
|
|
||||||
{
|
|
||||||
await checkLoginAndRefreshTwitch();
|
|
||||||
|
|
||||||
ctx.hooks.games.gameLookup.tapPromise(desc.name, async (matches, { source, id, search }) =>
|
|
||||||
{
|
|
||||||
if (!process.env.TWITCH_CLIENT_ID) return matches;
|
|
||||||
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
|
||||||
if (!access_token)
|
|
||||||
{
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((source === 'igdb' && id) || search)
|
|
||||||
{
|
|
||||||
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
|
|
||||||
|
|
||||||
const { data: games } = await this.queue.add(() => client.request('games')
|
|
||||||
.pipe(...(search ? [igdb.search(search)] : []),
|
|
||||||
igdb.fields(['id', 'name', 'summary', 'screenshots.image_id', 'slug', 'first_release_date', 'rating', 'genres.name', 'involved_companies.company.name', 'keywords.name', 'game_modes.name', 'cover.image_id', 'age_ratings.rating_category.rating', 'platforms.name', 'platforms.abbreviation', 'platforms.slug']),
|
|
||||||
...(source === 'igdb' && id ? [igdb.where('id', '=', Number(id))] : []),
|
|
||||||
igdb.limit(10)).execute());
|
|
||||||
|
|
||||||
matches.set(desc.name, games.filter(g => !!g.name)
|
|
||||||
.map(g =>
|
|
||||||
{
|
|
||||||
const lookup: GameLookup = {
|
|
||||||
source: 'igdb',
|
|
||||||
id: String(g.id),
|
|
||||||
coverUrl: g.cover ? `https://images.igdb.com/igdb/image/upload/t_720p/${g.cover.image_id}.webp` : undefined,
|
|
||||||
screenshotUrls: g.screenshots?.map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) ?? [],
|
|
||||||
name: g.name!,
|
|
||||||
summary: g.summary,
|
|
||||||
genres: g.genres?.map(g => g.name!) ?? [],
|
|
||||||
companies: g.involved_companies?.filter(c => c.company?.name).map(c => c.company?.name!) ?? [],
|
|
||||||
game_modes: g.game_modes?.map(m => m.name!) ?? [],
|
|
||||||
age_ratings: g.age_ratings?.map(r => r.rating_category?.rating!) ?? [],
|
|
||||||
player_count: undefined,
|
|
||||||
// UNIX date, needs to be converted
|
|
||||||
first_release_date: g.first_release_date ? g.first_release_date * 1000 : undefined,
|
|
||||||
average_rating: g.rating ?? undefined,
|
|
||||||
keywords: g.keywords?.map(k => k.name!) ?? [],
|
|
||||||
igdb_id: g.id ?? undefined,
|
|
||||||
platforms: g.platforms?.map(p => ({ id: p.id!, name: p.abbreviation, displayName: p.name!, slug: p.slug! })) ?? [],
|
|
||||||
slug: g.slug
|
|
||||||
};
|
|
||||||
|
|
||||||
return lookup;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches.set(desc.name, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) =>
|
|
||||||
{
|
|
||||||
let query: string | undefined = undefined;
|
|
||||||
if (source && id)
|
|
||||||
{
|
|
||||||
if (source !== 'igdb') return;
|
|
||||||
query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where id = ${id};`;
|
|
||||||
|
|
||||||
}
|
|
||||||
else if (slug)
|
|
||||||
{
|
|
||||||
query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where slug = "${slug}";`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query)
|
|
||||||
{
|
|
||||||
const data = await this.apiCall<[any]>('/platforms', query);
|
|
||||||
if (!data || data.length <= 0) return;
|
|
||||||
return {
|
|
||||||
slug: data[0].slug,
|
|
||||||
url_logo: `https://images.igdb.com/igdb/image/upload/t_logo_med/${data[0].platform_logo.image_id}.png`,
|
|
||||||
name: data[0].name,
|
|
||||||
family_name: data[0].platform_family?.name
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"name": "com.simeonradivoev.gameflow.igdb",
|
|
||||||
"displayName": "IGDB Integration",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "IGDB Metadata Integration",
|
|
||||||
"main": "./igdb.ts",
|
|
||||||
"icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EIGDB%3C%2Ftitle%3E%3Cpath%20d%3D%22M24%206.228c-8%20.002-16%200-24%200v11.543a88.875%2088.875%200%200%201%202.271-.333%2074.051%2074.051%200%200%201%2017.038-.28c1.57.153%203.134.363%204.69.614V6.228zm-.706.707v10.013a74.747%2074.747%200%200%200-22.588%200V6.934h22.588ZM7.729%208.84a2.624%202.624%200%200%200-1.857.72%202.55%202.55%200%200%200-.73%201.33c-.098.5-.063%201.03.112%201.51.177.488.515.917.954%201.196.547.354%201.224.472%201.865.401a3.242%203.242%200%200%200%201.786-.777c-.003-.724.002-1.449-.002-2.173-.725.004-1.45-.002-2.174.003.003.317%200%20.634.001.951h1.105c.002.236%200%20.473.002.71-.268.196-.603.286-.932.298-.32.02-.65-.05-.922-.225a1.464%201.464%200%200%201-.59-.744c-.18-.499-.134-1.085.163-1.53.23-.355.619-.61%201.043-.647a1.8%201.8%200%200%201%201.012.206c.152.082.286.192.424.295.228-.281.461-.559.692-.838a3.033%203.033%200%200%200-.595-.403c-.418-.212-.892-.285-1.357-.283Zm11.66.086c-.093%200-.187.002-.28%200-.68.002-1.359-.004-2.038.003.003%201.666%200%203.332.002%204.998h2.497c.239-.002.478-.034.709-.097.276-.076.546-.208.742-.422.194-.208.297-.492.304-.776.016-.278-.032-.572-.195-.804-.175-.252-.453-.408-.734-.514.211-.122.407-.285.521-.505.134-.246.149-.535.117-.807a1.156%201.156%200%200%200-.436-.73c-.264-.207-.599-.304-.93-.334a2.757%202.757%200%200%200-.279-.012Zm-16.715%200v5.002h1.102V8.927c-.368-.002-.735%200-1.102%200zm8.524%200v5.002h2.016a2.87%202.87%200%200%200%201.07-.211%202.445%202.445%200%200%200%201.174-.993c.34-.555.429-1.244.292-1.876a2.367%202.367%200%200%200-.828-1.338c-.478-.387-1.096-.577-1.707-.584h-2.017zm6.949.967c.392.002.784-.001%201.176.002.183.011.38.054.51.19.11.112.136.28.112.43a.436.436%200%200%201-.22.316%201.082%201.082%200%200%201-.483.116c-.365.002-.73-.001-1.094.001-.002-.351%200-.703-.001-1.054zm-5.031.026c.28%200%20.567.053.815.19.274.149.491.396.607.685.113.272.138.574.107.865a1.456%201.456%200%200%201-.335.786%201.425%201.425%200%200%201-.865.466c-.168.031-.34.022-.51.023h-.632V9.92h.813zm5.03%201.948h1.36c.174.006.354.035.505.127.11.066.191.18.212.308.025.15.004.32-.099.44-.102.12-.258.176-.409.2-.172.032-.348.02-.522.022-.35-.001-.698.002-1.047-.001v-1.096z%22%20%2F%3E%3C%2Fsvg%3E",
|
|
||||||
"category": "sources",
|
|
||||||
"keywords": [
|
|
||||||
"integration",
|
|
||||||
"igdb"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
"description": "ROMM Server Integration",
|
"description": "ROMM Server Integration",
|
||||||
"main": "./romm.ts",
|
"main": "./romm.ts",
|
||||||
"icon": "https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg",
|
"icon": "https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg",
|
||||||
"category": "sources",
|
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
"romm"
|
"romm"
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,41 @@
|
||||||
|
|
||||||
|
|
||||||
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
|
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
|
||||||
import { config, events } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { hashFile, isArchive, isSteamDeckGameMode } from "@/bun/utils";
|
import { hashFile, isSteamDeckGameMode } from "@/bun/utils";
|
||||||
import { CACHE_KEYS, getOrCached } from "@/bun/api/cache";
|
import { CACHE_KEYS, getOrCached } from "@/bun/api/cache";
|
||||||
import secrets from "@/bun/api/secrets";
|
import secrets from "@/bun/api/secrets";
|
||||||
import { getAuthToken } from "@/clients/romm/core/auth.gen";
|
import { getAuthToken } from "@/clients/romm/core/auth.gen";
|
||||||
import { client } from "@/clients/romm/client.gen";
|
import { client } from "@/clients/romm/client.gen";
|
||||||
import { validateGameSource } from "@/bun/api/games/services/statusService";
|
|
||||||
import z from "zod";
|
|
||||||
import { checkLoginAndRefreshRomm } from "@/bun/api/auth";
|
|
||||||
import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared";
|
|
||||||
import Conf from "conf";
|
|
||||||
|
|
||||||
const SettingsSchema = z.object({
|
export default class RommIntegration implements PluginType
|
||||||
savesSync: z.boolean().default(false).describe("Experimental save sync support"),
|
|
||||||
clientApiToken: z.string().optional().describe("Generate a long lived token from the ROMM server")
|
|
||||||
});
|
|
||||||
|
|
||||||
type SettingsType = z.infer<typeof SettingsSchema>;
|
|
||||||
|
|
||||||
export default class RommIntegration implements PluginType<SettingsType>
|
|
||||||
{
|
{
|
||||||
settingsSchema = SettingsSchema;
|
|
||||||
isSteamDeck = false;
|
isSteamDeck = false;
|
||||||
orderByMap: Record<string, string> = {
|
|
||||||
added: "created_at",
|
|
||||||
activity: "created_at",
|
|
||||||
name: "name",
|
|
||||||
release: "metadatum.first_release_date"
|
|
||||||
};
|
|
||||||
|
|
||||||
async checkRemote ()
|
async updateClient ()
|
||||||
{
|
|
||||||
if (!config.has('rommAddress')) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAccessToken (config: Conf<SettingsType>)
|
|
||||||
{
|
|
||||||
if (process.env.ROMM_CLIENT_TOKEN) return process.env.ROMM_CLIENT_TOKEN;
|
|
||||||
const client_token = config.get('clientApiToken');
|
|
||||||
if (client_token) return client_token;
|
|
||||||
return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateClient (pluginConfig: Conf<SettingsType>)
|
|
||||||
{
|
{
|
||||||
client.setConfig({
|
client.setConfig({
|
||||||
baseUrl: config.get('rommAddress'),
|
baseUrl: config.get('rommAddress'),
|
||||||
auth: (auth) =>
|
async auth (auth)
|
||||||
{
|
{
|
||||||
if (auth.scheme === 'bearer')
|
if (auth.scheme === 'bearer')
|
||||||
{
|
{
|
||||||
return this.getAccessToken(pluginConfig);
|
return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAuthToken (config: Conf<SettingsType>)
|
async getAuthToken ()
|
||||||
{
|
{
|
||||||
return getAuthToken({
|
return getAuthToken({
|
||||||
scheme: 'bearer',
|
scheme: 'bearer',
|
||||||
type: "http"
|
type: "http"
|
||||||
}, async (a) => this.getAccessToken(config));
|
}, async (a) => (await secrets.get({ service: "gameflow", name: 'romm_access_token' })) ?? undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRommPlatforms ()
|
async getAllRommPlatforms ()
|
||||||
|
|
@ -80,12 +47,9 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
{
|
{
|
||||||
const game: FrontEndGameType = {
|
const game: FrontEndGameType = {
|
||||||
id: { id: String(rom.id), source: 'romm' },
|
id: { id: String(rom.id), source: 'romm' },
|
||||||
path_covers: [`/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`],
|
path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`,
|
||||||
last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null,
|
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
||||||
updated_at: new Date(rom.created_at),
|
updated_at: new Date(rom.created_at),
|
||||||
metadata: {
|
|
||||||
first_release_date: rom.metadatum.first_release_date !== null ? new Date(rom.metadatum.first_release_date) : null,
|
|
||||||
},
|
|
||||||
slug: rom.slug,
|
slug: rom.slug,
|
||||||
platform_id: rom.platform_id,
|
platform_id: rom.platform_id,
|
||||||
platform_display_name: rom.platform_display_name,
|
platform_display_name: rom.platform_display_name,
|
||||||
|
|
@ -109,17 +73,9 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
fs_size_bytes: rom.fs_size_bytes,
|
fs_size_bytes: rom.fs_size_bytes,
|
||||||
local: false,
|
local: false,
|
||||||
missing: rom.missing_from_fs,
|
missing: rom.missing_from_fs,
|
||||||
igdb_id: rom.igdb_id,
|
genres: rom.metadatum.genres,
|
||||||
ra_id: rom.ra_id,
|
companies: rom.metadatum.companies,
|
||||||
metadata: {
|
release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined
|
||||||
age_ratings: rom.metadatum.age_ratings,
|
|
||||||
genres: rom.metadatum.genres,
|
|
||||||
companies: rom.metadatum.companies,
|
|
||||||
game_modes: rom.metadatum.game_modes,
|
|
||||||
player_count: rom.metadatum.player_count,
|
|
||||||
average_rating: rom.metadatum.average_rating,
|
|
||||||
first_release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : null
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const userData = await getCurrentUserApiUsersMeGet();
|
const userData = await getCurrentUserApiUsersMeGet();
|
||||||
|
|
@ -152,75 +108,62 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
return detailed;
|
return detailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async load (ctx: PluginLoadingContextType<SettingsType>)
|
async setup ()
|
||||||
{
|
{
|
||||||
this.isSteamDeck = isSteamDeckGameMode();
|
this.isSteamDeck = isSteamDeckGameMode();
|
||||||
ctx.setProgress(0, "Logging Into Romm");
|
await this.updateClient();
|
||||||
await this.updateClient(ctx.config);
|
}
|
||||||
await checkLoginAndRefreshRomm();
|
|
||||||
await this.updateClient(ctx.config);
|
|
||||||
|
|
||||||
|
load (ctx: PluginContextType)
|
||||||
|
{
|
||||||
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
|
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
|
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
|
||||||
{
|
{
|
||||||
|
|
||||||
|
const orderByMap: Record<string, string> = {
|
||||||
|
added: "created_at",
|
||||||
|
activity: "created_at",
|
||||||
|
name: "name"
|
||||||
|
};
|
||||||
|
|
||||||
const rommGames = await getRomsApiRomsGet({
|
const rommGames = await getRomsApiRomsGet({
|
||||||
query: {
|
query: {
|
||||||
platform_ids: query.platform_id ? [query.platform_id] : undefined,
|
platform_ids: query.platform_id ? [query.platform_id] : undefined,
|
||||||
collection_id: query.collection_id,
|
collection_id: query.collection_id,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
offset: query.offset,
|
offset: query.offset,
|
||||||
order_by: this.orderByMap[query.orderBy ?? ''],
|
order_by: orderByMap[query.orderBy ?? '']
|
||||||
with_filter_values: false,
|
|
||||||
genres: query.genres,
|
|
||||||
genres_logic: "all",
|
|
||||||
age_ratings: query.age_ratings,
|
|
||||||
search_term: query.search,
|
|
||||||
}, throwOnError: true
|
}, throwOnError: true
|
||||||
});
|
});
|
||||||
|
|
||||||
games.push(...rommGames.data.items.map(g =>
|
games.push(...rommGames.data.items.map(g =>
|
||||||
{
|
{
|
||||||
const game: FrontEndGameTypeWithIds = {
|
return this.convertRomToFrontend(g);
|
||||||
...this.convertRomToFrontend(g),
|
|
||||||
igdb_id: g.igdb_id,
|
|
||||||
ra_id: g.ra_id
|
|
||||||
};
|
|
||||||
return game;
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) =>
|
|
||||||
{
|
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
if (source && source !== 'romm') return;
|
|
||||||
|
|
||||||
const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true });
|
|
||||||
rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r));
|
|
||||||
rommFilters.data.companies.forEach(r => filters.companies.add(r));
|
|
||||||
rommFilters.data.languages.forEach(r => filters.languages.add(r));
|
|
||||||
rommFilters.data.player_counts.forEach(r => filters.player_counts.add(r));
|
|
||||||
rommFilters.data.genres.forEach(r => filters.genres.add(r));
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) =>
|
ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
if (service !== 'romm') return;
|
if (service !== 'romm') return;
|
||||||
await this.updateClient(ctx.config);
|
await this.updateClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) =>
|
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id, localGame }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
if (source !== 'romm') return;
|
if (source !== 'romm') return;
|
||||||
|
|
||||||
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
||||||
if (rom.data)
|
if (rom.data)
|
||||||
{
|
{
|
||||||
const romGame = await this.convertRomToFrontendDetailed(rom.data);
|
const romGame = await this.convertRomToFrontendDetailed(rom.data);
|
||||||
|
if (localGame)
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
...romGame,
|
||||||
|
...localGame,
|
||||||
|
};
|
||||||
|
}
|
||||||
return romGame;
|
return romGame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +172,6 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
|
|
||||||
ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) =>
|
ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
if (source !== 'romm') return;
|
if (source !== 'romm') return;
|
||||||
|
|
||||||
const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data;
|
const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data;
|
||||||
|
|
@ -239,9 +181,8 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
|
|
||||||
const files = await Promise.all(rom.files.map(async f =>
|
const files = await Promise.all(rom.files.map(async f =>
|
||||||
{
|
{
|
||||||
getRomContentApiRomsIdContentFileNameGet;
|
|
||||||
const file: DownloadFileEntry = {
|
const file: DownloadFileEntry = {
|
||||||
url: new URL(`${config.get('rommAddress')}/api/roms/${f.id}/files/content/${f.file_name}`),
|
url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`),
|
||||||
file_name: f.file_name,
|
file_name: f.file_name,
|
||||||
file_path: f.file_path,
|
file_path: f.file_path,
|
||||||
size: f.file_size_bytes,
|
size: f.file_size_bytes,
|
||||||
|
|
@ -250,21 +191,8 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
return file;
|
return file;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let extract_path: string | undefined = undefined;
|
|
||||||
let path_fs = path.join(rom.fs_path, rom.fs_name);
|
|
||||||
if (files.length === 1)
|
|
||||||
{
|
|
||||||
if (isArchive(files[0].file_name))
|
|
||||||
{
|
|
||||||
extract_path = '.';
|
|
||||||
path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const info: DownloadInfo = {
|
const info: DownloadInfo = {
|
||||||
platform: {
|
platform: {
|
||||||
source: 'romm',
|
|
||||||
id: String(rommPlatform.id),
|
|
||||||
slug: rommPlatform.slug,
|
slug: rommPlatform.slug,
|
||||||
name: rommPlatform.name,
|
name: rommPlatform.name,
|
||||||
family_name: rommPlatform.family_name ?? undefined
|
family_name: rommPlatform.family_name ?? undefined
|
||||||
|
|
@ -276,24 +204,21 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
ra_id: rom.ra_id ?? undefined,
|
ra_id: rom.ra_id ?? undefined,
|
||||||
summary: rom.summary ?? undefined,
|
summary: rom.summary ?? undefined,
|
||||||
name: rom.name ?? "Unknown",
|
name: rom.name ?? "Unknown",
|
||||||
path_fs,
|
path_fs: path.join(rom.fs_path, rom.fs_name),
|
||||||
source_id: String(rom.id),
|
source_id: String(rom.id),
|
||||||
slug: rom.slug ?? undefined,
|
slug: rom.slug ?? undefined,
|
||||||
system_slug: rommPlatform.slug,
|
system_slug: rommPlatform.slug,
|
||||||
metadata: rom.metadatum,
|
metadata: rom.metadatum,
|
||||||
files,
|
files,
|
||||||
auth: await this.getAuthToken(ctx.config),
|
auth: await this.getAuthToken()
|
||||||
extract_path,
|
|
||||||
id: "romm"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return [info];
|
return info;
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) =>
|
ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
const files: DownloadFileEntry[] = [];
|
const files: DownloadFileEntry[] = [];
|
||||||
const allRommPlatforms = await this.getAllRommPlatforms();
|
const allRommPlatforms = await this.getAllRommPlatforms();
|
||||||
|
|
||||||
|
|
@ -319,22 +244,21 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (files.length > 0) return { files, auth: await this.getAuthToken(ctx.config) };
|
if (files.length > 0) return { files, auth: await this.getAuthToken() };
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) =>
|
ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
const rommPlatforms = await this.getAllRommPlatforms();
|
const rommPlatforms = await this.getAllRommPlatforms();
|
||||||
if (rommPlatforms)
|
if (rommPlatforms)
|
||||||
{
|
{
|
||||||
const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug);
|
const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug);
|
||||||
if (rommPlatform)
|
if (rommPlatform)
|
||||||
{
|
{
|
||||||
const rommGames = await getRomsApiRomsGet({ query: { genres: game.metadata.genres, genres_logic: 'any' } });
|
const rommGames = await getRomsApiRomsGet({ query: { genres: game.genres, genres_logic: 'any' } });
|
||||||
if (rommGames.data)
|
if (rommGames.data)
|
||||||
{
|
{
|
||||||
games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g) })));
|
games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g), metadata: g.metadatum })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -342,7 +266,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
|
|
||||||
ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) =>
|
ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
const rommPlatforms = await this.getAllRommPlatforms();
|
const rommPlatforms = await this.getAllRommPlatforms();
|
||||||
const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!));
|
const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!));
|
||||||
if (rommPlatforms)
|
if (rommPlatforms)
|
||||||
|
|
@ -372,7 +296,6 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
|
|
||||||
ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) =>
|
ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
if (source !== 'romm') return;
|
if (source !== 'romm') return;
|
||||||
const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } });
|
const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } });
|
||||||
if (rommPlatform)
|
if (rommPlatform)
|
||||||
|
|
@ -395,13 +318,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
|
|
||||||
ctx.hooks.games.fetchPlatforms.tapPromise(desc.name, async ({ platforms }) =>
|
ctx.hooks.games.fetchPlatforms.tapPromise(desc.name, async ({ platforms }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
const rommPlatforms = await this.getAllRommPlatforms();
|
||||||
const rommPlatforms = await this.getAllRommPlatforms().catch(e =>
|
|
||||||
{
|
|
||||||
console.error(e);
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rommPlatforms)
|
if (rommPlatforms)
|
||||||
{
|
{
|
||||||
const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
|
const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
|
||||||
|
|
@ -435,139 +352,16 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) =>
|
ctx.hooks.games.updatePlayed.tapPromise(desc.name, async ({ source, id }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
if (source !== 'romm') return false;
|
||||||
if (source !== 'romm' || !ctx.config.get('savesSync')) return;
|
|
||||||
if (!saveFolderSlots) return;
|
|
||||||
|
|
||||||
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
|
|
||||||
{
|
|
||||||
setProgress(0, "saves");
|
|
||||||
|
|
||||||
const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } });
|
|
||||||
if (saveFiles.error)
|
|
||||||
{
|
|
||||||
console.error(saveFiles.error);
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
const rommSlot = saveFiles.data.slots.find(s => s.slot === 'gameflow' && s.latest.file_name_no_tags === slot);
|
|
||||||
if (rommSlot)
|
|
||||||
{
|
|
||||||
const auth = await this.getAuthToken(ctx.config);
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (auth)
|
|
||||||
headers['Authorization'] = auth;
|
|
||||||
|
|
||||||
const saveResponse = await fetch(`${config.get('rommAddress')}${rommSlot.latest.download_path}`, { headers });
|
|
||||||
if (!saveResponse.ok)
|
|
||||||
{
|
|
||||||
console.error("Error downloading save", saveResponse.statusText);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveArchive = new Bun.Archive(await saveResponse.blob());
|
|
||||||
setProgress(50, "saves");
|
|
||||||
const count = await saveArchive.extract(cwd);
|
|
||||||
setProgress(100, "saves");
|
|
||||||
console.log("Loaded", count, "save files");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(100, "saves");
|
|
||||||
await Bun.sleep(1000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should run after emulators decide on saves
|
|
||||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) =>
|
|
||||||
{
|
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
if (source !== 'romm' || !ctx.config.get('savesSync')) return;
|
|
||||||
|
|
||||||
const sourceValidation = await validateGameSource(source, id);
|
|
||||||
if (!sourceValidation.valid)
|
|
||||||
{
|
|
||||||
console.warn("Invalid Source", sourceValidation.reason, "Skipping updates");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared && !f.isGlob).flatMap(s => Array.isArray(s.subPath) ? s.subPath.map(p => ({ cwd: s.cwd, subPath: p })) : [{ cwd: s.cwd, subPath: s.subPath }]);
|
|
||||||
|
|
||||||
const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } });
|
|
||||||
if (saveFiles.error)
|
|
||||||
{
|
|
||||||
console.error(saveFiles.error);
|
|
||||||
} else if (saveFolderPath)
|
|
||||||
{
|
|
||||||
for (let i = 0; i < saveFiles.data.slots.length; i++)
|
|
||||||
{
|
|
||||||
const slot = saveFiles.data.slots[i];
|
|
||||||
const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`);
|
|
||||||
if (await fs.exists(savePath))
|
|
||||||
{
|
|
||||||
const stat = await fs.stat(savePath);
|
|
||||||
if (stat.mtimeMs > new Date(slot.latest.updated_at).getTime())
|
|
||||||
{
|
|
||||||
const subPath = path.join(slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`);
|
|
||||||
if (!finalSavePaths.some(f => f.subPath === subPath))
|
|
||||||
{
|
|
||||||
// Add newer files to the list, maybe they were changed offscreen.
|
|
||||||
finalSavePaths.push({ subPath, cwd: saveFolderPath });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
|
|
||||||
const finalSavePaths = Object.entries(validChangedSaveFiles).filter(([slot, change]) => !change.isGlob && !change.shared);
|
|
||||||
|
|
||||||
if (finalSavePaths.length > 0)
|
|
||||||
{
|
|
||||||
console.log("Files Changed:", finalSavePaths.map(([slot, change]) => Array.isArray(change.subPath) ? change.subPath.join(',') : change.subPath)?.join(", "));
|
|
||||||
|
|
||||||
await Promise.all(finalSavePaths.map(async ([slot, change]) =>
|
|
||||||
{
|
|
||||||
const savesArray = Array.isArray(change.subPath) ? change.subPath : [change.subPath];
|
|
||||||
|
|
||||||
// TODO: handle directories
|
|
||||||
const archive = new Bun.Archive(Object.fromEntries(savesArray.map(s => [s, Bun.file(path.join(change.cwd, s))])));
|
|
||||||
const data: FormData = new FormData();
|
|
||||||
data.append('saveFile', await archive.blob(), slot);
|
|
||||||
|
|
||||||
const url = new URL(`${config.get('rommAddress')}/api/saves`);
|
|
||||||
url.searchParams.set('rom_id', id);
|
|
||||||
url.searchParams.set('slot', slot);
|
|
||||||
url.searchParams.set('autocleanup', "true");
|
|
||||||
url.searchParams.set('autocleanup_limit', "2");
|
|
||||||
if (command.emulator)
|
|
||||||
url.searchParams.set('emulator', command.emulator);
|
|
||||||
url.searchParams.set('overwrite', "true");
|
|
||||||
|
|
||||||
const auth = await this.getAuthToken(ctx.config);
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (auth)
|
|
||||||
headers['Authorization'] = auth;
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
body: data,
|
|
||||||
method: "POST",
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
if (!response.ok) console.error(response.statusText);
|
|
||||||
}));
|
|
||||||
|
|
||||||
events.emit('notification', { message: "Saves Uploaded", icon: 'upload', type: "success" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } });
|
const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } });
|
||||||
if (resp.error) console.error(resp.error);
|
if (resp.error) console.error(resp.error);
|
||||||
events.emit('notification', { message: "Updated Played", type: "success", icon: "clock" });
|
return resp.response.ok;
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) =>
|
ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
const rommCollections = await getCollectionsApiCollectionsGet();
|
const rommCollections = await getCollectionsApiCollectionsGet();
|
||||||
if (rommCollections.response.ok && rommCollections.data)
|
if (rommCollections.response.ok && rommCollections.data)
|
||||||
{
|
{
|
||||||
|
|
@ -588,7 +382,6 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
|
|
||||||
ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) =>
|
ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
if (source !== 'romm') return;
|
if (source !== 'romm') return;
|
||||||
const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } });
|
const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } });
|
||||||
if (collection.data)
|
if (collection.data)
|
||||||
|
|
@ -605,35 +398,11 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) =>
|
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id }) =>
|
||||||
{
|
{
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
let platform: PlatformSchema | undefined = undefined;
|
|
||||||
|
|
||||||
if (id && source)
|
|
||||||
{
|
|
||||||
if (source !== 'romm') return;
|
|
||||||
const platforms = await this.getAllRommPlatforms();
|
|
||||||
platform = platforms.find(p => p.id === Number(id));
|
|
||||||
|
|
||||||
} else if (slug)
|
|
||||||
{
|
|
||||||
const platforms = await this.getAllRommPlatforms();
|
|
||||||
platform = platforms.find(p => p.slug === slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!platform) return;
|
|
||||||
return { slug: platform?.slug, url_logo: platform.url_logo, name: platform.display_name, family_name: platform.family_name ?? undefined };
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) =>
|
|
||||||
{
|
|
||||||
if (!await this.checkRemote()) return;
|
|
||||||
if (source !== 'romm') return;
|
if (source !== 'romm') return;
|
||||||
const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } });
|
const platforms = await this.getAllRommPlatforms();
|
||||||
if (roms.error) throw roms.error;
|
return platforms.find(p => p.id === Number(id));
|
||||||
if (!roms.data) return;
|
|
||||||
return this.convertRomToFrontendDetailed(roms.data);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue