Compare commits
76 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5593985884 | |||
| 9141fb35d4 | |||
| 9a3e605625 | |||
| 2e78ddf08e | |||
| 38cb752552 | |||
| 9051834ace | |||
| f82bf1215a | |||
| 11c4a802e4 | |||
| c9cf0b827c | |||
| 4da717c26d | |||
| 7029477392 | |||
| 04e332d91e | |||
| 2683d46b16 | |||
| 06b7e4074d | |||
| e54a6ac8f0 | |||
| 79b627ed31 | |||
| c23521bf94 | |||
| 1653e49465 | |||
| ae196e11d6 | |||
| cf84f40a17 | |||
| 813785f4f3 | |||
| 587956c792 | |||
| 701f882136 | |||
| 7bd0ebdcca | |||
| 6aacec2c0d | |||
| 7065e64722 | |||
| c09fbd3dc8 | |||
| 444d8c4c27 | |||
| 4806f3487a | |||
| 7948bd24fa | |||
| 54dd9256e3 | |||
| 02a4f2c9a9 | |||
| 05fafced07 | |||
| 764691fc86 | |||
| 09b8b9c6f8 | |||
| 04d5856f7d | |||
| 34db717ec5 | |||
| a69147a4f7 | |||
| edbc390d14 | |||
| fe0ab3b498 | |||
| d24dc89515 | |||
| bb8f716201 | |||
| 4271f268c3 | |||
| 8a0be8c913 | |||
| 58d3c31c56 | |||
| b4e9112989 | |||
| ccc5a05ed7 | |||
| dc0f2d150a | |||
| 90d6711935 | |||
| a7eb655a48 | |||
| 816d50ae4d | |||
| 7c10f4e4c2 | |||
| a78e75335f | |||
| d85268fad7 | |||
| 91ee719633 | |||
| 3750e9ed8f | |||
| cf6fff6fac | |||
| 364bc9d0be | |||
| acadfe04ad | |||
| c86e8cd197 | |||
| 8125c8695c | |||
| df20979afa | |||
| f33c928633 | |||
| fe80b074d2 | |||
| 258ce63bc3 | |||
| 489124a4a3 | |||
| 90f9221a80 | |||
| 7286541822 | |||
| 2f32cbc730 | |||
| 4739b89933 | |||
| 01b91aa48c | |||
| 592d0cb21f | |||
| 52a0d5d825 | |||
| 6ee6957858 | |||
| 9e03fc9aea | |||
| 67e88d9c29 |
415 changed files with 30464 additions and 4293 deletions
2
.config/appimage/AppRun
Normal file
2
.config/appimage/AppRun
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/bash
|
||||||
|
exec "$APPDIR/usr/bin/{{BINARY_NAME}}" "$@"
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>{{APP_ID}}</id>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>{{LICENSE}}</project_license>
|
||||||
|
<name>{{APP_NAME}}</name>
|
||||||
|
<summary>Retro gaming frontend designed for handheld and controllers</summary>
|
||||||
|
<developer id="com.simeonradivoev">
|
||||||
|
<name>Simeon Radivoev</name>
|
||||||
|
</developer>
|
||||||
|
<description>
|
||||||
|
<p>A Cross-Platform Retro gaming frontend designed for handheld and controllers. Focused on building a simple user experience and intuitive UI.</p>
|
||||||
|
</description>
|
||||||
|
<categories>
|
||||||
|
<category>Game</category>
|
||||||
|
</categories>
|
||||||
|
<recommends>
|
||||||
|
<internet>always</internet>
|
||||||
|
</recommends>
|
||||||
|
<launchable type="desktop-id">{{APP_ID}}.desktop</launchable>
|
||||||
|
<url type="homepage">https://github.com/simeonradivoev/gameflow-deck</url>
|
||||||
|
<url type="bugtracker">https://github.com/simeonradivoev/gameflow-deck/issues</url>
|
||||||
|
<url type="donation">https://github.com/sponsors/simeonradivoev</url>
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/yObFD2LySH.jpg</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Game Details</caption>
|
||||||
|
<image>https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/3nhuKCK6E3.jpg</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>The Settings Panel</caption>
|
||||||
|
<image>https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/GL7SkQbHIY.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Emulator Details</caption>
|
||||||
|
<image>https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/xNj7scPEDQ.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Gameflow Store</caption>
|
||||||
|
<image>https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/CpBLzTNM6N.png</image>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
<releases>
|
||||||
|
{{{RELEASES}}}
|
||||||
|
</releases>
|
||||||
|
<content_rating type="oars-1.0" />
|
||||||
|
<provides>
|
||||||
|
<id>{{APP_ID}}.desktop</id>
|
||||||
|
<binary>gameflow</binary>
|
||||||
|
</provides>
|
||||||
|
</component>
|
||||||
10
.config/appimage/com.simeonradivoev.gameflow-deck.desktop
Normal file
10
.config/appimage/com.simeonradivoev.gameflow-deck.desktop
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[Desktop Entry]
|
||||||
|
X-AppImage-Name={{APP_NAME}}
|
||||||
|
X-AppImage-Version={{VERSION}}
|
||||||
|
X-AppImage-Arch={{ARCH}}
|
||||||
|
Name={{APP_NAME}}
|
||||||
|
Comment={{DESCRIPTION}}
|
||||||
|
Exec=gameflow
|
||||||
|
Icon=gameflow
|
||||||
|
Type=Application
|
||||||
|
Categories=Game;
|
||||||
|
|
@ -1,23 +1,36 @@
|
||||||
{
|
{
|
||||||
"app-id": "com.simeonradivoev.gameflow-deck",
|
"app-id": "com.simeonradivoev.gameflow-deck",
|
||||||
"runtime": "org.kde.Platform",
|
"runtime": "org.freedesktop.Platform",
|
||||||
"runtime-version": "6.10",
|
"runtime-version": "25.08",
|
||||||
"sdk": "org.kde.Sdk",
|
"sdk": "org.freedesktop.Sdk",
|
||||||
"command": "/app/bin/gameflow",
|
"command": "/app/bin/gameflow",
|
||||||
"base": "io.qt.qtwebengine.BaseApp",
|
|
||||||
"base-version": "6.10",
|
|
||||||
"finish-args": [
|
"finish-args": [
|
||||||
"--share=ipc",
|
"--share=ipc",
|
||||||
"--share=network",
|
"--share=network",
|
||||||
"--socket=pulseaudio",
|
"--socket=pulseaudio",
|
||||||
"--socket=wayland",
|
"--socket=wayland",
|
||||||
|
"--socket=inherit-wayland-socket",
|
||||||
"--socket=x11",
|
"--socket=x11",
|
||||||
|
"--socket=fallback-x11",
|
||||||
|
"--socket=session-bus",
|
||||||
|
"--socket=system-bus",
|
||||||
"--device=all",
|
"--device=all",
|
||||||
"--filesystem=host",
|
"--filesystem=host",
|
||||||
"--filesystem=home",
|
"--filesystem=home",
|
||||||
|
"--filesystem=~/.steam/steam:rw",
|
||||||
|
"--filesystem=~/.steam:rw",
|
||||||
|
"--filesystem=~/.var/app/com.valvesoftware.Steam:rw",
|
||||||
|
"--filesystem=/run/udev:ro",
|
||||||
|
"--filesystem=/run/media",
|
||||||
|
"--filesystem=xdg-documents",
|
||||||
|
"--filesystem=xdg-desktop",
|
||||||
|
"--filesystem=xdg-run/gamescope-0:rw",
|
||||||
"--env=PKG_CONFIG_LIBDIR=/app/lib",
|
"--env=PKG_CONFIG_LIBDIR=/app/lib",
|
||||||
"--env=FLATPAK_BUILD=true",
|
"--env=FLATPAK_BUILD=true",
|
||||||
"--allow=devel"
|
"--allow=devel",
|
||||||
|
"--talk-name=org.freedesktop.portal.OpenURI",
|
||||||
|
"--talk-name=org.freedesktop.Flatpak",
|
||||||
|
"--talk-name=org.a11y.Bus"
|
||||||
],
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
|
|
@ -29,7 +42,6 @@
|
||||||
"mkdir -p /app/lib",
|
"mkdir -p /app/lib",
|
||||||
"install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png",
|
"install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png",
|
||||||
"install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop",
|
"install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop",
|
||||||
"mv libvips-cpp.so.* /app/lib",
|
|
||||||
"mv * /app/share/gameflow/",
|
"mv * /app/share/gameflow/",
|
||||||
"mv /app/share/gameflow/gameflow /app/bin",
|
"mv /app/share/gameflow/gameflow /app/bin",
|
||||||
"mv /app/share/gameflow/bun /app/bin",
|
"mv /app/share/gameflow/bun /app/bin",
|
||||||
|
|
@ -39,15 +51,15 @@
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"type": "dir",
|
"type": "dir",
|
||||||
"path": "../build/linux"
|
"path": "../../build/linux"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "../flatpak/com.simeonradivoev.gameflow-deck.desktop"
|
"path": "com.simeonradivoev.gameflow-deck.desktop"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "../src/mainview/assets/256x256.png"
|
"path": "../../src/mainview/public/256x256.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "script",
|
"type": "script",
|
||||||
|
|
@ -72,23 +84,22 @@
|
||||||
"only-arches": [
|
"only-arches": [
|
||||||
"aarch64"
|
"aarch64"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"path": "../node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3",
|
|
||||||
"only-arches": [
|
|
||||||
"x86_64"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "webview",
|
"name": "NW.js",
|
||||||
"buildsystem": "cmake-ninja",
|
"buildsystem": "simple",
|
||||||
|
"build-commands": [
|
||||||
|
"mkdir -p /app/bin/nw",
|
||||||
|
"mv * /app/bin/nw",
|
||||||
|
"chmod +x /app/bin/nw/nw"
|
||||||
|
],
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"type": "dir",
|
"type": "archive",
|
||||||
"path": "../flatpak/webview"
|
"url": "https://dl.nwjs.io/v0.110.1/nwjs-v0.110.1-linux-x64.tar.gz",
|
||||||
|
"sha256": "d9a9ed2255e9ee87c9dd1860d9c7a479cea5279dcd80d3e80e23b083d325554a"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
cmake_minimum_required(VERSION 3.16)
|
|
||||||
|
|
||||||
project(SimpleWebView LANGUAGES CXX)
|
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
||||||
|
|
||||||
# Required for Qt WebEngine
|
|
||||||
set(CMAKE_AUTOMOC ON)
|
|
||||||
set(CMAKE_AUTORCC ON)
|
|
||||||
set(CMAKE_AUTOUIC ON)
|
|
||||||
|
|
||||||
find_package(Qt6 REQUIRED COMPONENTS
|
|
||||||
Core
|
|
||||||
Widgets
|
|
||||||
WebEngineWidgets,
|
|
||||||
Gamepad
|
|
||||||
)
|
|
||||||
|
|
||||||
add_executable(webview
|
|
||||||
main.cpp
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_libraries(webview PRIVATE
|
|
||||||
Qt6::Core
|
|
||||||
Qt6::Widgets
|
|
||||||
Qt6::WebEngineWidgets,
|
|
||||||
Qt6::Gamepad
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Install binary into Flatpak prefix (/app)
|
|
||||||
install(TARGETS webview
|
|
||||||
RUNTIME DESTINATION bin
|
|
||||||
)
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
#include <QApplication>
|
|
||||||
#include <QWebEngineView>
|
|
||||||
#include <QWebEngineSettings>
|
|
||||||
#include <QUrl>
|
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
|
||||||
{
|
|
||||||
QApplication app(argc, argv);
|
|
||||||
if (argc < 2) { return 1; }
|
|
||||||
QWebEngineView view;
|
|
||||||
view.setUrl(QUrl(argv[1]));
|
|
||||||
view.showFullScreen();
|
|
||||||
return app.exec();
|
|
||||||
}
|
|
||||||
|
|
@ -4,6 +4,8 @@ on:
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- "v*.*.*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
env:
|
||||||
|
TWITCH_CLIENT_ID: ${{ env.TWITCH_CLIENT_ID }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -54,6 +56,8 @@ jobs:
|
||||||
|
|
||||||
- name: Build Canary
|
- name: Build Canary
|
||||||
run: bun run package:Linux
|
run: bun run package:Linux
|
||||||
|
env:
|
||||||
|
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
|
|
|
||||||
3
.gitattributes
vendored
3
.gitattributes
vendored
|
|
@ -4,3 +4,6 @@
|
||||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||||
*.webp filter=lfs diff=lfs merge=lfs -text
|
*.webp filter=lfs diff=lfs merge=lfs -text
|
||||||
*.svg filter=lfs diff=lfs merge=lfs -text
|
*.svg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.wav filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ogg filter=lfs diff=lfs merge=lfs -text
|
||||||
BIN
.github/screenshots/3d screenshot.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/3d screenshot.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/3nhuKCK6E3.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/3nhuKCK6E3.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/4MtAe7Wkev.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/4MtAe7Wkev.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/6wz3gW8c2h.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/6wz3gW8c2h.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/7s0842oAC9.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/7s0842oAC9.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/8jipsHiLST.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/8jipsHiLST.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/CpBLzTNM6N.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/CpBLzTNM6N.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/EWPHmIBEE5.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/EWPHmIBEE5.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/FHMzJjGOs6.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/FHMzJjGOs6.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/GL7SkQbHIY.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/GL7SkQbHIY.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/J5BHVZBh7k.png
(Stored with Git LFS)
vendored
BIN
.github/screenshots/J5BHVZBh7k.png
(Stored with Git LFS)
vendored
Binary file not shown.
BIN
.github/screenshots/MMeJxl4IXr.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/MMeJxl4IXr.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/Pkazk0RufB.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/Pkazk0RufB.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/mockup-1777308293568.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/mockup-1777308293568.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/rBY2mgTLy0.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/rBY2mgTLy0.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/xNj7scPEDQ.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/xNj7scPEDQ.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/yObFD2LySH.jpg
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/yObFD2LySH.jpg
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/zEQxtzhPGx.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/zEQxtzhPGx.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
BIN
.github/screenshots/zl8Dj4xnEw.png
(Stored with Git LFS)
vendored
Normal file
BIN
.github/screenshots/zl8Dj4xnEw.png
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
|
|
@ -59,6 +59,7 @@ jobs:
|
||||||
run: bun run package:${{ runner.os }}
|
run: bun run package:${{ runner.os }}
|
||||||
env:
|
env:
|
||||||
BUILD_DIR: ./build/${{ runner.os }}
|
BUILD_DIR: ./build/${{ runner.os }}
|
||||||
|
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
|
||||||
|
|
||||||
- name: Install 7zip (minimal)
|
- name: Install 7zip (minimal)
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
|
@ -74,6 +75,17 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
|
||||||
|
- name: Archive Windows Release
|
||||||
|
uses: thedoctor0/zip-release@0.7.5
|
||||||
|
with:
|
||||||
|
type: "zip"
|
||||||
|
directory: ${{ github.workspace }}
|
||||||
|
filename: "Gameflow-win32-x64.zip"
|
||||||
|
path: "canary-build-Windows"
|
||||||
|
|
||||||
- name: Publish Release
|
- name: Publish Release
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
with:
|
with:
|
||||||
|
|
@ -82,4 +94,6 @@ jobs:
|
||||||
tag: ${{ needs.create-draft.outputs.current-version }}
|
tag: ${{ needs.create-draft.outputs.current-version }}
|
||||||
omitNameDuringUpdate: true
|
omitNameDuringUpdate: true
|
||||||
omitBodyDuringUpdate: true
|
omitBodyDuringUpdate: true
|
||||||
|
replacesArtifacts: true
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-*.zip"
|
||||||
|
|
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -25,4 +25,11 @@ artifacts
|
||||||
trace
|
trace
|
||||||
downloads
|
downloads
|
||||||
.flatpak-builder
|
.flatpak-builder
|
||||||
gameflow-deck.code-workspace
|
gameflow-deck.code-workspace
|
||||||
|
.env.local
|
||||||
|
src/tests/mock-roms/db.sqlite
|
||||||
|
src/tests/mock-roms/store
|
||||||
|
src/tests/mock-config
|
||||||
|
bin
|
||||||
|
.config/flatpak/repo
|
||||||
|
xenia.log
|
||||||
18
.versionrc
Normal file
18
.versionrc
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"packageFiles": [
|
||||||
|
{
|
||||||
|
"filename": "package.json",
|
||||||
|
"type": "json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bumpFiles": [
|
||||||
|
{
|
||||||
|
"filename": "package.json",
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "src/packages/gameflow-sdk/package.json",
|
||||||
|
"type": "json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -9,7 +9,7 @@
|
||||||
"internalConsoleOptions": "neverOpen",
|
"internalConsoleOptions": "neverOpen",
|
||||||
"request": "attach",
|
"request": "attach",
|
||||||
"name": "Attach Bun",
|
"name": "Attach Bun",
|
||||||
"url": "ws://127.0.0.1:9229/fixed-session",
|
"url": "ws://127.0.0.1:9228/fixed-session",
|
||||||
"localRoot": "${workspaceFolder}",
|
"localRoot": "${workspaceFolder}",
|
||||||
"stopOnEntry": false,
|
"stopOnEntry": false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
|
|
@ -6,17 +6,22 @@
|
||||||
"files.watcherExclude": {
|
"files.watcherExclude": {
|
||||||
"**/*.gen.*": true,
|
"**/*.gen.*": true,
|
||||||
"src/mainview/gen/*": true,
|
"src/mainview/gen/*": true,
|
||||||
|
"**/build": true,
|
||||||
|
"**/.config/flatpack/repo/**": true,
|
||||||
|
"**/.flatpak-builder/**": true,
|
||||||
},
|
},
|
||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"**/*.gen.*": true,
|
"**/*.gen.*": true,
|
||||||
".flatpak-builder/**/*": true,
|
"**/.flatpak-builder": true,
|
||||||
|
"**/.config/flatpack/repo/**": true,
|
||||||
|
"**/build": true,
|
||||||
"src/mainview/gen/*": true,
|
"src/mainview/gen/*": true,
|
||||||
},
|
},
|
||||||
"npm.scriptRunner": "bun",
|
"npm.scriptRunner": "bun",
|
||||||
"npm.exclude": [
|
"npm.exclude": [
|
||||||
"**/.flatpak-builder/**/*",
|
"**/.flatpak-builder/**/*",
|
||||||
"**/build/flatpack/**",
|
"**/build/flatpack/**",
|
||||||
"**/flatpack/repo/**",
|
"**/.config/flatpack/repo/**",
|
||||||
],
|
],
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
|
|
@ -30,9 +35,11 @@
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"elysia",
|
"elysia",
|
||||||
"elysiajs",
|
"elysiajs",
|
||||||
|
"emulatorjs",
|
||||||
"gameflow",
|
"gameflow",
|
||||||
"hackolade",
|
"hackolade",
|
||||||
"keytar",
|
"keytar",
|
||||||
|
"mainview",
|
||||||
"norigin",
|
"norigin",
|
||||||
"noriginmedia",
|
"noriginmedia",
|
||||||
"romm"
|
"romm"
|
||||||
|
|
|
||||||
89
CHANGELOG.md
89
CHANGELOG.md
|
|
@ -1,6 +1,93 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
||||||
|
|
||||||
|
## [1.6.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.5.0...v1.6.0) (2026-05-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Implemented public plugin system accessible from the store. ([38cb752](https://github.com/simeonradivoev/gameflow-deck/commit/38cb7525527b5ad4f6eb284cdad0001fd87eaf7e))
|
||||||
|
|
||||||
|
## [1.5.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.4.0...v1.5.0) (2026-05-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Implemented local game import (with a wizard) ([06b7e40](https://github.com/simeonradivoev/gameflow-deck/commit/06b7e4074da23afdec3b2ff97f84a9e1486944d2))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Navigation blocking now working with focuesed input fields ([4da717c](https://github.com/simeonradivoev/gameflow-deck/commit/4da717c26d9840febd48ee87a6a493a3e1acc6b9))
|
||||||
|
|
||||||
|
## [1.4.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.3.0...v1.4.0) (2026-04-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Added more ways to detect duplicates ([05fafce](https://github.com/simeonradivoev/gameflow-deck/commit/05fafced07c853deb656d7c17d05184c42ee507c))
|
||||||
|
* added update notes and moved update to own tab ([cf84f40](https://github.com/simeonradivoev/gameflow-deck/commit/cf84f40a174b8f242ca58fb6fe02eefab46ff442))
|
||||||
|
* Added way to update the local games from romm when IDs change based on IGDB or Retro Achievement ID ([4806f34](https://github.com/simeonradivoev/gameflow-deck/commit/4806f3487a577ab8e7c66907e5b640d95ab8a46c)), closes [#2](https://github.com/simeonradivoev/gameflow-deck/issues/2)
|
||||||
|
* Bundled NW.js with appimages ([813785f](https://github.com/simeonradivoev/gameflow-deck/commit/813785f4f3d292a87cc4a6b86dc152c43572d2c8))
|
||||||
|
* Implemented audio effects ([edbc390](https://github.com/simeonradivoev/gameflow-deck/commit/edbc390d144bf44da35d0f5383ec36eb25c34d1b))
|
||||||
|
* Implemented dolphin integration ([a69147a](https://github.com/simeonradivoev/gameflow-deck/commit/a69147a4f73cf626b92622a8ee22b54f538d41a9))
|
||||||
|
* Implemented emulator launching ([09b8b9c](https://github.com/simeonradivoev/gameflow-deck/commit/09b8b9c6f850cea3b897308925faf9be02cefa1a)), closes [#1](https://github.com/simeonradivoev/gameflow-deck/issues/1)
|
||||||
|
* Implemented emulator versions and updating ([34db717](https://github.com/simeonradivoev/gameflow-deck/commit/34db717ec5cbcf8b1ae54fbda33bf9a78f01bd17))
|
||||||
|
* Implemented filtering and searching ([444d8c4](https://github.com/simeonradivoev/gameflow-deck/commit/444d8c4c278c6032b37f44a884cb6d7bf0b54c85))
|
||||||
|
* implemented haptics ([54dd925](https://github.com/simeonradivoev/gameflow-deck/commit/54dd9256e361877d0950a84061d9402616706352))
|
||||||
|
* Implemented romm saves for dolphin and xenia ([7948bd2](https://github.com/simeonradivoev/gameflow-deck/commit/7948bd24fabfc01b7be358f06fcd58c8795826c7))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Fixed a bunch of issues on linux ([6aacec2](https://github.com/simeonradivoev/gameflow-deck/commit/6aacec2c0de253a71599e261e07aff53055cdb1e))
|
||||||
|
* Fixed emulator details buttons not showing ([04d5856](https://github.com/simeonradivoev/gameflow-deck/commit/04d5856f7d71c944c82877d2a1457facea4b6d31))
|
||||||
|
* Fixed tests ([c09fbd3](https://github.com/simeonradivoev/gameflow-deck/commit/c09fbd3dc88891227eda2b9f3bd9ac45621c00ea))
|
||||||
|
* logins now refresh on plugins load ([7bd0ebd](https://github.com/simeonradivoev/gameflow-deck/commit/7bd0ebdcca1843076911547ec1098cbaae9e2414))
|
||||||
|
* Made self update work on windows ([ae196e1](https://github.com/simeonradivoev/gameflow-deck/commit/ae196e11d616b9813dba11f64e7c844077686db8))
|
||||||
|
* Made store downloads extract in their own folder ([764691f](https://github.com/simeonradivoev/gameflow-deck/commit/764691fc8610fafebc93a69ca24f74bcac42a898))
|
||||||
|
|
||||||
|
## [1.3.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.2.1...v1.3.0) (2026-03-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Implemented emulator installation ([3750e9e](https://github.com/simeonradivoev/gameflow-deck/commit/3750e9ed8fc1c0919aade9e45a0189838f12b16d))
|
||||||
|
* moved to npm package for the store ([91ee719](https://github.com/simeonradivoev/gameflow-deck/commit/91ee7196332313518324cf7195f64d0e92b2cc8b))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Added keyboard focus shortcut ([b4e9112](https://github.com/simeonradivoev/gameflow-deck/commit/b4e911298935483bec7e315d2eebee47562bd448))
|
||||||
|
* ditched sdl and moved to xinput for windows for less ram usage ([dc0f2d1](https://github.com/simeonradivoev/gameflow-deck/commit/dc0f2d150a37bebefa76988f98d8766f530f44b4))
|
||||||
|
* Fixed browser referencing main and getting called twice when in dev mode ([7c10f4e](https://github.com/simeonradivoev/gameflow-deck/commit/7c10f4e4c2b4996e784be051132233a854270250))
|
||||||
|
* Fixed romm login, now uses token ([816d50a](https://github.com/simeonradivoev/gameflow-deck/commit/816d50ae4d61723e67a0980ca310561ead661a68))
|
||||||
|
* Issues with launching and installation on the steam deck ([ccc5a05](https://github.com/simeonradivoev/gameflow-deck/commit/ccc5a05ed7010adea77eea9190f3149b67702b39))
|
||||||
|
* Manual checking for system info to fix bug in library ([a7eb655](https://github.com/simeonradivoev/gameflow-deck/commit/a7eb655a48c6976baa18bb4cde96c989ce8cd375))
|
||||||
|
* missing gitlab as download type ([bb8f716](https://github.com/simeonradivoev/gameflow-deck/commit/bb8f7162018f7a320be76128d09da82ccac1a896))
|
||||||
|
* switched to node-7z ([90d6711](https://github.com/simeonradivoev/gameflow-deck/commit/90d67119355baa64bd992c9d4e9d11036706bbc9))
|
||||||
|
|
||||||
|
### [1.2.1](https://github.com/simeonradivoev/gameflow-deck/compare/v1.2.0...v1.2.1) (2026-03-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Added control for opening emulator js menu on steam deck controller ([f33c928](https://github.com/simeonradivoev/gameflow-deck/commit/f33c928633a06d1f99e1125a984059b9ade3a369))
|
||||||
|
* Browser not getting closed on manual exit ([489124a](https://github.com/simeonradivoev/gameflow-deck/commit/489124a4a332a7606fb4b8b82f76929c7909a192))
|
||||||
|
* Emulators not launching ([fe80b07](https://github.com/simeonradivoev/gameflow-deck/commit/fe80b074d2e5c6c0b9bd9a667f3378455fb5d97a))
|
||||||
|
* Fixed cross platform errors and emulatorjs not opening on linux ([df20979](https://github.com/simeonradivoev/gameflow-deck/commit/df20979afa00bd578922a6a516b28845a4b5cab3))
|
||||||
|
* minor UI issues ([8125c86](https://github.com/simeonradivoev/gameflow-deck/commit/8125c8695cc84358afdfb2657cc6a3638ae68d69))
|
||||||
|
* Wrong webview library path for appimage building ([258ce63](https://github.com/simeonradivoev/gameflow-deck/commit/258ce63bc3cb24c6fb273fd98a1323ae7fde439d))
|
||||||
|
|
||||||
|
## [1.2.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.1.0...v1.2.0) (2026-03-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Added interface options ([2f32cbc](https://github.com/simeonradivoev/gameflow-deck/commit/2f32cbc730053c6959e026aca1a030159f50e48b))
|
||||||
|
* Added QR login ([4739b89](https://github.com/simeonradivoev/gameflow-deck/commit/4739b89933f9dd6082d40f84f8fedd19a013ee98))
|
||||||
|
* implemented a basic store and emulatorjs ([7286541](https://github.com/simeonradivoev/gameflow-deck/commit/7286541822251e001f2a49c1afbb03520c8d9c4b))
|
||||||
|
|
||||||
## 1.1.0 (2026-03-01)
|
## 1.1.0 (2026-03-01)
|
||||||
|
|
||||||
|
|
|
||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
|
|
@ -0,0 +1,661 @@
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
101
README.md
101
README.md
|
|
@ -1,41 +1,81 @@
|
||||||
# Gameflow Deck
|
# Gameflow Deck
|
||||||
|
|
||||||
A Cross-Platform Retro gaming frontend designed for handheld and controllers.
|
A Cross-Platform open source Retro gaming frontend designed for handheld and controllers.
|
||||||
Focused on building a simple user experience and intuitive UI.
|
Focused on building a simple user experience and intuitive UI as a curated community driven experience.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> This app is actively in development, it doesn't have most of its critical features implemented yet.
|
> This app is actively in development, it is constantly changing and improving.
|
||||||
> It will have an opinionated design and will be used as an experiment in discovering a good UX.
|
> 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
|
||||||
|
|
||||||
- **Cross Platform**: Can run on multiple platforms. Built with web technologies and bun backend.
|
### Integrations
|
||||||
- **[Romm](https://github.com/rommapp/romm) Support**: Has integration with romm.
|
|
||||||
- **Lightweight**: It uses the existing system browser to launch the front end, so no need to include a whole web browser.
|
- **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms.
|
||||||
|
- Show Achievements and sync playtime.
|
||||||
|
- Experimental save syncing
|
||||||
|
- **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores.
|
||||||
|
- **[RClone](https://github.com/rclone/rclone)** - sync saves between devices or cloud. Some Emulators and store games support it.
|
||||||
|
- **[UMU](https://github.com/Open-Wine-Components/umu-launcher)** - UMU Launcher for playing windows games on linux without needing steam. (Only used for store games for now)
|
||||||
|
|
||||||
|
### Store
|
||||||
|
|
||||||
|
- **Emulators** - (WIP) Download and install emulators and automatically configure them from a list of supported in the store. Some even come with advanced features like cloud saves.
|
||||||
|
- **Free Curated Games** - Download free curated games and homebrew roms without ever leaving the app
|
||||||
|
|
||||||
|
### Others
|
||||||
|
|
||||||
|
- **Cross Platform** - Can run on multiple platforms. Built with web technologies and bun backend.
|
||||||
|
- **Steam Deck Support** - Extensively tested with the steam deck. It can use flatpak installed browsers.
|
||||||
|
- **Lightweight** - It uses the window's webview as a frontend, reducing build size and ram usage.
|
||||||
- On Windows it first uses webview2 then your browser
|
- On Windows it first uses webview2 then your browser
|
||||||
- On linux it uses WebKitGTK or a browser even from flatpak
|
- On linux it does ship with NW.js to work on most distros. A big one is the steam deck missing WebKitGTK.
|
||||||
- Not tested on Mac yet
|
- Not tested on Mac yet
|
||||||
- **Steam Deck Support**: Extensively tested with the steam deck. It can use flatpak installed browsers.
|
- **Great for Controllers** - The UI is inspired by the switch and works great with joysticks and dpads.
|
||||||
- Automatic Keyboard prompts
|
- **Automatic Downloads** - Downloads roms from ROMM automatically
|
||||||
- **Great for Controllers**: The UI is inspired by the switch and works great with joysticks and dpads.
|
- **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 Download** Downloads roms from ROMM automatically
|
|
||||||
- **Automatic Emulator Discovery** Using the configs of the excellent ES-DE to discover installed emulators and launch games.
|
|
||||||
- Easy fallback configuration with built in file browser.
|
- 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/7s0842oAC9.png" width="25%"></img>
|
<img src=".github/screenshots/3d screenshot.png" title="Home Screen Showing games sorted by latest activity" width="25%"></img>
|
||||||
<img src=".github/screenshots/FHMzJjGOs6.png" width="25%"></img>
|
<img src=".github/screenshots/3nhuKCK6E3.png" title="Game Details." width="25%"></img>
|
||||||
<img src=".github/screenshots/EWPHmIBEE5.png" width="25%"></img>
|
<img src=".github/screenshots/yObFD2LySH.jpg" title="Home Screen in dark mode" width="25%"></img>
|
||||||
<img src=".github/screenshots/J5BHVZBh7k.png" width="25%"></img>
|
<img src=".github/screenshots/GL7SkQbHIY.png" title="Plugins Page" width="25%"></img>
|
||||||
<img src=".github/screenshots/8jipsHiLST.png" width="25%"></img>
|
<img src=".github/screenshots/CpBLzTNM6N.png" title="Store Home Page" width="25%"></img>
|
||||||
|
<img src=".github/screenshots/xNj7scPEDQ.png" title="Store emulator details" width="25%"></img>
|
||||||
|
<img src=".github/screenshots/zEQxtzhPGx.png" title="Store Emulators in dark mode" width="25%"></img>
|
||||||
|
<img src=".github/screenshots/MMeJxl4IXr.png" title="Store Emulators in light mode" width="25%"></img>
|
||||||
|
<img src=".github/screenshots/EWPHmIBEE5.png" title="Platform Grouping List" width="25%"></img>
|
||||||
|
<img src=".github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif" title="Platform Grouping List" width="76%"></img>
|
||||||
|
|
||||||
## Goals
|
## 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.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
@ -66,6 +106,17 @@ I really want to add matrix chat support in the app for engaging with your favor
|
||||||
- `bun run openapi-ts` generated the openapi client calls from romm's API
|
- `bun run 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
|
||||||
|
|
||||||
|
|
@ -77,3 +128,11 @@ I really want to add matrix chat support in the app for engaging with your favor
|
||||||
- [Tanstack](https://tanstack.com/) router and query for navigation and data
|
- [Tanstack](https://tanstack.com/) router and query for navigation and data
|
||||||
- [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
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
|
||||||
27
drizzle/0001_outstanding_silk_fever.sql
Normal file
27
drizzle/0001_outstanding_silk_fever.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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,
|
||||||
|
`last_played` integer,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`metadata` text DEFAULT '{}',
|
||||||
|
`slug` text,
|
||||||
|
`platform_id` integer NOT NULL,
|
||||||
|
`cover` blob,
|
||||||
|
`type` text,
|
||||||
|
`summary` 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", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary" 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`);
|
||||||
31
drizzle/0002_flowery_rocket_raccoon.sql
Normal file
31
drizzle/0002_flowery_rocket_raccoon.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_games` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`source_id` text,
|
||||||
|
`source` text,
|
||||||
|
`igdb_id` integer,
|
||||||
|
`name` text,
|
||||||
|
`ra_id` integer,
|
||||||
|
`path_fs` text,
|
||||||
|
`main_glob` text,
|
||||||
|
`last_played` integer,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`metadata` text DEFAULT '{}' NOT NULL,
|
||||||
|
`slug` text,
|
||||||
|
`platform_id` integer NOT NULL,
|
||||||
|
`cover` blob,
|
||||||
|
`type` text,
|
||||||
|
`summary` text,
|
||||||
|
`version` text,
|
||||||
|
`version_source` text,
|
||||||
|
`version_system` text,
|
||||||
|
FOREIGN KEY (`platform_id`) REFERENCES `platforms`(`id`) ON UPDATE cascade ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", NULL, "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", NULL, NULL, NULL FROM `games`;--> statement-breakpoint
|
||||||
|
DROP TABLE `games`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_games` RENAME TO `games`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `games_igdb_id_unique` ON `games` (`igdb_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `games_ra_id_unique` ON `games` (`ra_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `games_slug_unique` ON `games` (`slug`);
|
||||||
451
drizzle/meta/0001_snapshot.json
Normal file
451
drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "6dc00b41-64d7-4cb3-ad90-f9e8e35af643",
|
||||||
|
"prevId": "673fe5dc-58a5-495b-8fb1-104e7945e90b",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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": false,
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
479
drizzle/meta/0002_snapshot.json
Normal file
479
drizzle/meta/0002_snapshot.json
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "40569ae5-facd-4680-bd48-fe70c5abf498",
|
||||||
|
"prevId": "6dc00b41-64d7-4cb3-ad90-f9e8e35af643",
|
||||||
|
"tables": {
|
||||||
|
"collections": {
|
||||||
|
"name": "collections",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"collections_games": {
|
||||||
|
"name": "collections_games",
|
||||||
|
"columns": {
|
||||||
|
"collection_id": {
|
||||||
|
"name": "collection_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"game_id": {
|
||||||
|
"name": "game_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"collections_games_collection_id_collections_id_fk": {
|
||||||
|
"name": "collections_games_collection_id_collections_id_fk",
|
||||||
|
"tableFrom": "collections_games",
|
||||||
|
"tableTo": "collections",
|
||||||
|
"columnsFrom": [
|
||||||
|
"collection_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
},
|
||||||
|
"collections_games_game_id_games_id_fk": {
|
||||||
|
"name": "collections_games_game_id_games_id_fk",
|
||||||
|
"tableFrom": "collections_games",
|
||||||
|
"tableTo": "games",
|
||||||
|
"columnsFrom": [
|
||||||
|
"game_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"games": {
|
||||||
|
"name": "games",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"source_id": {
|
||||||
|
"name": "source_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"igdb_id": {
|
||||||
|
"name": "igdb_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ra_id": {
|
||||||
|
"name": "ra_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"path_fs": {
|
||||||
|
"name": "path_fs",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"main_glob": {
|
||||||
|
"name": "main_glob",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_played": {
|
||||||
|
"name": "last_played",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'{}'"
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"platform_id": {
|
||||||
|
"name": "platform_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"cover": {
|
||||||
|
"name": "cover",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"name": "summary",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"name": "version",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"version_source": {
|
||||||
|
"name": "version_source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"version_system": {
|
||||||
|
"name": "version_system",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"games_igdb_id_unique": {
|
||||||
|
"name": "games_igdb_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"igdb_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"games_ra_id_unique": {
|
||||||
|
"name": "games_ra_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"ra_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"games_slug_unique": {
|
||||||
|
"name": "games_slug_unique",
|
||||||
|
"columns": [
|
||||||
|
"slug"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"games_platform_id_platforms_id_fk": {
|
||||||
|
"name": "games_platform_id_platforms_id_fk",
|
||||||
|
"tableFrom": "games",
|
||||||
|
"tableTo": "platforms",
|
||||||
|
"columnsFrom": [
|
||||||
|
"platform_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"platforms": {
|
||||||
|
"name": "platforms",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"igdb_id": {
|
||||||
|
"name": "igdb_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"igdb_slug": {
|
||||||
|
"name": "igdb_slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"moby_id": {
|
||||||
|
"name": "moby_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"es_slug": {
|
||||||
|
"name": "es_slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ra_id": {
|
||||||
|
"name": "ra_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"cover": {
|
||||||
|
"name": "cover",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"family_name": {
|
||||||
|
"name": "family_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"platforms_igdb_id_unique": {
|
||||||
|
"name": "platforms_igdb_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"igdb_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"platforms_igdb_slug_unique": {
|
||||||
|
"name": "platforms_igdb_slug_unique",
|
||||||
|
"columns": [
|
||||||
|
"igdb_slug"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"platforms_moby_id_unique": {
|
||||||
|
"name": "platforms_moby_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"moby_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"platforms_es_slug_unique": {
|
||||||
|
"name": "platforms_es_slug_unique",
|
||||||
|
"columns": [
|
||||||
|
"es_slug"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"platforms_ra_id_unique": {
|
||||||
|
"name": "platforms_ra_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"ra_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"platforms_slug_unique": {
|
||||||
|
"name": "platforms_slug_unique",
|
||||||
|
"columns": [
|
||||||
|
"slug"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"screenshots": {
|
||||||
|
"name": "screenshots",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"game_id": {
|
||||||
|
"name": "game_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "blob",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"screenshots_game_id_games_id_fk": {
|
||||||
|
"name": "screenshots_game_id_games_id_fk",
|
||||||
|
"tableFrom": "screenshots",
|
||||||
|
"tableTo": "games",
|
||||||
|
"columnsFrom": [
|
||||||
|
"game_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,20 @@
|
||||||
"when": 1771508990238,
|
"when": 1771508990238,
|
||||||
"tag": "0000_pretty_harry_osborn",
|
"tag": "0000_pretty_harry_osborn",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772998956867,
|
||||||
|
"tag": "0001_outstanding_silk_fever",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1776111721964,
|
||||||
|
"tag": "0002_flowery_rocket_raccoon",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
142
package.json
142
package.json
|
|
@ -1,114 +1,158 @@
|
||||||
{
|
{
|
||||||
"name": "com.simeonradivoev.gameflow-deck",
|
"name": "com.simeonradivoev.gameflow-deck",
|
||||||
"displayName": "Gameflow",
|
"displayName": "Gameflow",
|
||||||
"version": "1.1.0",
|
"author": {
|
||||||
|
"name": "Simeon Radivoev",
|
||||||
|
"email": "work@simeonradivoev.com",
|
||||||
|
"url": "https://simeonradivoev.com"
|
||||||
|
},
|
||||||
|
"version": "1.6.0",
|
||||||
"description": "Game Launcher",
|
"description": "Game Launcher",
|
||||||
"icon": "./src/mainview/assets/icon.svg",
|
"icon": "./src/mainview/assets/icon.svg",
|
||||||
"main": "./src/bun/index.ts",
|
"main": "./src/bun/index.ts",
|
||||||
"bin": "gameflow",
|
"bin": "gameflow",
|
||||||
|
"license": "AGPL-3.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/simeonradivoev/gameflow-deck"
|
"url": "https://github.com/simeonradivoev/gameflow-deck"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.3.9",
|
"packageManager": "bun@1.3.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"workspaces": [
|
||||||
|
"./src/packages/gameflow-sdk"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development bun run build:vite && WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS='--remote-debugging-port=9222' 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 dev'",
|
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
|
||||||
"build:vite": "vite build",
|
"dev:bun:hmr": "PUBLIC_ACCESS=true NODE_ENV=development conc 'bun run hmr' 'bun run --watch ./src/bun/index.ts'",
|
||||||
|
"dev:bun": "NODE_ENV=development bun run build:vite && conc 'bun run ./src/bun/index.ts'",
|
||||||
|
"build:vite": "bun run --bun vite build",
|
||||||
"build:prod:vite": "NODE_ENV=production bun run build:vite",
|
"build: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",
|
||||||
"hmr": "vite --port 5173",
|
"hmr": "bun run --bun vite --port 5173",
|
||||||
"drizzle:generate": "bunx drizzle-kit generate",
|
"drizzle:generate": "bunx drizzle-kit generate",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"mappings:generate": "bun run drizzle-kit generate --dialect=sqlite --schema=./src/bun/api/schema/emulators.ts --out=./scripts/drizzle/es-de && bun run ./scripts/generate-es-de-mapping.ts",
|
"mappings:generate": "bun run drizzle-kit generate --dialect=sqlite --schema=./src/bun/api/schema/emulators.ts --out=./scripts/drizzle/es-de && bun run ./scripts/generate-es-de-mapping.ts",
|
||||||
"flatpak:generate-sources": "bun run ./scripts/generate-flatpak-sources.ts",
|
"flatpak:generate-sources": "bun run ./scripts/generate-flatpak-sources.ts",
|
||||||
"flatpak:override": "flatpak override org.flatpak.Builder --filesystem=host --device=all",
|
"flatpak:override": "flatpak override org.flatpak.Builder --filesystem=host --device=all",
|
||||||
"flatpak:restore": "flatpak override --reset --user org.flatpak.Builder",
|
"flatpak:restore": "flatpak override --reset --user org.flatpak.Builder",
|
||||||
"flatpak:build": "flatpak run org.flatpak.Builder build/flatpak flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --force-clean",
|
"flatpak:build": "FLATPAK_BUILD=true NODE_ENV=production NON_COMPILED=true bun run build && flatpak run org.flatpak.Builder ../gameflow-flatpak/build/flatpak .config/flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --state-dir=../gameflow-flatpak/state --force-clean",
|
||||||
"flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck",
|
"flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck",
|
||||||
"build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts",
|
"build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts",
|
||||||
"build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts",
|
"build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts",
|
||||||
"version:generate": "standard-version --sign",
|
"version:generate": "commit-and-tag-version --sign",
|
||||||
"package:Linux": "bun run build:prod:appimage",
|
"package:Linux": "bun run build:prod:appimage",
|
||||||
"package:Windows": "bun run build:prod"
|
"package:Windows": "bun run build:prod",
|
||||||
|
"download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium",
|
||||||
|
"download: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",
|
||||||
"@auth/core": "^0.34.3",
|
"@auth/core": "^0.34.3",
|
||||||
"@elysiajs/cors": "^1.4.1",
|
"@elysiajs/cors": "^1.4.2",
|
||||||
"@elysiajs/eden": "^1.4.6",
|
"@elysiajs/eden": "^1.4.9",
|
||||||
"@elysiajs/static": "^1.4.7",
|
"@jimp/wasm-webp": "^1.6.1",
|
||||||
"@rcompat/webview": "^0.18.0",
|
"@phalcode/ts-igdb-client": "^1.0.26",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"conf": "^15.0.2",
|
"conf": "^15.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.2",
|
||||||
"elysia": "^1.4.22",
|
"elysia": "^1.4.28",
|
||||||
"fs-extra": "^11.3.3",
|
"fs-extra": "^11.3.5",
|
||||||
"get-folder-size": "^5.0.0",
|
"get-folder-size": "^5.0.0",
|
||||||
"jimp": "^1.6.0",
|
"ini": "^6.0.0",
|
||||||
|
"jimp": "^1.6.1",
|
||||||
|
"mustache": "^4.2.0",
|
||||||
|
"node-7z": "^3.0.0",
|
||||||
"node-disk-info": "^1.3.0",
|
"node-disk-info": "^1.3.0",
|
||||||
"node-downloader-helper": "^2.1.10",
|
"node-downloader-helper": "^2.1.11",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
|
"node-unrar-js": "^2.0.2",
|
||||||
|
"npm-check-updates": "^22.2.0",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
|
"p-queue": "^9.2.0",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"systeminformation": "^5.31.1",
|
"slugify": "^1.6.9",
|
||||||
"tough-cookie": "^6.0.0",
|
"smol-toml": "^1.6.1",
|
||||||
|
"systeminformation": "^5.31.6",
|
||||||
|
"tapable": "^2.3.3",
|
||||||
|
"tough-cookie": "^6.0.1",
|
||||||
"tough-cookie-file-store": "^3.3.0",
|
"tough-cookie-file-store": "^3.3.0",
|
||||||
"unzip-stream": "^0.3.4",
|
"unzip-stream": "^0.3.4",
|
||||||
"zod": "^4.3.6"
|
"webview-bun": "^2.4.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ap0nia/eden": "^1.0.0-next.22",
|
"@ap0nia/eden": "^1.6.1",
|
||||||
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
|
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
|
||||||
"@hey-api/openapi-ts": "^0.91.0",
|
"@emulatorjs/emulatorjs": "^4.2.3",
|
||||||
"@noriginmedia/norigin-spatial-navigation": "^2.3.0",
|
"@hey-api/openapi-ts": "^0.91.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@noriginmedia/norigin-spatial-navigation": "^3.1.0",
|
||||||
"@tanstack/react-form": "^1.28.0",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/react-query-devtools": "^5.91.3",
|
"@tanstack/react-form": "^1.32.0",
|
||||||
"@tanstack/react-router": "^1.157.16",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"@tanstack/react-router-devtools": "^1.154.12",
|
"@tanstack/react-query-devtools": "^5.100.10",
|
||||||
"@tanstack/react-router-ssr-query": "^1.157.17",
|
"@tanstack/react-query-persist-client": "^5.100.10",
|
||||||
"@tanstack/router-plugin": "^1.157.16",
|
"@tanstack/react-router": "^1.169.2",
|
||||||
"@tanstack/zod-adapter": "^1.162.4",
|
"@tanstack/react-router-devtools": "^1.166.13",
|
||||||
|
"@tanstack/react-router-ssr-query": "^1.166.12",
|
||||||
|
"@tanstack/router-plugin": "^1.167.35",
|
||||||
|
"@tanstack/zod-adapter": "^1.166.9",
|
||||||
|
"@types/adm-zip": "^0.5.8",
|
||||||
|
"@types/audiosprite": "^0.7.3",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/react": "^19.2.9",
|
"@types/howler": "^2.2.12",
|
||||||
|
"@types/ini": "^4.1.1",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
|
"@types/mustache": "^4.2.6",
|
||||||
|
"@types/node-7z": "^2.1.11",
|
||||||
|
"@types/rclone.js": "^0.6.3",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/unzip-stream": "^0.3.4",
|
"@types/unzip-stream": "^0.3.4",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"app-builder-bin": "^5.0.0-alpha.13",
|
"app-builder-bin": "^5.0.0-alpha.13",
|
||||||
|
"audiosprite": "^0.7.2",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"commit-and-tag-version": "^12.7.3",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"daisyui": "^5.5.14",
|
"daisyui": "^5.5.19",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.10",
|
||||||
"dts-bundle-generator": "^9.5.1",
|
|
||||||
"eden-tanstack-query": "^0.0.9",
|
"eden-tanstack-query": "^0.0.9",
|
||||||
|
"howler": "^2.2.4",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"pretty-bytes": "^7.1.0",
|
"pretty-bytes": "^7.1.0",
|
||||||
"react": "^19.2.4",
|
"pretty-ms": "^9.3.0",
|
||||||
"react-dom": "^19.2.4",
|
"react": "^19.2.6",
|
||||||
"react-error-boundary": "^6.1.0",
|
"react-dom": "^19.2.6",
|
||||||
|
"react-error-boundary": "^6.1.1",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"sass-embedded": "^1.97.3",
|
"react-markdown": "^10.1.0",
|
||||||
"standard-version": "^9.5.0",
|
"react-qr-code": "^2.0.21",
|
||||||
"tailwind-merge": "^3.4.0",
|
"sass-embedded": "^1.99.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.3",
|
||||||
"vite-plugin-svg-icons-ng": "^1.5.2",
|
"vite-plugin-svg-icons-ng": "^1.9.1",
|
||||||
"vite-static-assets-plugin": "^1.2.2",
|
"vite-static-assets-plugin": "^1.2.2",
|
||||||
"vite-tsconfig-paths": "^6.1.1"
|
"vite-tsconfig-paths": "^6.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,12 @@ 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 { rmdir } from "node:fs";
|
import mustache from "mustache";
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// CONFIGURE THESE FOR YOUR APP
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
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/assets/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;
|
||||||
const APP_ID = pkg.name;
|
const APP_ID = pkg.name;
|
||||||
|
|
@ -23,30 +18,54 @@ const APPDIR = path.resolve(TMP_FOLDER, `${APP_ID}.AppDir`);
|
||||||
console.log(`>>> Building AppImage for ${APP_NAME} (${APP_ID})...`);
|
console.log(`>>> Building AppImage for ${APP_NAME} (${APP_ID})...`);
|
||||||
|
|
||||||
await ensureDir(path.join(APPDIR, `usr`, 'bin'));
|
await ensureDir(path.join(APPDIR, `usr`, 'bin'));
|
||||||
|
await ensureDir(path.join(APPDIR, `usr`, 'lib'));
|
||||||
await ensureDir("build");
|
await ensureDir("build");
|
||||||
|
|
||||||
// Copy app dir
|
// Copy app dir
|
||||||
await fs.cp(`${APP_DIR}/.`, path.join(APPDIR, `usr`, 'share'), { recursive: true });
|
await fs.cp(`${APP_DIR}/.`, path.join(APPDIR, `usr`, 'share'), { recursive: true });
|
||||||
await fs.rename(path.join(APPDIR, `usr`, 'share', BINARY_NAME), path.join(APPDIR, `usr`, 'bin', BINARY_NAME));
|
await fs.rename(path.join(APPDIR, `usr`, 'share', BINARY_NAME), path.join(APPDIR, `usr`, 'bin', BINARY_NAME));
|
||||||
|
await fs.rename(path.join(APPDIR, `usr`, 'share', `libwebview-${process.arch}.so`), path.join(APPDIR, `usr`, 'lib', `libwebview-${process.arch}.so`));
|
||||||
|
await fs.rename(path.join(APPDIR, `usr`, 'share', `7za`), path.join(APPDIR, `usr`, 'bin', `7za`));
|
||||||
|
|
||||||
await fs.writeFile(path.join(APPDIR, `${APP_ID}.desktop`), `[Desktop Entry]
|
if (!await fs.exists('./bin/nw/nw'))
|
||||||
Version=${pkg.version}
|
{
|
||||||
X-AppImage-Name=${APP_NAME}
|
await import('./download-nw');
|
||||||
X-AppImage-Version=${pkg.version}
|
}
|
||||||
X-AppImage-Arch=${process.arch}
|
|
||||||
Name=${APP_NAME}
|
|
||||||
Comment=${pkg.description}
|
|
||||||
Exec=${APP_ID}.AppImage
|
|
||||||
Icon=.DirIcon
|
|
||||||
Type=Application
|
|
||||||
Categories=Game;
|
|
||||||
`);
|
|
||||||
|
|
||||||
await Bun.write(path.join(APPDIR, "AppRun"), `#!/bin/bash
|
await ensureDir(path.join(APPDIR, `usr`, 'lib', 'nw'));
|
||||||
APPDIR="$(dirname "$(readlink -f "$0")")"
|
await fs.cp('./bin/nw', path.join(APPDIR, `usr`, 'lib', 'nw'), { recursive: true });
|
||||||
APPIMAGE=true
|
await fs.symlink(path.join(APPDIR, `usr`, 'lib', 'nw', 'nw'), path.join(APPDIR, `usr`, `bin`, 'nw'));
|
||||||
exec "$APPDIR/usr/bin/${BINARY_NAME}" "$@"
|
|
||||||
`);
|
const templateVars = {
|
||||||
|
APP_NAME,
|
||||||
|
VERSION: pkg.version,
|
||||||
|
ARCH: process.arch,
|
||||||
|
DESCRIPTION: pkg.description,
|
||||||
|
APP_ID,
|
||||||
|
BINARY_NAME,
|
||||||
|
LICENSE: pkg.license
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktopFileTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.desktop', 'utf8');
|
||||||
|
|
||||||
|
const raw = await $`git tag --sort=-version:refname`.text().then(d => d.trim());
|
||||||
|
const tags = raw.split('\n').filter(t => t.match(/^\d+\.\d+\.\d+$/));
|
||||||
|
console.log("tags", tags);
|
||||||
|
|
||||||
|
console.log(">>> Updating Release History...");
|
||||||
|
const releases = await Promise.all(tags.map(async tag =>
|
||||||
|
{
|
||||||
|
const date = await $`git log -1 --format=%as ${tag}`.text().then(d => d.trim());
|
||||||
|
const version = tag.replace(/^v/, '');
|
||||||
|
return ` <release version="${version}" date="${date}"/>`;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const appStreamTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml', 'utf8');
|
||||||
|
await ensureDir(path.join(APPDIR, 'usr', 'share', 'metainfo'));
|
||||||
|
await fs.writeFile(path.join(APPDIR, 'usr', 'share', 'metainfo', `${APP_ID}.appdata.xml`), mustache.render(appStreamTemplate, { ...templateVars, RELEASES: releases }));
|
||||||
|
|
||||||
|
const appRunTemplate = await fs.readFile(`./.config/appimage/AppRun`, 'utf8');
|
||||||
|
await Bun.write(path.join(APPDIR, "AppRun"), mustache.render(appRunTemplate, templateVars));
|
||||||
await $`chmod +x ${APPDIR}/AppRun`;
|
await $`chmod +x ${APPDIR}/AppRun`;
|
||||||
|
|
||||||
console.log(">>> Building AppImage...");
|
console.log(">>> Building AppImage...");
|
||||||
|
|
@ -54,7 +73,7 @@ const config = {
|
||||||
productName: pkg.displayName,
|
productName: pkg.displayName,
|
||||||
productFilename: pkg.name,
|
productFilename: pkg.name,
|
||||||
executableName: BINARY_NAME,
|
executableName: BINARY_NAME,
|
||||||
desktopEntry: DESKTOP,
|
desktopEntry: mustache.render(desktopFileTemplate, templateVars),
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
file: ICON,
|
file: ICON,
|
||||||
|
|
@ -69,7 +88,7 @@ const config = {
|
||||||
// Remove the build dir, mainly to help with CIs
|
// Remove the build dir, mainly to help with CIs
|
||||||
await fs.rm(APP_DIR, { recursive: true });
|
await fs.rm(APP_DIR, { recursive: true });
|
||||||
await ensureDir(APP_DIR);
|
await ensureDir(APP_DIR);
|
||||||
const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}.AppImage`);
|
const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}-${process.platform}-${process.arch}.AppImage`);
|
||||||
const STAGE = path.resolve(TMP_FOLDER, `${APP_ID}.stage`);
|
const STAGE = path.resolve(TMP_FOLDER, `${APP_ID}.stage`);
|
||||||
|
|
||||||
await ensureDir(STAGE);
|
await ensureDir(STAGE);
|
||||||
|
|
@ -88,8 +107,9 @@ const proc = Bun.spawn([
|
||||||
});
|
});
|
||||||
|
|
||||||
const code = await proc.exited;
|
const code = await proc.exited;
|
||||||
await fs.rm(APPDIR, { recursive: true, force: true });
|
|
||||||
await fs.rm(STAGE, { recursive: true, force: true });
|
await fs.rm(STAGE, { recursive: true, force: true });
|
||||||
|
await fs.rm(APPDIR, { recursive: true, force: true });
|
||||||
|
|
||||||
if (code !== 0) process.exit(code);
|
if (code !== 0) process.exit(code);
|
||||||
|
|
||||||
console.log(`\n✅ Done!`);
|
console.log(`\n Done!`);
|
||||||
|
|
@ -1,43 +1,104 @@
|
||||||
// watcher.ts - run this instead of --watch
|
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import { watch } from "fs";
|
|
||||||
import browser from '../src/bun/browser';
|
import browser from '../src/bun/browser';
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import path from "path";
|
||||||
|
import { watch } from "fs";
|
||||||
|
import { sleep } from "bun";
|
||||||
const events = new EventEmitter();
|
const events = new EventEmitter();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
let restarting = false;
|
||||||
|
|
||||||
|
process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222";
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
|
||||||
function spawnServer ()
|
function spawnServer ()
|
||||||
{
|
{
|
||||||
return Bun.spawn(["bun", "run", "--inspect=127.0.0.1:9229/fixed-session", '--watch', "./src/bun/index.ts"], {
|
const s = Bun.spawn(["bun", '--install=fallback', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
|
||||||
env: {
|
env: {
|
||||||
...Bun.env,
|
...process.env,
|
||||||
HEADLESS: "true",
|
HEADLESS: "true",
|
||||||
},
|
},
|
||||||
stdout: "inherit",
|
stdout: 'inherit',
|
||||||
stderr: "inherit",
|
stderr: 'inherit',
|
||||||
stdin: "inherit",
|
stdin: 'inherit',
|
||||||
|
signal: abortController.signal,
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
ipc (message, subprocess, handle)
|
ipc (message, subprocess, handle)
|
||||||
{
|
{
|
||||||
if (message.type === 'exitapp')
|
if (message === 'focus')
|
||||||
|
{
|
||||||
|
events.emit('focus');
|
||||||
|
} else if (message === 'exitapp')
|
||||||
{
|
{
|
||||||
events.emit('exitapp');
|
events.emit('exitapp');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onExit (subprocess, exitCode, signalCode)
|
onExit (subprocess, exitCode, signalCode)
|
||||||
{
|
{
|
||||||
process.exit();
|
if (!restarting)
|
||||||
|
{
|
||||||
|
console.log("Existing Dev With", exitCode);
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnBrowser ()
|
function spawnBrowser ()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return browser(events, !!Bun.env.FORCE_BROWSER);
|
|
||||||
|
return browser(events, {
|
||||||
|
configPath: path.join(tmpdir(), 'gameflow'),
|
||||||
|
isSteamDeckGameMode: false,
|
||||||
|
forceBrowser: process.env.FORCE_BROWSER === "true"
|
||||||
|
});
|
||||||
} catch (error)
|
} catch (error)
|
||||||
{
|
{
|
||||||
console.error(error);
|
console.error(error);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = spawnServer();
|
async function restart ()
|
||||||
spawnBrowser()?.then(e => server.send({ type: 'exitapp' }));
|
{
|
||||||
|
if (server)
|
||||||
|
{
|
||||||
|
restarting = true;
|
||||||
|
server.kill();
|
||||||
|
await server.exited;
|
||||||
|
server = undefined;
|
||||||
|
console.log("Old Server stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
server = spawnServer();
|
||||||
|
await sleep(1000);
|
||||||
|
console.log("New Server started");
|
||||||
|
restarting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch("./src/bun", { recursive: true }, (event, filename) =>
|
||||||
|
{
|
||||||
|
if (restarting) return;
|
||||||
|
console.log(`[watcher] ${event}: ${filename} — restarting...`);
|
||||||
|
restart();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch("./src/packages", { recursive: true }, (event, filename) =>
|
||||||
|
{
|
||||||
|
if (restarting) return;
|
||||||
|
console.log(`[watcher] ${event}: ${filename} — restarting...`);
|
||||||
|
restart();
|
||||||
|
});
|
||||||
|
|
||||||
|
let server: Bun.Subprocess | undefined = spawnServer();
|
||||||
|
if (!process.env.HEADLESS)
|
||||||
|
{
|
||||||
|
spawnBrowser()?.then(async e =>
|
||||||
|
{
|
||||||
|
if (!server) return;
|
||||||
|
abortController.abort();
|
||||||
|
await server.exited;
|
||||||
|
});
|
||||||
|
}
|
||||||
283
scripts/download-chromium.ts
Normal file
283
scripts/download-chromium.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* download-chromium.ts
|
||||||
|
* Downloads the latest ungoogled-chromium for the current platform + arch.
|
||||||
|
* Skips the download if the binary is already present and up to date.
|
||||||
|
*
|
||||||
|
* Usage: bun download-chromium.ts [--out=./chromium] [--force]
|
||||||
|
* In package.json scripts: "prebuild": "bun scripts/download-chromium.ts"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import StreamZip from "node-stream-zip";
|
||||||
|
|
||||||
|
// --- Config ------------------------------------------------------------------
|
||||||
|
|
||||||
|
const GITHUB_API = "https://api.github.com";
|
||||||
|
const VERSION_FILE = ".chromium-version";
|
||||||
|
|
||||||
|
const REPOS: Record<string, string> = {
|
||||||
|
linux: "ungoogled-software/ungoogled-chromium-portablelinux",
|
||||||
|
darwin: "ungoogled-software/ungoogled-chromium-macos",
|
||||||
|
win32: "ungoogled-software/ungoogled-chromium-windows",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORM_MAP: Record<string, string> = {
|
||||||
|
linux: "linux",
|
||||||
|
win32: "windows",
|
||||||
|
darwin: 'macos'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ARCH_MAP: Record<string, Record<string, string>> = {
|
||||||
|
linux: { x64: "x86_64", arm64: "arm64" },
|
||||||
|
darwin: { x64: "x86_64", arm64: "arm64" },
|
||||||
|
win32: { x64: "x64", arm64: "arm64" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFERRED_EXT: Record<string, string[]> = {
|
||||||
|
linux: [".tar.xz"],
|
||||||
|
darwin: [".dmg", ".zip"],
|
||||||
|
win32: [".zip"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The expected binary path per platform after extraction */
|
||||||
|
function getBinaryPath (outDir: string, version: string, platform: string, arch: string): string
|
||||||
|
{
|
||||||
|
const subFolder = `ungoogled-chromium_${version}_${PLATFORM_MAP[platform]}_${ARCH_MAP[platform][arch]}`;
|
||||||
|
if (platform === "linux")
|
||||||
|
{
|
||||||
|
return path.join(outDir, subFolder, "chrome");
|
||||||
|
}
|
||||||
|
if (platform === "darwin") return path.join(outDir, "Chromium.app");
|
||||||
|
return path.join(outDir, subFolder, "chrome.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
function log (msg: string)
|
||||||
|
{
|
||||||
|
process.stdout.write(`\x1b[36m[chromium]\x1b[0m ${msg}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function err (msg: string): never
|
||||||
|
{
|
||||||
|
process.stderr.write(`\x1b[31m[error]\x1b[0m ${msg}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ghFetch (url: string)
|
||||||
|
{
|
||||||
|
const headers: Record<string, string> = { "User-Agent": "bun-chromium-downloader" };
|
||||||
|
const token = process.env.GITHUB_TOKEN;
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
const res = await fetch(url, { headers });
|
||||||
|
if (!res.ok) err(`GitHub API error ${res.status}: ${url}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readVersionCache (outDir: string): Promise<string | null>
|
||||||
|
{
|
||||||
|
const file = path.join(outDir, VERSION_FILE);
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
return (await Bun.file(file).text()).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeVersionCache (outDir: string, version: string)
|
||||||
|
{
|
||||||
|
await Bun.write(path.join(outDir, VERSION_FILE), version);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadWithProgress (url: string, dest: string)
|
||||||
|
{
|
||||||
|
log(`Downloading -> ${dest}`);
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) err(`Download failed: ${res.status} ${url}`);
|
||||||
|
|
||||||
|
const total = Number(res.headers.get("content-length") ?? 0);
|
||||||
|
let received = 0;
|
||||||
|
const writer = Bun.file(dest).writer();
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
writer.write(value);
|
||||||
|
received += value.length;
|
||||||
|
if (total > 0)
|
||||||
|
{
|
||||||
|
const pct = ((received / total) * 100).toFixed(1);
|
||||||
|
const mb = (received / 1e6).toFixed(1);
|
||||||
|
const totalMb = (total / 1e6).toFixed(1);
|
||||||
|
process.stdout.write(`\r ${pct}% ${mb} / ${totalMb} MB `);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writer.end();
|
||||||
|
process.stdout.write("\n");
|
||||||
|
log("Download complete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractZip (src: string, outDir: string)
|
||||||
|
{
|
||||||
|
log(`Extracting zip -> ${outDir}`);
|
||||||
|
const zip = new StreamZip.async({ file: src });
|
||||||
|
const entries = await zip.entries();
|
||||||
|
const total = Object.keys(entries).length;
|
||||||
|
await zip.extract(null, outDir);
|
||||||
|
await zip.close();
|
||||||
|
log(`Extracted ${total} files.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNative (src: string, outDir: string)
|
||||||
|
{
|
||||||
|
if (src.endsWith(".AppImage"))
|
||||||
|
{
|
||||||
|
const dest = path.join(outDir, "chromium.AppImage");
|
||||||
|
spawnSync("cp", [src, dest]);
|
||||||
|
spawnSync("chmod", ["+x", dest]);
|
||||||
|
log(`AppImage ready at: ${dest}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src.endsWith(".tar.xz"))
|
||||||
|
{
|
||||||
|
const result = spawnSync("tar", ["-xJf", src, "-C", outDir], { stdio: "inherit" });
|
||||||
|
if (result.status !== 0) err("tar extraction failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src.endsWith(".dmg"))
|
||||||
|
{
|
||||||
|
log("Mounting DMG...");
|
||||||
|
const mount = spawnSync("hdiutil", ["attach", src, "-nobrowse", "-quiet"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
if (mount.status !== 0) err("hdiutil mount failed");
|
||||||
|
const mountLine = mount.stdout.split("\n").find((l) => l.includes("/Volumes/"));
|
||||||
|
const mountPoint = mountLine?.split("\t").at(-1)?.trim();
|
||||||
|
if (!mountPoint) err("Could not find DMG mount point");
|
||||||
|
spawnSync("cp", ["-R", mountPoint!, outDir], { stdio: "inherit" });
|
||||||
|
spawnSync("hdiutil", ["detach", mountPoint!, "-quiet"]);
|
||||||
|
log(`DMG contents copied to: ${outDir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
err(`Unknown archive format: ${src}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main --------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main ()
|
||||||
|
{
|
||||||
|
const platform = process.platform;
|
||||||
|
const arch = process.arch;
|
||||||
|
const force = process.argv.includes("--force");
|
||||||
|
const outArg = process.argv.find(a => a.startsWith("--out="))?.slice(6)
|
||||||
|
?? "./chromium";
|
||||||
|
const outDir = path.resolve(outArg);
|
||||||
|
|
||||||
|
log(`Platform: ${platform} Arch: ${arch}`);
|
||||||
|
|
||||||
|
const repo = REPOS[platform];
|
||||||
|
if (!repo) err(`Unsupported platform: ${platform}`);
|
||||||
|
|
||||||
|
const archStr = ARCH_MAP[platform]?.[arch];
|
||||||
|
if (!archStr) err(`Unsupported arch "${arch}" on ${platform}`);
|
||||||
|
|
||||||
|
// Fetch latest version (lightweight — just the tag, no asset download yet)
|
||||||
|
log(`Checking latest release from ${repo}...`);
|
||||||
|
const release = await ghFetch(`${GITHUB_API}/repos/${repo}/releases/latest`);
|
||||||
|
const version: string = release.tag_name ?? release.name ?? "unknown";
|
||||||
|
log(`Latest version: ${version}`);
|
||||||
|
|
||||||
|
// Check if already downloaded and up to date
|
||||||
|
if (!force)
|
||||||
|
{
|
||||||
|
const cachedVersion = await readVersionCache(outDir);
|
||||||
|
const assets: Array<{ name: string; }> = release.assets ?? [];
|
||||||
|
const preferred = PREFERRED_EXT[platform] ?? [];
|
||||||
|
let assetName: string | undefined;
|
||||||
|
for (const ext of preferred)
|
||||||
|
{
|
||||||
|
assetName = assets.find(a => a.name.includes(archStr) && a.name.endsWith(ext))?.name;
|
||||||
|
if (assetName) break;
|
||||||
|
}
|
||||||
|
if (!assetName) assetName = assets.find(a => a.name.includes(archStr))?.name;
|
||||||
|
|
||||||
|
if (cachedVersion === version)
|
||||||
|
{
|
||||||
|
const binaryPath = getBinaryPath(outDir, cachedVersion, platform, arch);
|
||||||
|
if (existsSync(binaryPath))
|
||||||
|
{
|
||||||
|
log(`Already up to date (${version}). Skipping download.`);
|
||||||
|
log(`Binary: ${binaryPath}`);
|
||||||
|
return;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
log(`Version matches but binary missing — re-downloading.`);
|
||||||
|
}
|
||||||
|
} else if (cachedVersion)
|
||||||
|
{
|
||||||
|
log(`New version available: ${cachedVersion} -> ${version}`);
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
log("--force flag set, re-downloading.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick asset to download
|
||||||
|
const assets: Array<{ name: string; browser_download_url: string; }> = release.assets ?? [];
|
||||||
|
if (assets.length === 0) err("No assets found in the latest release.");
|
||||||
|
|
||||||
|
const preferred = PREFERRED_EXT[platform] ?? [];
|
||||||
|
let chosen: (typeof assets)[0] | undefined;
|
||||||
|
|
||||||
|
for (const ext of preferred)
|
||||||
|
{
|
||||||
|
chosen = assets.find(a => a.name.includes(archStr) && a.name.endsWith(ext));
|
||||||
|
if (chosen) break;
|
||||||
|
}
|
||||||
|
if (!chosen) chosen = assets.find(a => a.name.includes(archStr));
|
||||||
|
|
||||||
|
if (!chosen)
|
||||||
|
{
|
||||||
|
log("Available assets:");
|
||||||
|
for (const a of assets) log(` ${a.name}`);
|
||||||
|
err(`No asset found matching arch "${archStr}" on ${platform}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Selected asset: ${chosen.name}`);
|
||||||
|
|
||||||
|
if (!existsSync(outDir)) await mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const tmpFile = path.join(outDir, chosen.name);
|
||||||
|
await downloadWithProgress(chosen.browser_download_url, tmpFile);
|
||||||
|
|
||||||
|
const { unlink } = await import("node:fs/promises");
|
||||||
|
|
||||||
|
if (chosen.name.endsWith(".zip"))
|
||||||
|
{
|
||||||
|
await extractZip(tmpFile, outDir);
|
||||||
|
await unlink(tmpFile);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
extractNative(tmpFile, outDir);
|
||||||
|
if (!chosen.name.endsWith(".AppImage"))
|
||||||
|
{
|
||||||
|
await unlink(tmpFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save version so next run can skip
|
||||||
|
await writeVersionCache(outDir, version);
|
||||||
|
|
||||||
|
log(`\nDone! Chromium ${version} extracted to: ${outDir}`);
|
||||||
|
|
||||||
|
const binaryPath = getBinaryPath(outDir, version, platform, arch);
|
||||||
|
log(`Binary: ${binaryPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => err(String(e)));
|
||||||
54
scripts/download-nw.ts
Normal file
54
scripts/download-nw.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { ensureDir, remove } from "fs-extra";
|
||||||
|
import StreamZip from "node-stream-zip";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
|
||||||
|
const VERSION = "0.110.1";
|
||||||
|
|
||||||
|
const platformMap: Record<string, string> = {
|
||||||
|
"win32": "win",
|
||||||
|
"darwin": "osx"
|
||||||
|
};
|
||||||
|
const extMap: Record<string, string> = {
|
||||||
|
"win32": "zip",
|
||||||
|
"linux": "tar.gz",
|
||||||
|
"darwin": "zip"
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Removing old download");
|
||||||
|
await remove('./bin/nw');
|
||||||
|
|
||||||
|
const downloadUrl = `https://dl.nwjs.io/v${VERSION}/nwjs-sdk-v${VERSION}-${platformMap[process.platform] ?? process.platform}-${process.arch}.${extMap[process.platform]}`;
|
||||||
|
|
||||||
|
console.log("Starting NW download from", downloadUrl);
|
||||||
|
const response = await fetch(downloadUrl);
|
||||||
|
if (!response.ok) throw new Error(response.statusText);
|
||||||
|
const downlodPath = `./bin/nw.${extMap[process.platform]}`;
|
||||||
|
await ensureDir('./bin');
|
||||||
|
await Bun.write(downlodPath, response);
|
||||||
|
console.log("Downloaded NW to", downlodPath);
|
||||||
|
|
||||||
|
if (downlodPath.endsWith('.zip'))
|
||||||
|
{
|
||||||
|
await extractZip(downlodPath, './bin');
|
||||||
|
}
|
||||||
|
else if (downlodPath.endsWith(".tar.gz"))
|
||||||
|
{
|
||||||
|
const result = spawnSync("tar", ["-xvf", downlodPath, "-C", './bin'], { stdio: "inherit" });
|
||||||
|
if (result.status !== 0) console.error("tar extraction failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Renaming to nw');
|
||||||
|
await fs.rename(`./bin/nwjs-sdk-v${VERSION}-${platformMap[process.platform] ?? process.platform}-${process.arch}`, './bin/nw');
|
||||||
|
await fs.rm(downlodPath);
|
||||||
|
|
||||||
|
async function extractZip (src: string, outDir: string)
|
||||||
|
{
|
||||||
|
console.log(`Extracting zip -> ${outDir}`);
|
||||||
|
const zip = new StreamZip.async({ file: src });
|
||||||
|
const entries = await zip.entries();
|
||||||
|
const total = Object.keys(entries).length;
|
||||||
|
await zip.extract(null, outDir);
|
||||||
|
await zip.close();
|
||||||
|
console.log(`Extracted ${total} files.`);
|
||||||
|
}
|
||||||
34
scripts/drizzle/es-de/0000_sparkling_banshee.sql
Normal file
34
scripts/drizzle/es-de/0000_sparkling_banshee.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
CREATE TABLE `commands` (
|
||||||
|
`system` text,
|
||||||
|
`label` text,
|
||||||
|
`command` text NOT NULL,
|
||||||
|
FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE cascade ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `emulators` (
|
||||||
|
`name` text PRIMARY KEY NOT NULL,
|
||||||
|
`fullname` text,
|
||||||
|
`systempath` text DEFAULT (json_array()) NOT NULL,
|
||||||
|
`staticpath` text DEFAULT (json_array()) NOT NULL,
|
||||||
|
`corepath` text DEFAULT (json_array()) NOT NULL,
|
||||||
|
`androidpackage` text DEFAULT (json_array()) NOT NULL,
|
||||||
|
`winregistrypath` text DEFAULT (json_array()) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `emulators_name_unique` ON `emulators` (`name`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `systemMappings` (
|
||||||
|
`source` text,
|
||||||
|
`sourceSlug` text,
|
||||||
|
`sourceId` integer,
|
||||||
|
`system` text NOT NULL,
|
||||||
|
FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `systems` (
|
||||||
|
`name` text PRIMARY KEY NOT NULL,
|
||||||
|
`fullname` text,
|
||||||
|
`path` text,
|
||||||
|
`extension` text DEFAULT (json_array()) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `systems_name_unique` ON `systems` (`name`);
|
||||||
234
scripts/drizzle/es-de/meta/0000_snapshot.json
Normal file
234
scripts/drizzle/es-de/meta/0000_snapshot.json
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "b4ee710f-eaa5-4bbb-9e69-13d490c7142c",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"commands": {
|
||||||
|
"name": "commands",
|
||||||
|
"columns": {
|
||||||
|
"system": {
|
||||||
|
"name": "system",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"name": "label",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"name": "command",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"commands_system_systems_name_fk": {
|
||||||
|
"name": "commands_system_systems_name_fk",
|
||||||
|
"tableFrom": "commands",
|
||||||
|
"tableTo": "systems",
|
||||||
|
"columnsFrom": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "cascade"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"emulators": {
|
||||||
|
"name": "emulators",
|
||||||
|
"columns": {
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fullname": {
|
||||||
|
"name": "fullname",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"systempath": {
|
||||||
|
"name": "systempath",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(json_array())"
|
||||||
|
},
|
||||||
|
"staticpath": {
|
||||||
|
"name": "staticpath",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(json_array())"
|
||||||
|
},
|
||||||
|
"corepath": {
|
||||||
|
"name": "corepath",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(json_array())"
|
||||||
|
},
|
||||||
|
"androidpackage": {
|
||||||
|
"name": "androidpackage",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(json_array())"
|
||||||
|
},
|
||||||
|
"winregistrypath": {
|
||||||
|
"name": "winregistrypath",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(json_array())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"emulators_name_unique": {
|
||||||
|
"name": "emulators_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"systemMappings": {
|
||||||
|
"name": "systemMappings",
|
||||||
|
"columns": {
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sourceSlug": {
|
||||||
|
"name": "sourceSlug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sourceId": {
|
||||||
|
"name": "sourceId",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"name": "system",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"systemMappings_system_systems_name_fk": {
|
||||||
|
"name": "systemMappings_system_systems_name_fk",
|
||||||
|
"tableFrom": "systemMappings",
|
||||||
|
"tableTo": "systems",
|
||||||
|
"columnsFrom": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"name": "systems",
|
||||||
|
"columns": {
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fullname": {
|
||||||
|
"name": "fullname",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"name": "path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"extension": {
|
||||||
|
"name": "extension",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(json_array())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"systems_name_unique": {
|
||||||
|
"name": "systems_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
scripts/drizzle/es-de/meta/_journal.json
Normal file
13
scripts/drizzle/es-de/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1776039605377,
|
||||||
|
"tag": "0000_sparkling_banshee",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
30
scripts/generate-audio-sprites.ts
Normal file
30
scripts/generate-audio-sprites.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import audioSprite from 'audiosprite';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { soundMap } from '../src/mainview/scripts/audio/audioConstants';
|
||||||
|
|
||||||
|
var allFiles = await Array.fromAsync(new Bun.Glob('*.{ogg,wav}').scan({ cwd: './src/sounds' }));
|
||||||
|
const files = Object.values(soundMap).map(v =>
|
||||||
|
{
|
||||||
|
const existingFile = allFiles.find(f => f.startsWith(v.key));
|
||||||
|
if (!existingFile) throw new Error(`Could not find file for sound ${v.key}`);
|
||||||
|
const filePath = path.join(path.resolve('./src/sounds'), existingFile);
|
||||||
|
return filePath;
|
||||||
|
});
|
||||||
|
console.log("Loaded", files.join(","));
|
||||||
|
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
{
|
||||||
|
audioSprite(files,
|
||||||
|
{
|
||||||
|
output: path.resolve('./src/mainview/assets/sounds'),
|
||||||
|
path: path.resolve('./src/sounds'),
|
||||||
|
format: 'howler',
|
||||||
|
export: 'ogg'
|
||||||
|
},
|
||||||
|
async function (err, obj: any)
|
||||||
|
{
|
||||||
|
if (err) return console.error(err);
|
||||||
|
delete obj.urls;
|
||||||
|
Bun.file('./src/mainview/assets/sounds.json').write(JSON.stringify(obj, null, 2)).then(r => resolve(true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -41,10 +41,10 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
||||||
|
|
||||||
await Promise.all(platforms.map(async ([platform, arch]) =>
|
await Promise.all(platforms.map(async ([platform, arch]) =>
|
||||||
{
|
{
|
||||||
const systems = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_systems.xml`).arrayBuffer();
|
const systemsXml = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_systems.xml`).arrayBuffer();
|
||||||
const $s = cheerio.load(Buffer.from(systems));
|
const $s = cheerio.load(Buffer.from(systemsXml));
|
||||||
const rules = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_find_rules.xml`).arrayBuffer();
|
const rulesXml = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_find_rules.xml`).arrayBuffer();
|
||||||
const $r = cheerio.load(Buffer.from(rules));
|
const $r = cheerio.load(Buffer.from(rulesXml));
|
||||||
|
|
||||||
const sqlitePath = `./vendors/es-de/emulators.${platform}.${arch}.sqlite`;
|
const sqlitePath = `./vendors/es-de/emulators.${platform}.${arch}.sqlite`;
|
||||||
const sqlite = new Database(sqlitePath, { create: true, readwrite: true });
|
const sqlite = new Database(sqlitePath, { create: true, readwrite: true });
|
||||||
|
|
@ -52,9 +52,10 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
||||||
migrate(db, { migrationsFolder: "./scripts/drizzle/es-de" });
|
migrate(db, { migrationsFolder: "./scripts/drizzle/es-de" });
|
||||||
|
|
||||||
/** Save the ruleset for emulators */
|
/** Save the ruleset for emulators */
|
||||||
await db.insert(schema.emulators).values($r('ruleList emulator').toArray().map(s =>
|
const emulators = $r('ruleList emulator').toArray().map(s =>
|
||||||
{
|
{
|
||||||
const $emulator = $r(s);
|
const $emulator = $r(s);
|
||||||
|
const comment = $emulator.contents().toArray().find((node) => node.type === 'comment');
|
||||||
const $systempath = $emulator.find('rule[type=systempath] entry');
|
const $systempath = $emulator.find('rule[type=systempath] entry');
|
||||||
const $staticpath = $emulator.find('rule[type=staticpath] entry');
|
const $staticpath = $emulator.find('rule[type=staticpath] entry');
|
||||||
const $corepath = $emulator.find('rule[type=corepath] entry');
|
const $corepath = $emulator.find('rule[type=corepath] entry');
|
||||||
|
|
@ -64,77 +65,109 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
||||||
const emulatorName = $emulator.attr('name');
|
const emulatorName = $emulator.attr('name');
|
||||||
const emulator: typeof schema.emulators.$inferInsert = {
|
const emulator: typeof schema.emulators.$inferInsert = {
|
||||||
name: emulatorName!,
|
name: emulatorName!,
|
||||||
|
fullname: comment?.data.trim(),
|
||||||
systempath: $systempath.toArray().map(p => $r(p).text()),
|
systempath: $systempath.toArray().map(p => $r(p).text()),
|
||||||
staticpath: $staticpath.toArray().map(p => $r(p).text()),
|
staticpath: $staticpath.toArray().map(p => $r(p).text()),
|
||||||
corepath: $corepath.toArray().map(p => $r(p).text()),
|
corepath: $corepath.toArray().map(p => $r(p).text()),
|
||||||
androidpackage: $androidpackage.toArray().map(p => $r(p).text()),
|
androidpackage: $androidpackage.toArray().map(p => $r(p).text()),
|
||||||
winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()),
|
winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()),
|
||||||
};
|
};
|
||||||
|
|
||||||
return emulator;
|
return emulator;
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
await db.insert(schema.emulators).values(emulators);
|
||||||
|
|
||||||
/** Save the systems like ps2 or psp */
|
/** Save the systems like ps2 or psp */
|
||||||
await Promise.all($s(`systemList system`).toArray().map(async s =>
|
const systems = await Promise.all($s(`systemList system`).toArray().map(async s =>
|
||||||
{
|
{
|
||||||
const name = $s(s).find("name").text();
|
const name = $s(s).find("name").text();
|
||||||
const fullname = $s(s).find("fullname").text();
|
const fullname = $s(s).find("fullname").text();
|
||||||
const rommMapping = rommPlatforms.data?.find(p =>
|
|
||||||
p.slug === (customMappings as any)[name] ||
|
|
||||||
p.slug === name ||
|
|
||||||
p.igdb_slug === name ||
|
|
||||||
p.hltb_slug === name ||
|
|
||||||
p.moby_slug === name ||
|
|
||||||
p.display_name === fullname
|
|
||||||
);
|
|
||||||
|
|
||||||
const system: typeof schema.systems.$inferInsert = {
|
const commands = $s(s).find("command").toArray().map(c =>
|
||||||
name,
|
|
||||||
fullname,
|
|
||||||
path: $s(s).find("path").text(),
|
|
||||||
extension: $s(s).find("extension").text().replaceAll('.', '').split(' ')
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Store mappings to all other sources for easy reference */
|
|
||||||
db.transaction(async (tx) =>
|
|
||||||
{
|
|
||||||
await tx.insert(schema.systems).values(system);
|
|
||||||
if (rommMapping)
|
|
||||||
{
|
|
||||||
const sources: [string, keyof typeof rommMapping | null, keyof typeof rommMapping | null][] = [
|
|
||||||
['ra', 'ra_id', null],
|
|
||||||
['ss', 'ss_id', null],
|
|
||||||
['hltb', null, 'hltb_slug'],
|
|
||||||
['moby', 'moby_id', 'moby_slug'],
|
|
||||||
['launchbox', 'launchbox_id', null],
|
|
||||||
['sgdb', 'sgdb_id', null],
|
|
||||||
['tgdb', 'tgdb_id', null],
|
|
||||||
['hasheous', 'hasheous_id', null],
|
|
||||||
['flashpoint', 'flashpoint_id', null],
|
|
||||||
['romm', null, 'slug'],
|
|
||||||
['igdb', 'igdb_id', 'igdb_slug']
|
|
||||||
];
|
|
||||||
|
|
||||||
await tx.insert(schema.systemMappings)
|
|
||||||
.values(sources.map(([source, sourceId, sourceSlug]) => ({
|
|
||||||
source,
|
|
||||||
sourceId: sourceId ? rommMapping[sourceId] as number : null,
|
|
||||||
sourceSlug: sourceSlug ? rommMapping[sourceSlug] as string : null,
|
|
||||||
system: system.name
|
|
||||||
} satisfies typeof schema.systemMappings.$inferInsert))
|
|
||||||
.filter(m => m.sourceId !== null || m.sourceSlug !== null));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(schema.commands).values($s(s).find("command").toArray().map(c =>
|
|
||||||
{
|
{
|
||||||
const command: typeof schema.commands.$inferInsert = {
|
const command: typeof schema.commands.$inferInsert = {
|
||||||
label: $s(c).attr('label'),
|
label: $s(c).attr('label'),
|
||||||
command: $s(c).text(),
|
command: $s(c).text(),
|
||||||
system: system.name
|
system: name
|
||||||
};
|
};
|
||||||
|
|
||||||
return command;
|
return command;
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
const rommMapping = rommPlatforms.data?.find(p =>
|
||||||
|
{
|
||||||
|
const custom = (customMappings as any)[name];
|
||||||
|
if (Array.isArray(custom) && custom.some(m => m === p.slug))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.slug === custom ||
|
||||||
|
p.slug === name ||
|
||||||
|
p.igdb_slug === name ||
|
||||||
|
p.display_name === fullname;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const mappings: {
|
||||||
|
source: string;
|
||||||
|
sourceId: number | null;
|
||||||
|
sourceSlug: string | null;
|
||||||
|
system: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
if (rommMapping)
|
||||||
|
{
|
||||||
|
const sources: [string, keyof typeof rommMapping | null, keyof typeof rommMapping | null][] = [
|
||||||
|
['ra', 'ra_id', null],
|
||||||
|
['ss', 'ss_id', null],
|
||||||
|
['hltb', null, 'hltb_slug'],
|
||||||
|
['moby', 'moby_id', 'moby_slug'],
|
||||||
|
['launchbox', 'launchbox_id', null],
|
||||||
|
['sgdb', 'sgdb_id', null],
|
||||||
|
['tgdb', 'tgdb_id', null],
|
||||||
|
['hasheous', 'hasheous_id', null],
|
||||||
|
['flashpoint', 'flashpoint_id', null],
|
||||||
|
['romm', null, 'slug'],
|
||||||
|
['igdb', 'igdb_id', 'igdb_slug']
|
||||||
|
];
|
||||||
|
|
||||||
|
mappings.push(...sources.map(([source, sourceId, sourceSlug]) => ({
|
||||||
|
source,
|
||||||
|
sourceId: sourceId ? rommMapping[sourceId] as number : null,
|
||||||
|
sourceSlug: sourceSlug ? rommMapping[sourceSlug] as string : null,
|
||||||
|
system: name
|
||||||
|
}))
|
||||||
|
.filter(m => m.sourceId !== null || m.sourceSlug !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
const system = {
|
||||||
|
name,
|
||||||
|
fullname,
|
||||||
|
path: $s(s).find("path").text(),
|
||||||
|
extension: $s(s).find("extension").text().replaceAll('.', '').split(' '),
|
||||||
|
commands,
|
||||||
|
mappings
|
||||||
|
};
|
||||||
|
|
||||||
|
return system;
|
||||||
|
}));
|
||||||
|
|
||||||
|
await Promise.all(systems.map(async system =>
|
||||||
|
{
|
||||||
|
/** Store mappings to all other sources for easy reference */
|
||||||
|
await db.transaction(async (tx) =>
|
||||||
|
{
|
||||||
|
await tx.insert(schema.systems).values(system);
|
||||||
|
if (system.mappings.length > 0)
|
||||||
|
{
|
||||||
|
await tx.insert(schema.systemMappings)
|
||||||
|
.values(system.mappings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(schema.commands).values(system.commands);
|
||||||
}));
|
}));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
|
|
||||||
const lockfile = Bun.argv[2] ?? "bun.lockb";
|
|
||||||
const output = Bun.argv[3] ?? ".config/flatpak/sources.gen.json";
|
const output = Bun.argv[3] ?? ".config/flatpak/sources.gen.json";
|
||||||
|
|
||||||
const text = await $`bun ./bun.lockb --hash: 0000000000000000-0000000000000000-0000000000000000-0000000000000000`.text();
|
const text = await $`bun ./bun.lockb --hash: 0000000000000000-0000000000000000-0000000000000000-0000000000000000`.text();
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path, { } from "node:path";
|
import path, { } from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import app from '../package.json';
|
||||||
|
|
||||||
const system = getPlatform();
|
const system = getPlatform();
|
||||||
const buildSubDir = process.env.BUILD_DIR ?? `./build/${system.platform}`;
|
const buildSubDir = process.env.BUILD_DIR ?? `./build/${system.platform}`;
|
||||||
|
|
||||||
const compileOption: Bun.CompileBuildOptions = {
|
const compileOption: Bun.CompileBuildOptions = {
|
||||||
outfile: "gameflow",
|
outfile: "gameflow",
|
||||||
execArgv: ['--windows-hide-console'],
|
|
||||||
autoloadTsconfig: true,
|
autoloadTsconfig: true,
|
||||||
autoloadPackageJson: true,
|
autoloadPackageJson: true,
|
||||||
autoloadDotenv: true,
|
autoloadDotenv: true,
|
||||||
autoloadBunfig: true,
|
autoloadBunfig: true,
|
||||||
|
windows: {
|
||||||
|
hideConsole: true,
|
||||||
|
icon: './src/mainview/public/favicon.ico',
|
||||||
|
title: app.displayName,
|
||||||
|
description: app.description,
|
||||||
|
version: app.version
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (process.env.TARGET)
|
if (process.env.TARGET)
|
||||||
|
|
@ -19,6 +26,38 @@ if (process.env.TARGET)
|
||||||
compileOption.target = process.env.TARGET as any;
|
compileOption.target = process.env.TARGET as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let zip: string | undefined;
|
||||||
|
let zipPath: string = '';
|
||||||
|
let zipNodePath: string | undefined;
|
||||||
|
let webviewLib: string | undefined;
|
||||||
|
switch (process.platform)
|
||||||
|
{
|
||||||
|
case "win32":
|
||||||
|
zip = "7za.exe";
|
||||||
|
zipNodePath = "win";
|
||||||
|
webviewLib = `libwebview.dll`;
|
||||||
|
break;
|
||||||
|
case "linux":
|
||||||
|
zip = "7za";
|
||||||
|
zipNodePath = 'linux';
|
||||||
|
webviewLib = `libwebview-${system.arch}.so`;
|
||||||
|
break;
|
||||||
|
case "darwin":
|
||||||
|
zip = "7za";
|
||||||
|
zipNodePath = 'mac';
|
||||||
|
webviewLib = `libwebview-${system.arch}.dylib`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webviewLib) throw new Error("Could not find webviewlib");
|
||||||
|
|
||||||
|
let webviewLibPath = '.';
|
||||||
|
if (process.env.APPIMAGE === "true")
|
||||||
|
{
|
||||||
|
webviewLibPath = `./usr/lib`;
|
||||||
|
zipPath = './usr/bin';
|
||||||
|
}
|
||||||
|
|
||||||
await Bun.build({
|
await Bun.build({
|
||||||
entrypoints: ["./src/bun/index.ts", `./src/bun/webview/${system.platform}.ts`],
|
entrypoints: ["./src/bun/index.ts", `./src/bun/webview/${system.platform}.ts`],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
|
|
@ -26,7 +65,9 @@ await Bun.build({
|
||||||
outdir: buildSubDir,
|
outdir: buildSubDir,
|
||||||
root: './src/bun',
|
root: './src/bun',
|
||||||
define: {
|
define: {
|
||||||
"process.env.IS_BINARY": "true"
|
"process.env.IS_BINARY": "true",
|
||||||
|
"process.env.WEBVIEW_PATH": `${webviewLibPath}/${webviewLib}`,
|
||||||
|
"process.env.ZIP7_PATH": `"${zip}"`
|
||||||
},
|
},
|
||||||
minify: process.env.NODE_ENV !== 'development',
|
minify: process.env.NODE_ENV !== 'development',
|
||||||
sourcemap: process.env.NODE_ENV === 'development' ? 'inline' : "linked",
|
sourcemap: process.env.NODE_ENV === 'development' ? 'inline' : "linked",
|
||||||
|
|
@ -50,11 +91,16 @@ await Bun.build({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
build.onEnd(async () =>
|
build.onEnd(async (b) =>
|
||||||
{
|
{
|
||||||
|
|
||||||
await fs.cp('./dist', `${buildSubDir}/dist`, { recursive: true });
|
await fs.cp('./dist', `${buildSubDir}/dist`, { recursive: true });
|
||||||
await fs.cp('./drizzle', `${buildSubDir}/drizzle`, { recursive: true });
|
await fs.cp('./drizzle', `${buildSubDir}/drizzle`, { recursive: true });
|
||||||
await fs.cp(`./vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, `${buildSubDir}/vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, { recursive: true });
|
await fs.cp(`./vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, `${buildSubDir}/vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, { recursive: true });
|
||||||
|
await fs.cp(path.join(`node_modules/webview-bun/build/`, webviewLib), path.join(buildSubDir, webviewLib));
|
||||||
|
await fs.cp(`node_modules/7zip-bin/${zipNodePath}/${process.arch}`, buildSubDir, { recursive: true, errorOnExist: false });
|
||||||
|
if (await fs.exists('bin/chromium'))
|
||||||
|
await fs.cp('bin/chromium', `${buildSubDir}/bin/chromium`, { recursive: true, errorOnExist: false });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,97 +1,146 @@
|
||||||
|
|
||||||
import { TaskQueue } from "./task-queue";
|
import { TaskQueue, AppEventMap } from "@simeonradivoev/gameflow-sdk";
|
||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import { CookieJar } from 'tough-cookie';
|
import { CookieJar } from 'tough-cookie';
|
||||||
import FileCookieStore from 'tough-cookie-file-store';
|
import FileCookieStore from 'tough-cookie-file-store';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import Conf from "conf";
|
import Conf from "conf";
|
||||||
import projectPackage from '~/package.json';
|
import projectPackage from '~/package.json';
|
||||||
import { Notification, SettingsSchema, SettingsType } from "@shared/constants";
|
import { SettingsType, SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
||||||
import { client } from "@clients/romm/client.gen";
|
import { client } from "@clients/romm/client.gen";
|
||||||
import * as schema from "./schema/app";
|
import * as schema from "@schema/app";
|
||||||
import * as emulatorSchema from "./schema/emulators";
|
import cacheSchema from "@schema/cache";
|
||||||
import { login, logout } from "./auth";
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { ActiveGame } from "../types/types";
|
|
||||||
import EventEmitter from "node:events";
|
import EventEmitter from "node:events";
|
||||||
import { ErrorLike } from "bun";
|
import { appPath } from "../utils";
|
||||||
import { appPath, getErrorMessage } from "../utils";
|
|
||||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import { PluginManager } from "./plugins/plugin-manager";
|
||||||
|
import registerPlugins from "./plugins/register-plugins";
|
||||||
|
import controls from './controls/controls';
|
||||||
|
import { RunAPIServer } from "./rpc";
|
||||||
|
import { RunBunServer } from "../server";
|
||||||
|
import ReloadPluginsJob from "./jobs/reload-plugins-job";
|
||||||
|
|
||||||
export const config = new Conf<SettingsType>({
|
export let config: Conf<SettingsType>;
|
||||||
projectName: projectPackage.name,
|
export let customEmulators: Conf<Record<string, string>>;
|
||||||
projectSuffix: 'bun',
|
export let fileCookieStore: FileCookieStore;
|
||||||
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any,
|
export let jar: CookieJar;
|
||||||
defaults: SettingsSchema.parse({
|
|
||||||
downloadPath: path.join(os.homedir(), "gameflow"),
|
|
||||||
windowSize: { width: 1280, height: 800 }
|
|
||||||
} satisfies SettingsType),
|
|
||||||
});
|
|
||||||
export const customEmulators = new Conf<Record<string, string>>({
|
|
||||||
projectName: projectPackage.name,
|
|
||||||
projectSuffix: 'bun',
|
|
||||||
configName: 'custom-emulators',
|
|
||||||
rootSchema: {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Config Path Located At: ", config.path);
|
|
||||||
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
|
||||||
console.log("App Directory is ", process.env.APPDIR);
|
|
||||||
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
|
||||||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
|
||||||
export const jar = new CookieJar(fileCookieStore);
|
|
||||||
await fs.mkdir(config.get('downloadPath'), { recursive: true });
|
|
||||||
let sqlite: Database;
|
let sqlite: Database;
|
||||||
|
export let cachePath: string;
|
||||||
|
let cacheSqlite: Database;
|
||||||
export let db: DrizzleSqliteDODatabase<typeof schema>;
|
export let db: DrizzleSqliteDODatabase<typeof schema>;
|
||||||
await reloadDatabase();
|
export let cache: DrizzleSqliteDODatabase<typeof cacheSchema>;
|
||||||
migrate(db!, { migrationsFolder: appPath("./drizzle") });
|
let emulatorsSqlite: Database;
|
||||||
const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
|
export let emulatorsDb: BunSQLiteDatabase<typeof emulatorSchema> & { $client: Database; };
|
||||||
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
export let taskQueue: TaskQueue;
|
||||||
export const taskQueue = new TaskQueue();
|
export let plugins: PluginManager;
|
||||||
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
export let events: EventEmitter<AppEventMap>;
|
||||||
await login();
|
let controlsHandle: { cleanup: () => void; };
|
||||||
export let activeGame: ActiveGame | undefined;
|
let api: { cleanup: () => Promise<void>; };
|
||||||
export function setActiveGame (game: ActiveGame)
|
let bunServer: { cleanup: () => Promise<void>; } | undefined;
|
||||||
|
let cleannedUp = false;
|
||||||
|
let cleaningUp = false;
|
||||||
|
|
||||||
|
export async function load ()
|
||||||
{
|
{
|
||||||
if (activeGame) throw new Error("Only one active game at a time");
|
config = new Conf<SettingsType>({
|
||||||
return activeGame = game;
|
projectName: projectPackage.name,
|
||||||
|
projectSuffix: 'bun',
|
||||||
|
cwd: process.env.CONFIG_CWD,
|
||||||
|
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any,
|
||||||
|
defaults: SettingsSchema.parse({
|
||||||
|
downloadPath: process.env.DEFAULT_DOWNLOAD_PATH ?? path.join(os.homedir(), "gameflow"),
|
||||||
|
windowSize: { width: 1280, height: 800 }
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
customEmulators = new Conf<Record<string, string>>({
|
||||||
|
projectName: projectPackage.name,
|
||||||
|
projectSuffix: 'bun',
|
||||||
|
cwd: process.env.CONFIG_CWD,
|
||||||
|
configName: 'custom-emulators',
|
||||||
|
rootSchema: {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Config Path Located At: ", config.path);
|
||||||
|
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
||||||
|
console.log("App Directory is ", process.env.APPDIR);
|
||||||
|
console.log("Cache Path is ", cachePath);
|
||||||
|
|
||||||
|
cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
|
||||||
|
fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
||||||
|
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||||
|
jar = new CookieJar(fileCookieStore);
|
||||||
|
taskQueue = new TaskQueue();
|
||||||
|
events = new EventEmitter<AppEventMap>();
|
||||||
|
emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
|
||||||
|
emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
||||||
|
await reloadDatabase();
|
||||||
|
plugins = new PluginManager();
|
||||||
|
api = await RunAPIServer();
|
||||||
|
await registerPlugins(plugins);
|
||||||
|
taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
|
||||||
|
controlsHandle = await controls();
|
||||||
|
if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer();
|
||||||
|
|
||||||
|
config.onDidChange('downloadPath', () => reloadDatabase());
|
||||||
|
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
||||||
}
|
}
|
||||||
export const events = new EventEmitter<AppEventMap>();
|
|
||||||
events.addListener('activegameexit', ({ error }) =>
|
|
||||||
{
|
|
||||||
activeGame = undefined;
|
|
||||||
if (error)
|
|
||||||
{
|
|
||||||
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log("Logging In to Romm");
|
|
||||||
|
|
||||||
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");
|
||||||
|
await bunServer?.cleanup();
|
||||||
|
await api.cleanup();
|
||||||
await taskQueue.close();
|
await taskQueue.close();
|
||||||
sqlite.close();
|
await plugins.cleanup();
|
||||||
await logout();
|
controlsHandle.cleanup();
|
||||||
|
cacheSqlite.close();
|
||||||
emulatorsSqlite.close();
|
emulatorsSqlite.close();
|
||||||
|
sqlite.close();
|
||||||
|
config._closeWatcher();
|
||||||
|
customEmulators._closeWatcher();
|
||||||
|
console.log("Finished Cleaning Up");
|
||||||
|
cleannedUp = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the cleanup flags. This is mainly used by tests since they run the same app. */
|
||||||
|
export async function resetCleanup ()
|
||||||
|
{
|
||||||
|
cleaningUp = false;
|
||||||
|
cleannedUp = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reloadDatabase ()
|
export async function reloadDatabase ()
|
||||||
{
|
{
|
||||||
|
await ensureDir(config.get('downloadPath'));
|
||||||
sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
|
sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
|
||||||
|
await ensureDir(path.join(os.tmpdir(), 'gameflow'));
|
||||||
|
console.log("Loaded Cache from: ", cachePath);
|
||||||
|
cacheSqlite = new Database(cachePath, { create: true, readwrite: true });
|
||||||
db = drizzle(sqlite, { schema });
|
db = drizzle(sqlite, { schema });
|
||||||
|
cache = drizzle(cacheSqlite, { schema: cacheSchema });
|
||||||
|
migrate(db!, { migrationsFolder: appPath("./drizzle") });
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON;");
|
||||||
|
await cache.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS item_cache (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
expire_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppEventMap
|
|
||||||
{
|
|
||||||
activegameexit: [{ source: string, id: number, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
|
||||||
exitapp: [];
|
|
||||||
notification: [Notification];
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +1,205 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { config, db, jar } from "./app";
|
import { config, events, plugins, taskQueue } from "./app";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { client } from "@clients/romm/client.gen";
|
import { getCurrentUserApiUsersMeGet, tokenApiTokenPost, UserSchema } from "@clients/romm";
|
||||||
import { loginApiLoginPost } from "@clients/romm";
|
|
||||||
import secrets from '../api/secrets';
|
import secrets from '../api/secrets';
|
||||||
|
import { LoginJob } from "./jobs/login-job";
|
||||||
|
import TwitchLoginJob from "./jobs/twitch-login-job";
|
||||||
|
|
||||||
export default new Elysia()
|
export default new Elysia()
|
||||||
.post('/login', async ({ body: { host, username, password } }) =>
|
.post('/login/twitch', async ({ body: { openInBrowser } }) =>
|
||||||
{
|
{
|
||||||
if (config.has('rommAddress') && config.has('rommUser'))
|
if (taskQueue.hasActiveOfType(TwitchLoginJob))
|
||||||
{
|
{
|
||||||
await logout();
|
return status("Conflict", `Twitch Authentication already in progress`);
|
||||||
const oldRommAddress = config.get('rommAddress');
|
|
||||||
if (oldRommAddress)
|
|
||||||
{
|
|
||||||
const cookies = await jar.getCookies(oldRommAddress);
|
|
||||||
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config.set('rommAddress', host);
|
if (!process.env.TWITCH_CLIENT_ID)
|
||||||
config.set('rommUser', username);
|
|
||||||
|
|
||||||
await secrets.set({ service: 'gameflow', name: 'romm', value: password });
|
|
||||||
await login();
|
|
||||||
|
|
||||||
return status(200);
|
|
||||||
}, { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
|
|
||||||
.get('/login', async () =>
|
|
||||||
{
|
|
||||||
const credentials = await secrets.get({ service: 'gameflow', name: 'romm' });
|
|
||||||
return { hasPassword: !!credentials };
|
|
||||||
}, { response: z.object({ hasPassword: z.boolean() }) })
|
|
||||||
.post('/logout', async () =>
|
|
||||||
{
|
|
||||||
await secrets.delete({ service: 'gameflow', name: 'romm' });
|
|
||||||
await logout();
|
|
||||||
const rommAddress = config.get('rommAddress');
|
|
||||||
if (rommAddress)
|
|
||||||
{
|
{
|
||||||
const cookies = await jar.getCookies(rommAddress);
|
return status("Not Found", "Twitch Client ID not set");
|
||||||
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return taskQueue.enqueue(TwitchLoginJob.id, new TwitchLoginJob(process.env.TWITCH_CLIENT_ID, openInBrowser ?? false));
|
||||||
|
},
|
||||||
|
{ body: z.object({ openInBrowser: z.boolean().optional() }) })
|
||||||
|
.post('/logout/twitch', async () =>
|
||||||
|
{
|
||||||
|
if (!process.env.TWITCH_CLIENT_ID)
|
||||||
|
{
|
||||||
|
return status("Not Found", "Twitch Client ID not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('https://id.twitch.tv/oauth2/revoke', {
|
||||||
|
method: "POST", headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: process.env.TWITCH_CLIENT_ID
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await secrets.delete({ service: 'gamflow_twitch', name: 'access_token' });
|
||||||
|
await secrets.delete({ service: 'gamflow_twitch', name: 'refresh_token' });
|
||||||
|
await secrets.delete({ service: 'gamflow_twitch', name: 'expires_in' });
|
||||||
|
|
||||||
|
await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' });
|
||||||
|
|
||||||
|
return status(res.status, res.statusText);
|
||||||
|
})
|
||||||
|
.get('/login/twitch', checkLoginAndRefreshTwitch)
|
||||||
|
.post('/login/romm/qr', async () =>
|
||||||
|
{
|
||||||
|
if (taskQueue.hasActiveOfType(LoginJob))
|
||||||
|
{
|
||||||
|
return status("Conflict", "Login Already Active");
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskQueue.enqueue(LoginJob.id, new LoginJob());
|
||||||
|
})
|
||||||
|
.get('/user/romm', async () =>
|
||||||
|
{
|
||||||
|
const data = await getCurrentUserApiUsersMeGet();
|
||||||
|
if (data.error) return status("Internal Server Error", data.response.statusText);
|
||||||
|
return data.data as UserSchema;
|
||||||
|
})
|
||||||
|
.post('/login/romm', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
|
||||||
|
.get('/login/romm', checkLoginAndRefreshRomm,
|
||||||
|
{ response: z.object({ hasLogin: z.boolean() }) })
|
||||||
|
.post('/logout/romm', async () =>
|
||||||
|
{
|
||||||
|
await secrets.delete({ service: 'gameflow', name: 'romm_access_token' });
|
||||||
|
await secrets.delete({ service: 'gameflow', name: 'romm_refresh_token' });
|
||||||
|
await secrets.delete({ service: 'gameflow', name: 'romm_expires_in' });
|
||||||
return status(200);
|
return status(200);
|
||||||
}, { response: z.any() });
|
}, { response: z.any() });
|
||||||
|
|
||||||
async function updateClient ()
|
|
||||||
|
export async function checkLoginAndRefreshTwitch ()
|
||||||
{
|
{
|
||||||
client.setConfig({
|
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
||||||
baseUrl: config.get('rommAddress'), headers: {
|
if (!access_token)
|
||||||
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
|
{
|
||||||
}
|
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
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout ()
|
if (refreshResponse.ok)
|
||||||
{
|
|
||||||
if (!config.has('rommAddress'))
|
|
||||||
{
|
{
|
||||||
return;
|
const data: {
|
||||||
}
|
access_token: string,
|
||||||
const rommAddress = config.get('rommAddress');
|
refresh_token: string,
|
||||||
if (rommAddress)
|
token_type: string;
|
||||||
{
|
expires_in: number;
|
||||||
console.log("Logging Out of ROMM");
|
} = await refreshResponse.json();
|
||||||
try
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
await loginApiLoginPost({
|
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
|
||||||
baseUrl: rommAddress, headers: {
|
|
||||||
'cookie': await jar.getCookieString(rommAddress)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error)
|
|
||||||
{
|
|
||||||
console.error("Failed to logout of ROMM ", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return status(400, res.statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login ()
|
export async function checkLoginAndRefreshRomm ()
|
||||||
{
|
{
|
||||||
if (!config.has('rommAddress') || !config.has('rommUser'))
|
const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' });
|
||||||
|
if (!access_token)
|
||||||
{
|
{
|
||||||
return;
|
return { hasLogin: false };
|
||||||
}
|
}
|
||||||
const rommAddress = config.get('rommAddress');
|
|
||||||
const rommUser = config.get('rommUser');
|
const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" });
|
||||||
if (rommAddress && rommUser)
|
if (expires_in)
|
||||||
{
|
{
|
||||||
console.log("Logging In to ROMM");
|
const date = new Date(expires_in);
|
||||||
const password = await secrets.get({ service: 'gameflow', name: "romm" });
|
if (date > new Date())
|
||||||
const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` });
|
{
|
||||||
loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress));
|
return { hasLogin: true };
|
||||||
await updateClient();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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; })
|
||||||
|
{
|
||||||
|
const response = await tokenApiTokenPost({
|
||||||
|
body: {
|
||||||
|
password,
|
||||||
|
username,
|
||||||
|
scope: 'me.read roms.read platforms.read assets.read assets.write firmware.read roms.user.read collections.read me.write roms.user.write'
|
||||||
|
}, baseUrl: host
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.response.ok && response.data)
|
||||||
|
{
|
||||||
|
await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: response.data.access_token });
|
||||||
|
await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + response.data.expires * 1000).toString() });
|
||||||
|
if (response.data.refresh_token)
|
||||||
|
{
|
||||||
|
await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: response.data.refresh_token });
|
||||||
|
}
|
||||||
|
|
||||||
|
config.set('rommAddress', host);
|
||||||
|
await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
54
src/bun/api/cache.ts
Normal file
54
src/bun/api/cache.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { cache } from "./app";
|
||||||
|
import cacheSchema from "@schema/cache";
|
||||||
|
import { GithubReleaseSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
||||||
|
import PQueue from "p-queue";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const CACHE_KEYS = {
|
||||||
|
ROM_PLATFORMS: 'rom-platforms',
|
||||||
|
STORE_GAME: (path: string) => `store-game-${path}`,
|
||||||
|
STORE_GAME_MANIFEST: 'store-game-manifest'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// we aggressively cache github data so burst of calls is fine.
|
||||||
|
export const githubRequestQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60 * 60, strict: true });
|
||||||
|
|
||||||
|
export async function getOrCached<T> (key: string, getter: (lastValue: T | undefined) => Promise<T>, options?: { expireMs?: number; force?: boolean; }): Promise<T>
|
||||||
|
{
|
||||||
|
const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) });
|
||||||
|
const updated_at = new Date();
|
||||||
|
|
||||||
|
if (cached && cached.expire_at > updated_at && !options?.force)
|
||||||
|
{
|
||||||
|
return cached.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getter(cached?.data as T);
|
||||||
|
if (data === undefined) return data;
|
||||||
|
|
||||||
|
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await cache.insert(cacheSchema.item_cache)
|
||||||
|
.values({ key, data, updated_at, expire_at })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: cacheSchema.item_cache.key,
|
||||||
|
set: { data, updated_at, expire_at }
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrCachedGithubRelease (path: string, forceCheck?: boolean)
|
||||||
|
{
|
||||||
|
return getOrCached<z.infer<typeof GithubReleaseSchema>>(`github-release-${path}`, () => githubRequestQueue.add(async () =>
|
||||||
|
{
|
||||||
|
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, {
|
||||||
|
method: "GET"
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(response.statusText);
|
||||||
|
const release = await GithubReleaseSchema.parseAsync(await response.json());
|
||||||
|
return release;
|
||||||
|
}), { expireMs: 1000 * 60 * 60, force: forceCheck });
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,15 @@ import { config, jar } from "./app";
|
||||||
import games from "./games/games";
|
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 emulatorjs from "./emulatorjs/emulatorjs";
|
||||||
|
|
||||||
export default new Elysia({ prefix: "/api/romm" })
|
export default new Elysia({ prefix: "/api/romm" })
|
||||||
.use([games, platforms, auth])
|
.use([games, platforms, collections, auth, emulatorjs])
|
||||||
.all("/*", async ({ request, params, set }) =>
|
.all("/*", async ({ request, set }) =>
|
||||||
{
|
{
|
||||||
|
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||||
|
|
||||||
if (!config.has('rommAddress') && !config.get('rommAddress'))
|
if (!config.has('rommAddress') && !config.get('rommAddress'))
|
||||||
{
|
{
|
||||||
return new Response("Romm Address Not Found", { status: 404 });
|
return new Response("Romm Address Not Found", { status: 404 });
|
||||||
|
|
|
||||||
66
src/bun/api/controls/controls.ts
Normal file
66
src/bun/api/controls/controls.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { LaunchGameJob } from '../jobs/launch-game-job';
|
||||||
|
import { events, taskQueue } from '../app';
|
||||||
|
import { GamepadManager } from './manager';
|
||||||
|
|
||||||
|
export default async function Initialize ()
|
||||||
|
{
|
||||||
|
let startSelectPressed = false;
|
||||||
|
let endPressed = false;
|
||||||
|
|
||||||
|
const manager = new GamepadManager();
|
||||||
|
|
||||||
|
function handleFocus ()
|
||||||
|
{
|
||||||
|
const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
|
||||||
|
if (launchGameTask)
|
||||||
|
{
|
||||||
|
taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300));
|
||||||
|
launchGameTask.abort('exit');
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
events.emit('focus');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loop = setInterval(() =>
|
||||||
|
{
|
||||||
|
for (const pad of manager.getGamepads())
|
||||||
|
{
|
||||||
|
const state = pad.update();
|
||||||
|
if (!state) continue;
|
||||||
|
|
||||||
|
if (state.buttons.START && state.buttons.SELECT)
|
||||||
|
{
|
||||||
|
if (!startSelectPressed)
|
||||||
|
{
|
||||||
|
startSelectPressed = true;
|
||||||
|
handleFocus();
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
startSelectPressed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboard = manager.getKeyboard();
|
||||||
|
const keyState = keyboard.update();
|
||||||
|
if (keyState?.keys.End && keyState?.keys.LeftControl)
|
||||||
|
{
|
||||||
|
if (!endPressed)
|
||||||
|
{
|
||||||
|
endPressed = true;
|
||||||
|
handleFocus();
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
endPressed = false;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup: () =>
|
||||||
|
{
|
||||||
|
clearInterval(loop);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
34
src/bun/api/controls/gamepad.ts
Normal file
34
src/bun/api/controls/gamepad.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// ./gamepad/index.ts
|
||||||
|
|
||||||
|
|
||||||
|
import type { IGamepadBackend, GamepadState } from "./types";
|
||||||
|
|
||||||
|
export class Gamepad
|
||||||
|
{
|
||||||
|
private index: number;
|
||||||
|
private backend: IGamepadBackend | undefined;
|
||||||
|
|
||||||
|
constructor(index = 0)
|
||||||
|
{
|
||||||
|
this.index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init ()
|
||||||
|
{
|
||||||
|
if (process.platform === "win32")
|
||||||
|
{
|
||||||
|
const { GamepadWindows } = await import("./windows");
|
||||||
|
this.backend = new GamepadWindows(this.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update (): GamepadState | null
|
||||||
|
{
|
||||||
|
return this.backend?.update() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
close ()
|
||||||
|
{
|
||||||
|
this.backend?.close?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/bun/api/controls/keyboard.ts
Normal file
22
src/bun/api/controls/keyboard.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { IKeyboardBackend, KeyboardState } from "./types";
|
||||||
|
|
||||||
|
export class Keybaord
|
||||||
|
{
|
||||||
|
private backend: IKeyboardBackend | undefined;
|
||||||
|
|
||||||
|
async init ()
|
||||||
|
{
|
||||||
|
if (process.platform === "win32")
|
||||||
|
{
|
||||||
|
const { KeyboardWindows } = await import("./windows");
|
||||||
|
this.backend = new KeyboardWindows();
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update (): KeyboardState | null
|
||||||
|
{
|
||||||
|
return this.backend?.update() ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/bun/api/controls/linux.ts
Normal file
23
src/bun/api/controls/linux.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { IGamepadBackend, GamepadState } from "./types";
|
||||||
|
|
||||||
|
export class GamepadLinux implements IGamepadBackend
|
||||||
|
{
|
||||||
|
constructor(index = 0)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
update (): GamepadState | null
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected ()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
close ()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/bun/api/controls/manager.ts
Normal file
65
src/bun/api/controls/manager.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { Gamepad } from "./gamepad";
|
||||||
|
import { platform } from "os";
|
||||||
|
import { Keybaord } from "./keyboard";
|
||||||
|
|
||||||
|
export class GamepadManager
|
||||||
|
{
|
||||||
|
private gamepads: Gamepad[] = [];
|
||||||
|
private keyboard: Keybaord;
|
||||||
|
private scanInterval: any;
|
||||||
|
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
this.scanGamepads();
|
||||||
|
this.keyboard = new Keybaord();
|
||||||
|
this.keyboard.init();
|
||||||
|
// scan every second for new/disconnected devices
|
||||||
|
this.scanInterval = setInterval(async () => this.scanGamepads(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanGamepads ()
|
||||||
|
{
|
||||||
|
const max = platform() === "win32" ? 4 : 8; // max controllers
|
||||||
|
for (let i = 0; i < max; i++)
|
||||||
|
{
|
||||||
|
if (!this.gamepads[i])
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const pad = new Gamepad(i);
|
||||||
|
await pad.init();
|
||||||
|
if (pad.update())
|
||||||
|
{
|
||||||
|
this.gamepads[i] = pad;
|
||||||
|
console.log(`Gamepad ${i} connected`);
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const connected = this.gamepads[i].update() !== null;
|
||||||
|
if (!connected)
|
||||||
|
{
|
||||||
|
console.log(`Gamepad ${i} disconnected`);
|
||||||
|
this.gamepads[i].close();
|
||||||
|
delete this.gamepads[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeyboard ()
|
||||||
|
{
|
||||||
|
return this.keyboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGamepads ()
|
||||||
|
{
|
||||||
|
return this.gamepads.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop ()
|
||||||
|
{
|
||||||
|
clearInterval(this.scanInterval);
|
||||||
|
for (const pad of this.gamepads) pad.close?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/bun/api/controls/types.ts
Normal file
53
src/bun/api/controls/types.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
export type ButtonName =
|
||||||
|
| "A" | "B" | "X" | "Y"
|
||||||
|
| "UP" | "DOWN" | "LEFT" | "RIGHT"
|
||||||
|
| "LB" | "RB"
|
||||||
|
| "START" | "SELECT"
|
||||||
|
| "L3" | "R3";
|
||||||
|
|
||||||
|
export type KeyCode =
|
||||||
|
| "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"
|
||||||
|
| "KeyW" | "KeyA" | "KeyS" | "KeyD"
|
||||||
|
| "Enter" | "Escape" | "Space" | "End" | "LeftShift" | "RightShift" | "LeftControl" | "RightControl" | "LeftAlt" | "RightAlt";
|
||||||
|
|
||||||
|
export interface KeyboardState
|
||||||
|
{
|
||||||
|
keys: Record<KeyCode, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IKeyboardBackend
|
||||||
|
{
|
||||||
|
update (): KeyboardState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stick
|
||||||
|
{
|
||||||
|
x: number; // -1 → 1
|
||||||
|
y: number; // -1 → 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Triggers
|
||||||
|
{
|
||||||
|
left: number; // 0 → 1
|
||||||
|
right: number; // 0 → 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamepadState
|
||||||
|
{
|
||||||
|
buttons: Record<ButtonName, boolean>;
|
||||||
|
leftStick: Stick;
|
||||||
|
rightStick: Stick;
|
||||||
|
triggers: Triggers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGamepadBackend
|
||||||
|
{
|
||||||
|
/** Polls the current state; returns null if disconnected */
|
||||||
|
update (): GamepadState | null;
|
||||||
|
|
||||||
|
/** Optional: release resources (like closing fd on Linux) */
|
||||||
|
close?(): void;
|
||||||
|
|
||||||
|
/** Optional: check if the gamepad is still connected */
|
||||||
|
isConnected?(): boolean;
|
||||||
|
}
|
||||||
117
src/bun/api/controls/windows.ts
Normal file
117
src/bun/api/controls/windows.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { IGamepadBackend, GamepadState, ButtonName, IKeyboardBackend, KeyboardState, KeyCode } from "./types";
|
||||||
|
import { dlopen, FFIType } from "bun:ffi";
|
||||||
|
|
||||||
|
const xinput = dlopen("xinput1_4.dll", {
|
||||||
|
XInputGetState: { args: [FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const user32 = dlopen("user32.dll", {
|
||||||
|
GetAsyncKeyState: {
|
||||||
|
args: [FFIType.i32],
|
||||||
|
returns: FFIType.i16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Virtual key codes
|
||||||
|
const VK: Record<KeyCode, number> = {
|
||||||
|
ArrowUp: 0x26,
|
||||||
|
ArrowDown: 0x28,
|
||||||
|
ArrowLeft: 0x25,
|
||||||
|
ArrowRight: 0x27,
|
||||||
|
KeyW: 0x57,
|
||||||
|
KeyA: 0x41,
|
||||||
|
KeyS: 0x53,
|
||||||
|
KeyD: 0x44,
|
||||||
|
Enter: 0x0D,
|
||||||
|
Escape: 0x1B,
|
||||||
|
Space: 0x20,
|
||||||
|
End: 0x23,
|
||||||
|
LeftShift: 0xA0,
|
||||||
|
RightShift: 0xA1,
|
||||||
|
LeftControl: 0xA2,
|
||||||
|
RightControl: 0xA3,
|
||||||
|
LeftAlt: 0xA4,
|
||||||
|
RightAlt: 0xA5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERROR_SUCCESS = 0;
|
||||||
|
|
||||||
|
export class KeyboardWindows implements IKeyboardBackend
|
||||||
|
{
|
||||||
|
private keys: Record<KeyCode, boolean> = {} as any;
|
||||||
|
|
||||||
|
update (): KeyboardState
|
||||||
|
{
|
||||||
|
const next: Record<KeyCode, boolean> = {} as any;
|
||||||
|
|
||||||
|
// default all keys to false
|
||||||
|
|
||||||
|
// poll keys globally
|
||||||
|
for (const vkStr in VK)
|
||||||
|
{
|
||||||
|
const vk = Number(VK[vkStr as KeyCode]);
|
||||||
|
const key = vkStr;
|
||||||
|
|
||||||
|
const state = user32.symbols.GetAsyncKeyState(vk);
|
||||||
|
|
||||||
|
if ((state & 0x8000) !== 0)
|
||||||
|
{
|
||||||
|
next[key as KeyCode] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.keys = next;
|
||||||
|
|
||||||
|
return { keys: this.keys };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GamepadWindows implements IGamepadBackend
|
||||||
|
{
|
||||||
|
private index: number;
|
||||||
|
private buffer = new ArrayBuffer(16);
|
||||||
|
private view = new DataView(this.buffer);
|
||||||
|
private currButtons = 0;
|
||||||
|
|
||||||
|
constructor(index = 0) { this.index = index; }
|
||||||
|
|
||||||
|
update (): GamepadState | null
|
||||||
|
{
|
||||||
|
const res = xinput.symbols.XInputGetState(this.index, this.buffer);
|
||||||
|
if (res !== ERROR_SUCCESS) return null;
|
||||||
|
|
||||||
|
this.prevButtons = this.currButtons;
|
||||||
|
this.currButtons = this.view.getUint16(4, true);
|
||||||
|
|
||||||
|
const btns: Record<ButtonName, boolean> = {
|
||||||
|
A: (this.currButtons & 0x1000) !== 0,
|
||||||
|
B: (this.currButtons & 0x2000) !== 0,
|
||||||
|
X: (this.currButtons & 0x4000) !== 0,
|
||||||
|
Y: (this.currButtons & 0x8000) !== 0,
|
||||||
|
UP: (this.currButtons & 0x0001) !== 0,
|
||||||
|
DOWN: (this.currButtons & 0x0002) !== 0,
|
||||||
|
LEFT: (this.currButtons & 0x0004) !== 0,
|
||||||
|
RIGHT: (this.currButtons & 0x0008) !== 0,
|
||||||
|
LB: (this.currButtons & 0x0100) !== 0,
|
||||||
|
RB: (this.currButtons & 0x0200) !== 0,
|
||||||
|
START: (this.currButtons & 0x0010) !== 0,
|
||||||
|
SELECT: (this.currButtons & 0x0020) !== 0,
|
||||||
|
L3: (this.currButtons & 0x0040) !== 0,
|
||||||
|
R3: (this.currButtons & 0x0080) !== 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
buttons: btns,
|
||||||
|
leftStick: { x: this.view.getInt16(6, true) / 32767, y: this.view.getInt16(8, true) / 32767 },
|
||||||
|
rightStick: { x: this.view.getInt16(10, true) / 32767, y: this.view.getInt16(12, true) / 32767 },
|
||||||
|
triggers: { left: this.view.getUint8(14) / 255, right: this.view.getUint8(15) / 255 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected ()
|
||||||
|
{
|
||||||
|
const res = xinput.symbols.XInputGetState(this.index, this.buffer);
|
||||||
|
return res === ERROR_SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Drive } from "@/shared/constants";
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
107
src/bun/api/emulatorjs/emulatorjs.ts
Normal file
107
src/bun/api/emulatorjs/emulatorjs.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// ES-DE to emulator JS mapping
|
||||||
|
|
||||||
|
import Elysia, { status } from "elysia";
|
||||||
|
import z from "zod";
|
||||||
|
import path from 'node:path';
|
||||||
|
import { config, events, plugins } from "../app";
|
||||||
|
import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService";
|
||||||
|
import { SaveFileChange } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
|
// TODO: use the retroarch cores based on ES-DE
|
||||||
|
export const cores: Record<string, string> = {
|
||||||
|
"atari5200": "atari5200",
|
||||||
|
"virtualboy": "vb",
|
||||||
|
"nds": "nds",
|
||||||
|
"arcade": "arcade",
|
||||||
|
"nes": "nes",
|
||||||
|
"gb": "gb",
|
||||||
|
"gbc": "gb",
|
||||||
|
"colecovision": "coleco",
|
||||||
|
"mastersystem": "segaMS",
|
||||||
|
"megadrive": "segaMD",
|
||||||
|
"gamegear": "segaGG",
|
||||||
|
"segacd": "segaCD",
|
||||||
|
"sega32x": "sega32x",
|
||||||
|
"genesis": "sega",
|
||||||
|
"mark3": "sega",
|
||||||
|
"megacd": "sega",
|
||||||
|
"megacdjp": "sega",
|
||||||
|
"megadrivejp": "sega",
|
||||||
|
"sg-1000": "sega",
|
||||||
|
"atarilynx": "lynx",
|
||||||
|
"mame": "mame",
|
||||||
|
"ngp": "ngp",
|
||||||
|
"supergrafx": "pce",
|
||||||
|
"pcfx": "pcfx",
|
||||||
|
"psx": "psx",
|
||||||
|
"wonderswan": "ws",
|
||||||
|
"gba": "gba",
|
||||||
|
"n64": "n64",
|
||||||
|
"3do": "3do",
|
||||||
|
"psp": "psp",
|
||||||
|
"atari7800": "atari7800",
|
||||||
|
"snes": "snes",
|
||||||
|
"atari2600": "atari2600",
|
||||||
|
"atarijaguar": "jaguar",
|
||||||
|
"saturn": "segaSaturn",
|
||||||
|
"amiga": "amiga",
|
||||||
|
"c64": "c64",
|
||||||
|
"c128": "c128",
|
||||||
|
"pet": "pet",
|
||||||
|
"plus4": "plus4",
|
||||||
|
"vic20": "vic20",
|
||||||
|
"dos": "dos"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default new Elysia({ prefix: '/emulatorjs' })
|
||||||
|
.put('/save', async ({ body: { save, screenshot } }) =>
|
||||||
|
{
|
||||||
|
await Bun.write(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", save.name), save);
|
||||||
|
}, {
|
||||||
|
body: z.object({
|
||||||
|
save: z.file(),
|
||||||
|
screenshot: z.file().optional()
|
||||||
|
})
|
||||||
|
}).get('/load', async ({ query: { filePath } }) =>
|
||||||
|
{
|
||||||
|
return Bun.file(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", filePath));
|
||||||
|
}, { query: z.object({ filePath: z.string() }) })
|
||||||
|
.post('/post_play/:source/:id', async ({ params: { source, id }, body: { save } }) =>
|
||||||
|
{
|
||||||
|
const localGame = await getLocalGame(source, id);
|
||||||
|
if (!localGame) return status("Not Found");
|
||||||
|
|
||||||
|
const changedSaveFiles: Record<string, SaveFileChange> = {};
|
||||||
|
if (save)
|
||||||
|
{
|
||||||
|
const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS");
|
||||||
|
const saveFile = path.join(savesPath, save.name);
|
||||||
|
await Bun.write(saveFile, save);
|
||||||
|
changedSaveFiles.gameflow = { subPath: save.name, cwd: savesPath, shared: false };
|
||||||
|
events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" });
|
||||||
|
}
|
||||||
|
await updateLocalLastPlayed(localGame.id);
|
||||||
|
await plugins.hooks.games.postPlay.promise({
|
||||||
|
source,
|
||||||
|
id,
|
||||||
|
saveFolderSlots: { 'emulatorjs': { cwd: path.join(config.get('downloadPath'), "saves", "EMULATORJS") } },
|
||||||
|
gameInfo: { platformSlug: localGame?.platform.slug },
|
||||||
|
changedSaveFiles: [],
|
||||||
|
validChangedSaveFiles: changedSaveFiles,
|
||||||
|
command: {
|
||||||
|
id: "EMULATORJS",
|
||||||
|
command: "",
|
||||||
|
emulator: "EMULATORJS",
|
||||||
|
valid: true,
|
||||||
|
metadata: {
|
||||||
|
romPath: localGame?.path_fs ?? undefined,
|
||||||
|
emulatorBin: undefined,
|
||||||
|
emulatorDir: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
body: z.object({
|
||||||
|
save: z.file().optional()
|
||||||
|
})
|
||||||
|
});
|
||||||
16
src/bun/api/games/collections.ts
Normal file
16
src/bun/api/games/collections.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import Elysia, { status } from "elysia";
|
||||||
|
import { plugins } from "../app";
|
||||||
|
import { FrontEndCollection } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
|
export default new Elysia()
|
||||||
|
.get('/collections', async () =>
|
||||||
|
{
|
||||||
|
const collections: FrontEndCollection[] = [];
|
||||||
|
await plugins.hooks.games.fetchCollections.promise({ collections });
|
||||||
|
return collections;
|
||||||
|
}).get('/collection/:source/:id', async ({ params: { source, id } }) =>
|
||||||
|
{
|
||||||
|
const collection = await plugins.hooks.games.fetchCollection.promise({ source, id });
|
||||||
|
if (!collection) return status("Not Found");
|
||||||
|
return collection;
|
||||||
|
});
|
||||||
|
|
@ -1,45 +1,75 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { config, db, taskQueue } from "../app";
|
import { config, db, emulatorsDb, plugins, taskQueue } from "../app";
|
||||||
import { and, eq, getTableColumns, sql } from "drizzle-orm";
|
import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import * as schema from "../schema/app";
|
import * as schema from "@schema/app";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants";
|
import { SERVER_URL } from "@shared/constants";
|
||||||
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
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 { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
|
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
||||||
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
import buildStatusResponse, { customUpdate, fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
|
||||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||||
import { launchCommand } from "./services/launchGameService";
|
import { launchCommand } from "./services/launchGameService";
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
import { getErrorMessage, SeededRandom } from "@/bun/utils";
|
||||||
import { Jimp } from 'jimp';
|
import { defaultFormats, defaultPlugins } from 'jimp';
|
||||||
|
import { createJimp } from "@jimp/core";
|
||||||
|
import webp from "@jimp/wasm-webp";
|
||||||
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
|
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||||
|
import { host } from "@/bun/utils/host";
|
||||||
|
import { LaunchGameJob } from "../jobs/launch-game-job";
|
||||||
|
import { cores } from "../emulatorjs/emulatorjs";
|
||||||
|
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
|
||||||
|
import { ImportJob } from "../jobs/import-job";
|
||||||
|
import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height }: { blur?: number, width?: number, height?: number; })
|
// A custom jimp that supports webp
|
||||||
|
const Jimp = createJimp({
|
||||||
|
formats: [...defaultFormats, webp],
|
||||||
|
plugins: defaultPlugins,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; })
|
||||||
{
|
{
|
||||||
if (blur)
|
|
||||||
{
|
|
||||||
const jimp = await Jimp.read(img);
|
|
||||||
if (width)
|
|
||||||
{
|
|
||||||
jimp.resize({ w: width, h: height });
|
|
||||||
}
|
|
||||||
if (height)
|
|
||||||
{
|
|
||||||
jimp.resize({ w: width, h: height });
|
|
||||||
}
|
|
||||||
if (blur)
|
|
||||||
{
|
|
||||||
jimp.blur(blur);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jimp.getBuffer('image/png');
|
try
|
||||||
|
{
|
||||||
|
if ((blur && !noBlur))
|
||||||
|
{
|
||||||
|
const jimp = await Jimp.read(img);
|
||||||
|
|
||||||
|
if (blur && !noBlur)
|
||||||
|
{
|
||||||
|
jimp.blur(blur);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width)
|
||||||
|
{
|
||||||
|
jimp.resize({ w: width, h: height });
|
||||||
|
} else if (height)
|
||||||
|
{
|
||||||
|
jimp.resize({ w: width, h: height });
|
||||||
|
}
|
||||||
|
return jimp.getBuffer('image/png');
|
||||||
|
}
|
||||||
|
} catch (e)
|
||||||
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof img === 'string')
|
if (typeof img === 'string')
|
||||||
{
|
{
|
||||||
const rommFetch = await fetch(img);
|
const res = await fetch(img);
|
||||||
return rommFetch;
|
|
||||||
|
return new Response(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": res.headers.get("Content-Type") ?? "image/jpeg",
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return img;
|
return img;
|
||||||
|
|
@ -48,6 +78,8 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width,
|
||||||
export default new Elysia()
|
export default new Elysia()
|
||||||
.get('/game/local/:id/cover', async ({ params: { id }, query, set }) =>
|
.get('/game/local/:id/cover', async ({ params: { id }, query, set }) =>
|
||||||
{
|
{
|
||||||
|
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||||
|
|
||||||
const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) });
|
const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) });
|
||||||
if (!coverBlob || !coverBlob.cover)
|
if (!coverBlob || !coverBlob.cover)
|
||||||
{
|
{
|
||||||
|
|
@ -59,32 +91,28 @@ export default new Elysia()
|
||||||
}
|
}
|
||||||
|
|
||||||
return processImage(coverBlob.cover, query);
|
return processImage(coverBlob.cover, query);
|
||||||
/*return sharp(coverBlob.cover)
|
|
||||||
.resize({ width, height, withoutEnlargement: true })
|
|
||||||
.blur(blur)
|
|
||||||
.toBuffer();*/
|
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.coerce.number() }),
|
params: z.object({ id: z.coerce.number() }),
|
||||||
query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() })
|
query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() })
|
||||||
})
|
})
|
||||||
.get('/image/:source/*', async ({ params: { source, "*": path }, query }) =>
|
.get('/image/:source/*', async ({ params: { source, "*": path }, query, set }) =>
|
||||||
{
|
{
|
||||||
if (source === 'romm')
|
if (source === 'romm')
|
||||||
{
|
{
|
||||||
|
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||||
const rommAdress = config.get('rommAddress');
|
const rommAdress = config.get('rommAddress');
|
||||||
return processImage(`${rommAdress}/${path}`, query);
|
return processImage(`${rommAdress}/${path}`, query);
|
||||||
|
|
||||||
/*
|
|
||||||
const rommFetch = await fetch(`${rommAdress}/${path}`);
|
|
||||||
return sharp(await rommFetch.arrayBuffer())
|
|
||||||
.resize({ width, height, withoutEnlargement: true })
|
|
||||||
.blur(blur)
|
|
||||||
.toBuffer();*/
|
|
||||||
}
|
}
|
||||||
return status('Not Found');
|
return status('Not Found');
|
||||||
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
|
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional(), noBlur: z.coerce.boolean().optional() }) })
|
||||||
|
.get('/image', async ({ query, set }) =>
|
||||||
|
{
|
||||||
|
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||||
|
return processImage(query.url, query);
|
||||||
|
}, { query: z.object({ url: z.url(), blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
|
||||||
.get('/screenshot/:id', async ({ params: { id }, query, set }) =>
|
.get('/screenshot/:id', async ({ params: { id }, query, set }) =>
|
||||||
{
|
{
|
||||||
|
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||||
const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } });
|
const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } });
|
||||||
if (screenshot)
|
if (screenshot)
|
||||||
{
|
{
|
||||||
|
|
@ -94,8 +122,6 @@ export default new Elysia()
|
||||||
}
|
}
|
||||||
|
|
||||||
return processImage(screenshot.content, query);
|
return processImage(screenshot.content, query);
|
||||||
//return sharp(screenshot.content).resize({ width, height, withoutEnlargement: true }).blur(blur).toBuffer();
|
|
||||||
//return screenshot.content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return status(404);
|
return status(404);
|
||||||
|
|
@ -115,142 +141,306 @@ export default new Elysia()
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.number() }),
|
params: z.object({ id: z.number() }),
|
||||||
response: z.object({ installed: z.boolean() })
|
response: z.object({ installed: z.boolean() })
|
||||||
}).get('/games', async ({ query: { platform_source, platform_slug, platform_id, collection_id } }) =>
|
})
|
||||||
|
.get('/games', async ({ query, set }) =>
|
||||||
{
|
{
|
||||||
const where: any[] = [];
|
|
||||||
if (platform_slug)
|
|
||||||
{
|
|
||||||
where.push(eq(schema.platforms.slug, platform_slug));
|
|
||||||
}
|
|
||||||
|
|
||||||
const games: FrontEndGameType[] = [];
|
const games: FrontEndGameType[] = [];
|
||||||
let localGamesSet: Set<number> | undefined;
|
|
||||||
|
|
||||||
if (!collection_id)
|
const where: any[] = [];
|
||||||
|
let localGamesSet: Set<string> | undefined;
|
||||||
|
|
||||||
|
if (query.platform_slug)
|
||||||
{
|
{
|
||||||
const localGames = await db.select({
|
where.push(eq(schema.platforms.slug, query.platform_slug));
|
||||||
...getTableColumns(schema.games),
|
} else if (query.platform_id && query.platform_source === 'local')
|
||||||
platform: schema.platforms,
|
{
|
||||||
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
|
where.push(eq(schema.platforms.id, query.platform_id));
|
||||||
})
|
}
|
||||||
.from(schema.games)
|
else if (query.platform_id && query.platform_source)
|
||||||
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
{
|
||||||
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
|
const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: query.platform_id ? String(query.platform_id) : undefined });
|
||||||
.groupBy(schema.games.id)
|
if (platform)
|
||||||
|
|
||||||
.where(and(...where));
|
|
||||||
|
|
||||||
localGamesSet = new Set(localGames.filter(g => !!g.source_id).map(g => g.source_id!));
|
|
||||||
games.push(...localGames.map(g =>
|
|
||||||
{
|
{
|
||||||
const game: FrontEndGameType = {
|
where.push(eq(schema.platforms.slug, platform?.slug));
|
||||||
platform_display_name: g.platform?.name ?? "Local",
|
}
|
||||||
id: { id: g.id, source: 'local' },
|
|
||||||
updated_at: g.created_at,
|
|
||||||
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
|
||||||
source_id: g.source_id,
|
|
||||||
source: g.source,
|
|
||||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
|
||||||
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
|
||||||
path_fs: g.path_fs,
|
|
||||||
last_played: g.last_played,
|
|
||||||
slug: g.slug,
|
|
||||||
name: g.name,
|
|
||||||
platform_id: g.platform_id
|
|
||||||
};
|
|
||||||
return game;
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((!platform_source || platform_source === 'romm') || !!collection_id)
|
if (query.search)
|
||||||
{
|
{
|
||||||
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true });
|
where.push(like(schema.games.name, query.search));
|
||||||
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(g.id)).map(g =>
|
|
||||||
{
|
|
||||||
return convertRomToFrontend(g);
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }))!);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
games.push(...localGames.slice(query.offset, query.limit !== undefined ? ((query.offset ?? 0) + query.limit) : undefined).filter(g =>
|
||||||
|
{
|
||||||
|
if (query.genres && query.genres.length > 0)
|
||||||
|
{
|
||||||
|
if (!g.metadata) return false;
|
||||||
|
if (!g.metadata.genres) return false;
|
||||||
|
if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}).map(g =>
|
||||||
|
{
|
||||||
|
return convertLocalToFrontend(g);
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (query.localOnly !== true)
|
||||||
|
{
|
||||||
|
const remoteGames: FrontEndGameTypeWithIds[] = [];
|
||||||
|
const remoteGameSet = new Set<string>();
|
||||||
|
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
|
||||||
|
games.push(...remoteGames.filter(g =>
|
||||||
|
{
|
||||||
|
if (localGameExistsPredicate(g))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g.igdb_id)
|
||||||
|
{
|
||||||
|
const igdbId = `igdb@${g.igdb_id}`;
|
||||||
|
if (remoteGameSet.has(igdbId)) return false;
|
||||||
|
remoteGameSet.add(igdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g.ra_id)
|
||||||
|
{
|
||||||
|
const raId = `ra@${g.ra_id}`;
|
||||||
|
if (remoteGameSet.has(raId)) return false;
|
||||||
|
remoteGameSet.add(raId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.orderBy)
|
||||||
|
{
|
||||||
|
switch (query.orderBy)
|
||||||
|
{
|
||||||
|
case 'added':
|
||||||
|
games.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime());
|
||||||
|
break;
|
||||||
|
case 'activity':
|
||||||
|
games.sort((a, b) => Math.max(b.updated_at.getTime(), b.last_played?.getTime() ?? 0) - Math.max(a.updated_at.getTime(), a.last_played?.getTime() ?? 0));
|
||||||
|
break;
|
||||||
|
case 'name':
|
||||||
|
games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
||||||
|
break;
|
||||||
|
case "release":
|
||||||
|
games.sort((a, b) => (b.metadata.first_release_date?.getTime() ?? 0) - (a.metadata.first_release_date?.getTime() ?? 0));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
return { games };
|
return { games };
|
||||||
}, {
|
}, {
|
||||||
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 } }) =>
|
||||||
|
{
|
||||||
|
const filePaths = await plugins.hooks.games.fetchRomFiles.promise({ source, id });
|
||||||
|
|
||||||
|
if (!filePaths || filePaths.length <= 0)
|
||||||
|
{
|
||||||
|
return status("Not Found", "No Valid Roms Found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Bun.file(filePaths[0]);
|
||||||
|
|
||||||
|
}, {
|
||||||
|
params: z.object({ source: z.string(), id: z.string() })
|
||||||
|
})
|
||||||
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||||
{
|
{
|
||||||
async function getLocalGameDetailed (match: any)
|
const sourceData = await getSourceGameDetailed(source, id);
|
||||||
|
|
||||||
|
if (sourceData)
|
||||||
{
|
{
|
||||||
const localGame = await db.query.games.findFirst({
|
if (sourceData.platform_slug)
|
||||||
where: match,
|
{
|
||||||
with: {
|
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
|
||||||
screenshots: { columns: { id: true } },
|
if (systemMapping)
|
||||||
platform: { columns: { name: true } }
|
{
|
||||||
|
const emulatorNames: string[] = [];
|
||||||
|
await plugins.hooks.emulators.findEmulatorForSystem.promise({ system: systemMapping.system, emulators: emulatorNames });
|
||||||
|
|
||||||
|
sourceData.emulators = (await Promise.all(emulatorNames.map(async name =>
|
||||||
|
{
|
||||||
|
if (name === 'EMULATORJS')
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
name: 'EMULATORJS',
|
||||||
|
validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }],
|
||||||
|
logo: 'https://emulatorjs.org/logo/EmulatorJS.png',
|
||||||
|
systems: await Promise.all(Object.keys(cores).map(async c =>
|
||||||
|
{
|
||||||
|
const mapping = await emulatorsDb.query.systemMappings.findFirst({
|
||||||
|
where (fields, operators)
|
||||||
|
{
|
||||||
|
return operators.and(operators.eq(fields.source, "romm"), operators.eq(fields.system, c));
|
||||||
|
}, columns: { sourceSlug: true }
|
||||||
|
});
|
||||||
|
const system: EmulatorSystem = {
|
||||||
|
id: c,
|
||||||
|
name: c,
|
||||||
|
iconUrl: `/api/romm/image/romm/assets/platforms/${mapping?.sourceSlug}.svg`
|
||||||
|
};
|
||||||
|
return system;
|
||||||
|
})),
|
||||||
|
gameCount: 0,
|
||||||
|
source: 'local',
|
||||||
|
integrations: []
|
||||||
|
} satisfies FrontEndGameTypeDetailedEmulator;
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: name });
|
||||||
|
|
||||||
|
const execPaths: EmulatorSourceEntryType[] = [];
|
||||||
|
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: name, sources: execPaths });
|
||||||
|
const integrations = findEmulatorPluginIntegration(id, execPaths);
|
||||||
|
|
||||||
|
if (foundEmulator)
|
||||||
|
{
|
||||||
|
foundEmulator.validSources = execPaths;
|
||||||
|
foundEmulator.integrations = integrations;
|
||||||
|
return foundEmulator;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
logo: "",
|
||||||
|
source: 'local',
|
||||||
|
systems: [],
|
||||||
|
gameCount: 0,
|
||||||
|
validSources: execPaths,
|
||||||
|
integrations: integrations
|
||||||
|
} satisfies FrontEndGameTypeDetailedEmulator;
|
||||||
|
}))).filter(e => !!e);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
if (localGame)
|
|
||||||
{
|
|
||||||
const exists = await checkInstalled(localGame.path_fs);
|
|
||||||
const fileSize = await calculateSize(localGame.path_fs);
|
|
||||||
const game: FrontEndGameTypeDetailed = {
|
|
||||||
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
|
|
||||||
updated_at: localGame.created_at,
|
|
||||||
id: { id: 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
|
|
||||||
};
|
|
||||||
return game;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return sourceData;
|
||||||
}
|
} else
|
||||||
|
|
||||||
if (source === 'local')
|
|
||||||
{
|
{
|
||||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, id));
|
return status("Not Found");
|
||||||
if (localGame) return localGame;
|
|
||||||
return status('Not Found');
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
|
||||||
if (localGame) return localGame;
|
|
||||||
|
|
||||||
const rom = await getRomApiRomsIdGet({ path: { id } });
|
|
||||||
if (rom.data)
|
|
||||||
{
|
|
||||||
const romGame = convertRomToFrontendDetailed(rom.data);
|
|
||||||
return romGame;
|
|
||||||
}
|
|
||||||
|
|
||||||
return status("Not Found", rom.response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ source: z.string(), id: z.coerce.number() })
|
params: z.object({ source: z.string(), id: z.string() })
|
||||||
})
|
|
||||||
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
|
|
||||||
{
|
|
||||||
set.headers["content-type"] = 'text/event-stream';
|
|
||||||
set.headers["cache-control"] = 'no-cache';
|
|
||||||
set.headers['connection'] = 'keep-alive';
|
|
||||||
return buildStatusResponse(source, id);
|
|
||||||
}, {
|
|
||||||
response: z.any(),
|
|
||||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
|
||||||
query: z.object({ isLocal: z.boolean().optional() })
|
|
||||||
})
|
})
|
||||||
|
.use(buildStatusResponse())
|
||||||
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
|
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||||
{
|
{
|
||||||
const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs });
|
const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs });
|
||||||
|
|
@ -262,36 +452,108 @@ export default new Elysia()
|
||||||
|
|
||||||
return status(deleted.length > 0 ? 'OK' : 'Not Modified');
|
return status(deleted.length > 0 ? 'OK' : 'Not Modified');
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
params: z.object({ id: z.string(), source: z.string() }),
|
||||||
})
|
})
|
||||||
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
.post('/game/:source/:id/install', async ({ params: { id, source }, body }) =>
|
||||||
{
|
{
|
||||||
if (!taskQueue.hasActive())
|
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
|
||||||
{
|
{
|
||||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
|
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body));
|
||||||
return status(200);
|
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
return status('Not Implemented');
|
return status('Not Implemented');
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.coerce.number(), 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()
|
||||||
})
|
})
|
||||||
.post('/game/:source/:id/play', async ({ params: { id, source }, set }) =>
|
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||||
{
|
{
|
||||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
const job = taskQueue.findJob(InstallJob.query({ source, id }), InstallJob);
|
||||||
if (validCommand)
|
if (job)
|
||||||
{
|
{
|
||||||
if (validCommand instanceof Error)
|
job.abort('cancel');
|
||||||
|
return status('OK');
|
||||||
|
}
|
||||||
|
return status('Not Found');
|
||||||
|
}, {
|
||||||
|
params: z.object({ id: z.string(), source: z.string() }),
|
||||||
|
response: z.any()
|
||||||
|
})
|
||||||
|
.get('/game/:source/:id/validate', async ({ params: { id, source } }) =>
|
||||||
|
{
|
||||||
|
const valid = await validateGameSource(source, id);
|
||||||
|
return { valid: valid.valid, reason: valid.reason };
|
||||||
|
})
|
||||||
|
.post('/game/:source/:id/fix_source', async ({ params: { id, source } }) =>
|
||||||
|
{
|
||||||
|
return fixSource(source, id);
|
||||||
|
})
|
||||||
|
.post('/game/:source/:id/update', async ({ params: { id, source } }) =>
|
||||||
|
{
|
||||||
|
return update(source, id);
|
||||||
|
})
|
||||||
|
.post('/game/:source/:id/update', async ({ params: { id, source }, body }) =>
|
||||||
|
{
|
||||||
|
return customUpdate(source, id, body.source, body.id);
|
||||||
|
}, { body: z.object({ source: z.string(), id: z.string() }) })
|
||||||
|
.get('/lookup', async ({ query: { search } }) =>
|
||||||
|
{
|
||||||
|
const matches = new Map<string, GameLookup[]>();
|
||||||
|
await plugins.hooks.games.gameLookup.promise(matches, { search });
|
||||||
|
return { hadMatchers: matches.size > 0, matches: Array.from(matches.values()).flatMap(m => m) };
|
||||||
|
}, {
|
||||||
|
query: z.object({ search: z.string() })
|
||||||
|
})
|
||||||
|
.get('/lookup/:source/:id', async ({ params: { source, id } }) =>
|
||||||
|
{
|
||||||
|
const matches = new Map<string, GameLookup[]>();
|
||||||
|
await plugins.hooks.games.gameLookup.promise(matches, { source, id });
|
||||||
|
return Array.from(matches.values()).flatMap(m => m);
|
||||||
|
})
|
||||||
|
.get('/game/:source/:id/commands', async ({ params: { id, source }, set }) =>
|
||||||
|
{
|
||||||
|
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
||||||
|
if (validCommands instanceof Error)
|
||||||
|
{
|
||||||
|
return errorToResponse(validCommands, set);
|
||||||
|
}
|
||||||
|
return validCommands as {
|
||||||
|
commands: CommandEntry[];
|
||||||
|
gameId: FrontEndId;
|
||||||
|
source?: string;
|
||||||
|
sourceId?: string;
|
||||||
|
} | undefined;
|
||||||
|
}, {
|
||||||
|
response: z.object({
|
||||||
|
commands: z.custom<CommandEntry>().array()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post('/game/:source/:id/play', async ({ params: { id, source }, body: { command_id }, set }) =>
|
||||||
|
{
|
||||||
|
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
||||||
|
if (validCommands)
|
||||||
|
{
|
||||||
|
if (validCommands instanceof Error)
|
||||||
{
|
{
|
||||||
return errorToResponse(validCommand, set);
|
return errorToResponse(validCommands, set);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await launchCommand(validCommand.command.command, source, id, validCommand.gameId);
|
const validCommand = command_id ? validCommands.commands.find(c => c.id === command_id) : validCommands.commands[0];
|
||||||
|
if (validCommand)
|
||||||
|
{
|
||||||
|
// launch command waits for the game to exit, we don't want that.
|
||||||
|
await launchCommand(validCommand, validCommands.gameId, validCommands.source, validCommands.sourceId);
|
||||||
|
return { type: 'application', command: null };
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return status("Not Found");
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error)
|
} catch (error)
|
||||||
{
|
{
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
@ -300,5 +562,185 @@ export default new Elysia()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
params: z.object({ id: z.string(), source: z.string() }),
|
||||||
|
body: z.object({ command_id: z.number().or(z.string()).optional() }),
|
||||||
|
response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() })
|
||||||
|
})
|
||||||
|
.post("/stop", async ({ }) =>
|
||||||
|
{
|
||||||
|
const job = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
|
||||||
|
if (job)
|
||||||
|
{
|
||||||
|
job.abort('cancel');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get('/emulatorjs/data/cores/*', async ({ params }) =>
|
||||||
|
{
|
||||||
|
const res = await fetch(`https://cdn.emulatorjs.org/latest/data/cores/${params['*']}`);
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.get('/emulatorjs/data/*', async () =>
|
||||||
|
{
|
||||||
|
return status("Not Found");
|
||||||
|
})
|
||||||
|
.get('/recommended/games/emulator/:id', async ({ params: { id } }) =>
|
||||||
|
{
|
||||||
|
const emulator = await getStoreEmulatorPackage(id);
|
||||||
|
if (!emulator) return status("Not Found");
|
||||||
|
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||||
|
|
||||||
|
const games: FrontEndGameType[] = [];
|
||||||
|
|
||||||
|
let localGamesSet: Set<string> | undefined;
|
||||||
|
|
||||||
|
const localGames = await db.select({
|
||||||
|
...getTableColumns(schema.games),
|
||||||
|
platform: schema.platforms,
|
||||||
|
screenshotIds: sql<number[]>`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]),
|
||||||
|
})
|
||||||
|
.from(schema.games)
|
||||||
|
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
||||||
|
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
|
||||||
|
.groupBy(schema.games.id)
|
||||||
|
.where(inArray(schema.platforms.slug, systems.map(s => s.id)));
|
||||||
|
|
||||||
|
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
|
||||||
|
games.push(...localGames.map(g =>
|
||||||
|
{
|
||||||
|
return convertLocalToFrontend(g);
|
||||||
|
}).slice(0, 3));
|
||||||
|
|
||||||
|
const remoteGames: FrontEndGameType[] = [];
|
||||||
|
await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames });
|
||||||
|
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`)));
|
||||||
|
|
||||||
|
return games;
|
||||||
|
})
|
||||||
|
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
|
||||||
|
{
|
||||||
|
const sourceData = await getSourceGameDetailed(source, id);
|
||||||
|
if (!sourceData) return status("Not Found");
|
||||||
|
|
||||||
|
const sourceCompaniesSet = new Set(sourceData.metadata.companies);
|
||||||
|
const sourceGenresSet = new Set(sourceData.metadata.genres);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const games: (FrontEndGameType & { metadata?: any; })[] = [];
|
||||||
|
|
||||||
|
const localGames = await db.select({ ...getTableColumns(schema.games), platform: schema.platforms })
|
||||||
|
.from(schema.games)
|
||||||
|
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
||||||
|
.groupBy(schema.games.id);
|
||||||
|
|
||||||
|
const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`));
|
||||||
|
|
||||||
|
games.push(...localGames.map(g => convertLocalToFrontend(g)));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const remoteGames: (FrontEndGameType & { metadata?: any; })[] = [];
|
||||||
|
plugins.hooks.games.fetchRecommendedGamesForGame.promise({
|
||||||
|
game: sourceData, games: remoteGames
|
||||||
|
});
|
||||||
|
|
||||||
|
games.push(...remoteGames.filter(g => !localGamesSourceSet.has(`${g.id.source}@${g.id.id}`)));
|
||||||
|
|
||||||
|
const random = new SeededRandom(Math.round(new Date().getTime() / 1000 / 60 / 60));
|
||||||
|
|
||||||
|
const rankedGames = games.filter(g =>
|
||||||
|
{
|
||||||
|
if (sourceData.source && g.id.id === sourceData.source_id && g.id.source === sourceData.source)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g.id.id === sourceData.id.id && g.id.source === sourceData.id.source)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}).map(g =>
|
||||||
|
{
|
||||||
|
let rank = random.next();
|
||||||
|
|
||||||
|
if (g.platform_slug === sourceData.platform_slug)
|
||||||
|
rank += 1;
|
||||||
|
|
||||||
|
if (g.id.source === 'local')
|
||||||
|
rank -= 0.2;
|
||||||
|
|
||||||
|
if (g.metadata)
|
||||||
|
{
|
||||||
|
if (g.metadata.companies instanceof Array && g.metadata.companies.some((c: string) => sourceCompaniesSet.has(c)))
|
||||||
|
{
|
||||||
|
rank += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g.metadata.genres instanceof Array && g.metadata.genres.some((g: string) => sourceGenresSet.has(g)))
|
||||||
|
{
|
||||||
|
rank += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rank: rank, game: g };
|
||||||
|
});
|
||||||
|
|
||||||
|
rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank);
|
||||||
|
|
||||||
|
return rankedGames.map(g => g.game).slice(0, 10);
|
||||||
|
})
|
||||||
|
.post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) =>
|
||||||
|
{
|
||||||
|
if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running");
|
||||||
|
const data = await taskQueue.enqueue(ImportJob.query({ source, id }), new ImportJob(source, id, gamePath, platformId), {
|
||||||
|
throwOnCancel: true
|
||||||
|
|
||||||
|
});
|
||||||
|
return { source: 'local', id: data.localId };
|
||||||
|
}, {
|
||||||
|
body: z.object({
|
||||||
|
source: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
gamePath: z.string(),
|
||||||
|
platformId: z.number()
|
||||||
|
})
|
||||||
|
}).get('/downloads/lookup', async ({ query: { search, page, rows, orderBy, sortDirection, source } }) =>
|
||||||
|
{
|
||||||
|
const matches = new Map<string, { count: number, items: DownloadLookupEntry[]; }>();
|
||||||
|
await plugins.hooks.games.downloadsLookup.promise(matches, { search, page, rows, orderBy, sortDirection, source });
|
||||||
|
const allValues = Array.from(matches.values());
|
||||||
|
return { hadMatchers: matches.size > 0, matches: allValues.flatMap(m => m.items), totalCount: allValues.reduce((p, c) => p + c.count, 0) };
|
||||||
|
}, {
|
||||||
|
query: z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().optional(),
|
||||||
|
rows: z.coerce.number().optional(),
|
||||||
|
orderBy: z.string().optional(),
|
||||||
|
sortDirection: z.literal(["desc", "asc"]).optional(),
|
||||||
|
source: z.string().optional()
|
||||||
|
})
|
||||||
|
}).get('/download/lookup/:source/:id', async ({ params: { source, id } }) =>
|
||||||
|
{
|
||||||
|
const match = await plugins.hooks.games.downloadLookup.promise({ source, id });
|
||||||
|
if (!match) return status("Not Found");
|
||||||
|
return match;
|
||||||
|
}).get('/download/file/info', async ({ query: { file_url } }) =>
|
||||||
|
{
|
||||||
|
const response = await fetch(file_url, { method: "HEAD" });
|
||||||
|
if (!response.ok) return status('Internal Server Error', response.statusText);
|
||||||
|
return { size: Number(response.headers.get('content-length')), content_type: response.headers.get('content-type') };
|
||||||
|
}, {
|
||||||
|
query: z.object({ file_url: z.url() })
|
||||||
|
}).get('/download/lookup/filters', async () =>
|
||||||
|
{
|
||||||
|
const filters: DownloadsLookupFilterValues = {
|
||||||
|
source: [],
|
||||||
|
orderBy: []
|
||||||
|
};
|
||||||
|
|
||||||
|
await plugins.hooks.games.downloadsLookupFilters.promise({ filters });
|
||||||
|
|
||||||
|
return filters;
|
||||||
});
|
});
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet } from "@clients/romm";
|
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { count, eq, getTableColumns, notInArray } from "drizzle-orm";
|
import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm";
|
||||||
import { db } from "../app";
|
import { config, db, plugins } from "../app";
|
||||||
import { FrontEndPlatformType } from "@shared/constants";
|
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 () =>
|
||||||
{
|
{
|
||||||
const platforms: FrontEndPlatformType[] = [];
|
|
||||||
let rommPlatformsSet: Set<string> | undefined;
|
|
||||||
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
|
|
||||||
|
|
||||||
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
|
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
|
||||||
.from(schema.platforms)
|
.from(schema.platforms)
|
||||||
.leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id))
|
.leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id))
|
||||||
|
|
@ -20,30 +16,39 @@ export default new Elysia()
|
||||||
|
|
||||||
const localPlatformSet = new Set(localPlatforms.filter(p => p.game_count > 0).map(p => p.slug));
|
const localPlatformSet = new Set(localPlatforms.filter(p => p.game_count > 0).map(p => p.slug));
|
||||||
|
|
||||||
if (rommPlatforms)
|
const remotePlatforms: FrontEndPlatformType[] = [];
|
||||||
|
|
||||||
|
await plugins.hooks.games.fetchPlatforms.promise({ platforms: remotePlatforms });
|
||||||
|
|
||||||
|
await Promise.all(remotePlatforms.map(async p =>
|
||||||
{
|
{
|
||||||
const frontEndPlatforms = rommPlatforms.map(p =>
|
p.hasLocal = localPlatformSet.has(p.slug);
|
||||||
|
|
||||||
|
if (p.paths_screenshots.length <= 0)
|
||||||
{
|
{
|
||||||
const platform: FrontEndPlatformType = {
|
const localScreenshots = await db.select({ id: schema.screenshots.id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(eq(schema.platforms.slug, p.slug)).leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)).limit(1);
|
||||||
slug: p.slug,
|
|
||||||
name: p.display_name,
|
|
||||||
family_name: p.family_name,
|
|
||||||
path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`,
|
|
||||||
game_count: p.rom_count,
|
|
||||||
updated_at: new Date(p.updated_at),
|
|
||||||
id: { source: 'romm', id: p.id },
|
|
||||||
hasLocal: localPlatformSet.has(p.slug)
|
|
||||||
};
|
|
||||||
|
|
||||||
return platform;
|
if (localScreenshots)
|
||||||
});
|
p.paths_screenshots.push(...localScreenshots.map(s => `/api/romm/screenshot/${s.id}`));
|
||||||
|
}
|
||||||
|
|
||||||
rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug));
|
const localGames = await db.select({ id: schema.games.id, source: schema.games.source, souceId: schema.games.source_id }).from(schema.games).leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)).where(and(eq(schema.platforms.slug, p.slug), not(eq(schema.games.source, 'romm')))).groupBy(schema.games.id);
|
||||||
platforms.push(...frontEndPlatforms);
|
p.game_count += localGames.length;
|
||||||
}
|
}));
|
||||||
|
|
||||||
platforms.push(...localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(p =>
|
const platformSlugSet = new Set(remotePlatforms.map(p => p.slug));
|
||||||
|
|
||||||
|
const platforms: FrontEndPlatformType[] = [];
|
||||||
|
platforms.push(...remotePlatforms);
|
||||||
|
platforms.push(...await Promise.all(localPlatforms.filter(p => !platformSlugSet?.has(p.slug)).map(async p =>
|
||||||
{
|
{
|
||||||
|
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id) });
|
||||||
|
let screenshots: { id: number; }[] = [];
|
||||||
|
if (game)
|
||||||
|
{
|
||||||
|
screenshots = await db.query.screenshots.findMany({ where: eq(schema.screenshots.game_id, game.id), columns: { id: true } });
|
||||||
|
}
|
||||||
|
|
||||||
const platform: FrontEndPlatformType = {
|
const platform: FrontEndPlatformType = {
|
||||||
slug: p.slug,
|
slug: p.slug,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
|
@ -51,25 +56,51 @@ export default new Elysia()
|
||||||
path_cover: `/api/romm/platform/local/${p.id}/cover`,
|
path_cover: `/api/romm/platform/local/${p.id}/cover`,
|
||||||
game_count: p.game_count,
|
game_count: p.game_count,
|
||||||
updated_at: p.created_at,
|
updated_at: p.created_at,
|
||||||
id: { source: 'local', id: p.id },
|
id: { source: 'local', id: String(p.id) },
|
||||||
hasLocal: true
|
hasLocal: true,
|
||||||
|
paths_screenshots: screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return platform;
|
return platform;
|
||||||
}));
|
})));
|
||||||
|
|
||||||
return { platforms };
|
return { platforms };
|
||||||
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
|
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
|
||||||
{
|
{
|
||||||
const rommPlatform = await getPlatformApiPlatformsIdGet({ path: { id } });
|
if (source === 'local')
|
||||||
if (rommPlatform.data)
|
|
||||||
{
|
{
|
||||||
return rommPlatform.data;
|
const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) });
|
||||||
}
|
if (localPlatform)
|
||||||
|
{
|
||||||
|
const platform: FrontEndPlatformType = {
|
||||||
|
slug: localPlatform.slug,
|
||||||
|
name: localPlatform.name,
|
||||||
|
family_name: localPlatform.family_name,
|
||||||
|
path_cover: `/api/romm/platform/local/${localPlatform.id}/cover`,
|
||||||
|
game_count: 0,
|
||||||
|
updated_at: localPlatform.created_at,
|
||||||
|
id: { source: 'local', id: String(localPlatform.id) },
|
||||||
|
hasLocal: true,
|
||||||
|
paths_screenshots: []
|
||||||
|
};
|
||||||
|
|
||||||
return status("Not Found", rommPlatform.response);
|
return platform;
|
||||||
}, { params: z.object({ source: z.string(), id: z.coerce.number() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
|
}
|
||||||
|
|
||||||
|
return status("Not Found");
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
|
||||||
|
if (!remotePlatform) return status("Not Found");
|
||||||
|
const local = await db.query.platforms.findFirst({ where: or(eq(schema.platforms.slug, remotePlatform?.slug), eq(schema.platforms.name, remotePlatform?.name)) });
|
||||||
|
return { ...remotePlatform, hasLocal: !!local };
|
||||||
|
}
|
||||||
|
}, { params: z.object({ source: z.string(), id: z.string() }) })
|
||||||
|
.get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
|
||||||
{
|
{
|
||||||
|
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||||
|
|
||||||
const coverBlob = await db.query.platforms.findFirst({
|
const coverBlob = await db.query.platforms.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
cover: true, cover_type: true
|
cover: true, cover_type: true
|
||||||
|
|
@ -85,4 +116,70 @@ export default new Elysia()
|
||||||
set.headers["content-type"] = coverBlob.cover_type;
|
set.headers["content-type"] = coverBlob.cover_type;
|
||||||
}
|
}
|
||||||
return status(200, coverBlob.cover);
|
return status(200, coverBlob.cover);
|
||||||
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) });
|
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) })
|
||||||
|
.post('/platform/:source/:id/update', async ({ params: { source, id } }) =>
|
||||||
|
{
|
||||||
|
const where: any[] = [];
|
||||||
|
if (source === 'local')
|
||||||
|
{
|
||||||
|
where.push(eq(schema.platforms.id, Number(id)));
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id });
|
||||||
|
if (remotePlatform)
|
||||||
|
{
|
||||||
|
where.push(eq(schema.platforms.slug, remotePlatform.slug));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPlatform = await db.query.platforms.findFirst({
|
||||||
|
where: or(...where)
|
||||||
|
|
||||||
|
});
|
||||||
|
if (!localPlatform) return status("Not Found");
|
||||||
|
|
||||||
|
const platformLookup = await plugins.hooks.games.platformLookup.promise({
|
||||||
|
slug: localPlatform.slug
|
||||||
|
});
|
||||||
|
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${localPlatform.slug}.svg`);
|
||||||
|
if (!platformCover.ok && platformLookup?.url_logo)
|
||||||
|
{
|
||||||
|
platformCover = await fetch(platformLookup.url_logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(schema.platforms).set({
|
||||||
|
name: platformLookup?.name,
|
||||||
|
cover: Buffer.from(await platformCover.arrayBuffer()),
|
||||||
|
cover_type: platformCover.headers.get('content-type'),
|
||||||
|
}).where(eq(schema.platforms.id, localPlatform.id));
|
||||||
|
})
|
||||||
|
.delete('/platform/local/:id', async ({ params: { id } }) =>
|
||||||
|
{
|
||||||
|
const deleted = await db.delete(schema.platforms).where(and(eq(schema.platforms.id, Number(id)),
|
||||||
|
notExists(
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(schema.games)
|
||||||
|
.where(eq(schema.games.platform_id, Number(id)))
|
||||||
|
))).returning();
|
||||||
|
if (deleted.length <= 0) return status("Not Found");
|
||||||
|
})
|
||||||
|
.get('/platform/lookup/match/:source/:id', async ({ params: { source, id } }) =>
|
||||||
|
{
|
||||||
|
const platformLookup = await plugins.hooks.games.platformLookup.promise({ source, id });
|
||||||
|
if (!platformLookup) return status("Not Found");
|
||||||
|
const match = await findPlatform({
|
||||||
|
system_slug: platformLookup.slug,
|
||||||
|
platform: {
|
||||||
|
source_slug: platformLookup.slug,
|
||||||
|
source_id: Number(id),
|
||||||
|
source: source,
|
||||||
|
name: platformLookup.name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { details: platformLookup, match };
|
||||||
|
}, {
|
||||||
|
detail: {
|
||||||
|
description: "Find matches of remote platform lookups. Returns the operations for each platform if it were to be imported. If platform locally exists. Will a new local platform be created from say romm. Unknown is returned if no match is found."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,349 +1,72 @@
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { which } from 'bun';
|
import { Glob } from 'bun';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import * as schema from '../../schema/emulators';
|
import { config, taskQueue } from '../../app';
|
||||||
import * as appSchema from "../../schema/app";
|
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
||||||
import { eq } from 'drizzle-orm';
|
import { getStoreEmulatorPackage } from '../../store/services/gamesService';
|
||||||
import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app';
|
import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
|
||||||
import os from 'node:os';
|
import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared';
|
||||||
import { $ } from 'bun';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
|
||||||
|
|
||||||
export const varRegex = /%([^%]+)%/g;
|
export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string)
|
||||||
|
|
||||||
interface CommandEntry
|
|
||||||
{
|
{
|
||||||
label?: string;
|
if (taskQueue.hasActiveOfType(LaunchGameJob))
|
||||||
command: string;
|
{
|
||||||
valid: boolean;
|
throw new Error(`Game currently running`);
|
||||||
emulator?: string;
|
}
|
||||||
|
|
||||||
|
taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function launchCommand (validCommand: string, source: string, sourceId: number, id: number)
|
export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise<EmulatorSourceEntryType | undefined>
|
||||||
{
|
{
|
||||||
if (activeGame && activeGame.process?.killed === false)
|
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
||||||
|
const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name)));
|
||||||
|
if (storeExecName)
|
||||||
{
|
{
|
||||||
throw new Error(`${activeGame.name} currently running`);
|
return { binPath: path.join(storeEmulatorFolder, storeExecName), rootPath: storeEmulatorFolder, exists: true, type: "store" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const localGame = await db.query.games.findFirst({
|
const storeEmulator = await getStoreEmulatorPackage(id);
|
||||||
where: eq(appSchema.games.id, id), columns: {
|
if (storeEmulator?.downloads)
|
||||||
name: true,
|
|
||||||
source_id: true,
|
|
||||||
source: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) =>
|
|
||||||
{
|
{
|
||||||
const game = spawn(validCommand, {
|
const storeExecName = (await Promise.all(storeEmulator.downloads[`${process.platform}:${process.arch}`].map(async dl =>
|
||||||
shell: true
|
|
||||||
});
|
|
||||||
game.stdout.on('data', data => console.log(data));
|
|
||||||
game.on('close', (code) =>
|
|
||||||
{
|
{
|
||||||
events.emit('activegameexit', { source, id: sourceId, exitCode: code, signalCode: null });
|
// glob file search causes issues so do manual search
|
||||||
resolve(code);
|
if (await fs.exists(storeEmulatorFolder))
|
||||||
});
|
|
||||||
game.on('error', e =>
|
|
||||||
{
|
|
||||||
console.error(e);
|
|
||||||
events.emit('notification', { message: e.message, type: 'error' });
|
|
||||||
reject(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
setActiveGame({
|
|
||||||
process: game,
|
|
||||||
name: localGame?.name ?? "Unknown",
|
|
||||||
gameId: id,
|
|
||||||
command: validCommand
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateRommProps (id: number)
|
|
||||||
{
|
|
||||||
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
|
|
||||||
events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source === 'romm')
|
|
||||||
{
|
|
||||||
updateRommProps(sourceId);
|
|
||||||
}
|
|
||||||
else if (localGame?.source === 'romm' && localGame.source_id)
|
|
||||||
{
|
|
||||||
updateRommProps(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 });
|
const glob = (dl as any).pattern ? new Glob((dl as any).pattern) : undefined;
|
||||||
},
|
let bin: string | undefined = (dl as any).bin;
|
||||||
stdin: "ignore",
|
if (!bin && dl.type === 'scoop')
|
||||||
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');
|
|
||||||
}*/
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getValidLaunchCommands (data: {
|
|
||||||
systemSlug: string;
|
|
||||||
gamePath: string;
|
|
||||||
customEmulatorConfig: {
|
|
||||||
get: (id: string) => string | undefined,
|
|
||||||
has: (id: string) => boolean,
|
|
||||||
};
|
|
||||||
}): 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}'`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedCommands = await Promise.all(system.commands.map(async command =>
|
|
||||||
{
|
|
||||||
const label = command.label;
|
|
||||||
let cmd = command.command;
|
|
||||||
|
|
||||||
let emulator: string | undefined = undefined;
|
|
||||||
let rom = validFiles[0];
|
|
||||||
|
|
||||||
if (cmd.includes('%ESCAPESPECIALS%'))
|
|
||||||
rom = rom.replace(/[&()^=;,]/g, '');
|
|
||||||
|
|
||||||
const staticVars: Record<string, string> = {
|
|
||||||
'%ROM%': $.escape(rom),
|
|
||||||
'%ROMRAW%': validFiles[0],
|
|
||||||
'%ROMRAWWIN%': $.escape(validFiles[0].replace('/', '\\')),
|
|
||||||
'%ESPATH%': $.escape(path.dirname(Bun.main)),
|
|
||||||
'%ROMPATH%': $.escape(gamePath),
|
|
||||||
'%BASENAME%': $.escape(path.basename(validFiles[0], path.extname(validFiles[0]))),
|
|
||||||
'%FILENAME%': $.escape(path.basename(validFiles[0]))
|
|
||||||
};
|
|
||||||
|
|
||||||
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (subscring, injectFile: string) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
|
|
||||||
{
|
{
|
||||||
return staticVars[a] ?? a;
|
const data = await getOrCachedScoopPackage(id, dl.url);
|
||||||
});
|
|
||||||
if (existsSync(resolvedInjectFile))
|
if (data)
|
||||||
{
|
{
|
||||||
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
|
bin = data.bin;
|
||||||
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
const files = (await fs.readdir(storeEmulatorFolder))
|
||||||
} catch (error)
|
.filter(f =>
|
||||||
{
|
{
|
||||||
return '';
|
if (glob && glob.match(f)) return true;
|
||||||
|
if (bin && f === bin) return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return files.map(f => path.join(storeEmulatorFolder, f));
|
||||||
}
|
}
|
||||||
});
|
return [];
|
||||||
|
|
||||||
const matches = Array.from(cmd.matchAll(varRegex));
|
}))).flatMap(f => f);
|
||||||
const varList = await Promise.all(matches.map(async ([value]) =>
|
|
||||||
|
if (storeExecName.length > 0)
|
||||||
{
|
{
|
||||||
if (value.startsWith("%EMULATOR_"))
|
return { binPath: storeExecName[0], rootPath: storeEmulatorFolder, exists: true, type: 'store' };
|
||||||
{
|
}
|
||||||
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
}
|
||||||
let exec = await findExec(emulatorName);
|
|
||||||
if (data.customEmulatorConfig.has(emulatorName))
|
|
||||||
{
|
|
||||||
exec = data.customEmulatorConfig.get(emulatorName);
|
|
||||||
}
|
|
||||||
|
|
||||||
emulator = emulatorName;
|
|
||||||
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]];
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = value[0].substring(1, value.length - 1);
|
return undefined;
|
||||||
return [[value, process.env[key]]];
|
|
||||||
}));
|
|
||||||
|
|
||||||
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
|
|
||||||
vars['%ESCAPESPECIALS%'] = "";
|
|
||||||
vars['%HIDEWINDOW%'] = '';
|
|
||||||
|
|
||||||
// missing variable
|
|
||||||
const invalid = Object.entries(vars).find(c => c[1] === undefined);
|
|
||||||
|
|
||||||
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: label ?? undefined,
|
|
||||||
command: formattedCommand,
|
|
||||||
valid: !invalid, emulator
|
|
||||||
} satisfies CommandEntry;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return formattedCommands.filter(c => !!c);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findExec (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}`);
|
|
||||||
}
|
|
||||||
if (os.platform() === 'win32')
|
|
||||||
{
|
|
||||||
const regValues = emulator.winregistrypath;
|
|
||||||
if (regValues.length > 0)
|
|
||||||
{
|
|
||||||
for (const node of regValues)
|
|
||||||
{
|
|
||||||
const registryValue = await readRegistryValue(node);
|
|
||||||
if (registryValue)
|
|
||||||
{
|
|
||||||
return registryValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const systempaths = emulator.systempath;
|
|
||||||
if (systempaths.length > 0)
|
|
||||||
{
|
|
||||||
const systemPath = await resolveSystemPath(systempaths);
|
|
||||||
if (systemPath)
|
|
||||||
{
|
|
||||||
return systemPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const staticPaths = emulator.staticpath;
|
|
||||||
if (staticPaths.length > 0)
|
|
||||||
{
|
|
||||||
const staticPath = await resolveStaticPath(staticPaths);
|
|
||||||
if (staticPath)
|
|
||||||
{
|
|
||||||
return staticPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,15 +1,19 @@
|
||||||
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
|
import { config, db, plugins, taskQueue } from "../../app";
|
||||||
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
|
|
||||||
import { getValidLaunchCommands } from "./launchGameService";
|
|
||||||
import * as schema from '../../schema/app';
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
import { getErrorMessage } from "@/bun/utils";
|
||||||
import { getLocalGameMatch } from "./utils";
|
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
|
||||||
import { getRomApiRomsIdGet } from "@/clients/romm";
|
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { ErrorLike } from "elysia/universal";
|
import Elysia from "elysia";
|
||||||
|
import z from "zod";
|
||||||
|
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
||||||
|
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
||||||
|
import * as appSchema from "@schema/app";
|
||||||
|
import { RPC_URL } from "@/shared/constants";
|
||||||
|
import { DownloadSourceSchema } from '@simeonradivoev/gameflow-sdk/shared';
|
||||||
|
import { host } from "@/bun/utils/host";
|
||||||
|
import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
class CommandSearchError extends Error
|
export class CommandSearchError extends Error
|
||||||
{
|
{
|
||||||
constructor(status: GameStatusType, message: string)
|
constructor(status: GameStatusType, message: string)
|
||||||
{
|
{
|
||||||
|
|
@ -18,193 +22,449 @@ class CommandSearchError extends Error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLocalGame (source: string, id: number)
|
export async function getLocalGame (source: string, id: string)
|
||||||
{
|
{
|
||||||
const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug })
|
const localGame = await db.query.games.findFirst({
|
||||||
.from(schema.games)
|
columns: {
|
||||||
.where(getLocalGameMatch(id, source))
|
id: true,
|
||||||
.leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id));
|
path_fs: true,
|
||||||
|
source: true,
|
||||||
|
source_id: true,
|
||||||
|
igdb_id: true,
|
||||||
|
ra_id: true,
|
||||||
|
main_glob: true
|
||||||
|
},
|
||||||
|
where: getLocalGameMatch(id, source),
|
||||||
|
with: {
|
||||||
|
platform: { columns: { slug: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (localGames.length > 0)
|
return localGame;
|
||||||
{
|
|
||||||
return localGames[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getValidLaunchCommandsForGame (source: string, id: number)
|
/** Update local game's metadata from custom source, not the actual source of the game. Say from metadata providers like IGDB */
|
||||||
|
export async function customUpdate (source: string, id: string, destination: string, destinationId: string)
|
||||||
|
{
|
||||||
|
const localGame = await getLocalGame(source, id);
|
||||||
|
if (!localGame) throw new Error("Could not find Local Game");
|
||||||
|
|
||||||
|
const matchesMap = new Map<string, GameLookup[]>();
|
||||||
|
await plugins.hooks.games.gameLookup.promise(matchesMap, { source: destination, id: destinationId });
|
||||||
|
const matches = matchesMap.values().next().value;
|
||||||
|
if (!matches || matches?.length <= 0) throw new Error("Could not find destination");
|
||||||
|
const match = matches[0];
|
||||||
|
|
||||||
|
await db.transaction(async (tx) =>
|
||||||
|
{
|
||||||
|
await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id));
|
||||||
|
|
||||||
|
// pre-fetch screenshots
|
||||||
|
const screenshots = await Promise.all(match.screenshotUrls.map(s => fetch(s)));
|
||||||
|
|
||||||
|
if (screenshots.length > 0)
|
||||||
|
{
|
||||||
|
await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
||||||
|
{
|
||||||
|
const screenshot: typeof appSchema.screenshots.$inferInsert = {
|
||||||
|
game_id: localGame.id,
|
||||||
|
content: Buffer.from(await response.arrayBuffer()),
|
||||||
|
type: response.headers.get('content-type')
|
||||||
|
};
|
||||||
|
|
||||||
|
return screenshot;
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cover: Buffer<ArrayBuffer> | undefined = undefined;
|
||||||
|
if (match.coverUrl)
|
||||||
|
{
|
||||||
|
const coverResponse = await fetch(match.coverUrl);
|
||||||
|
if (coverResponse.ok)
|
||||||
|
{
|
||||||
|
cover = Buffer.from(await coverResponse.arrayBuffer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.update(appSchema.games).set({
|
||||||
|
cover,
|
||||||
|
metadata: {
|
||||||
|
age_ratings: match.age_ratings,
|
||||||
|
genres: match.genres,
|
||||||
|
player_count: match.player_count ?? undefined,
|
||||||
|
companies: match.companies,
|
||||||
|
game_modes: match.game_modes,
|
||||||
|
average_rating: match.average_rating ?? undefined,
|
||||||
|
first_release_date: match.first_release_date,
|
||||||
|
}
|
||||||
|
}).where(eq(appSchema.games.id, localGame.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update (source: string, id: string)
|
||||||
|
{
|
||||||
|
const localGame = await getLocalGame(source, id);
|
||||||
|
if (!localGame) throw new Error("Could not find Local Game");
|
||||||
|
if (!localGame.source || !localGame.source_id) throw new Error("Game has not source defined");
|
||||||
|
const sourceGame = await getSourceGameDetailed(localGame.source, localGame.source_id, { sourceOnly: true });
|
||||||
|
if (!sourceGame) throw new Error("Could not find source game");
|
||||||
|
|
||||||
|
await db.transaction(async (tx) =>
|
||||||
|
{
|
||||||
|
await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id));
|
||||||
|
|
||||||
|
const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)];
|
||||||
|
if (paths_screenshots.length <= 0 && sourceGame.igdb_id)
|
||||||
|
{
|
||||||
|
const matches = new Map<string, GameLookup[]>();
|
||||||
|
await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(sourceGame.igdb_id) });
|
||||||
|
if (matches.size > 0)
|
||||||
|
{
|
||||||
|
const firstMatches = matches.values().next().value;
|
||||||
|
if (firstMatches && firstMatches.length > 0)
|
||||||
|
{
|
||||||
|
paths_screenshots.push(...firstMatches[0].screenshotUrls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pre-fetch screenshots
|
||||||
|
const screenshots = await Promise.all(paths_screenshots.map(s => fetch(s)));
|
||||||
|
|
||||||
|
if (screenshots.length > 0)
|
||||||
|
{
|
||||||
|
await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
||||||
|
{
|
||||||
|
const screenshot: typeof appSchema.screenshots.$inferInsert = {
|
||||||
|
game_id: localGame.id,
|
||||||
|
content: Buffer.from(await response.arrayBuffer()),
|
||||||
|
type: response.headers.get('content-type')
|
||||||
|
};
|
||||||
|
|
||||||
|
return screenshot;
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.update(appSchema.games).set({
|
||||||
|
metadata: {
|
||||||
|
age_ratings: sourceGame.metadata.age_ratings,
|
||||||
|
genres: sourceGame.metadata.genres,
|
||||||
|
player_count: sourceGame.metadata.player_count ?? undefined,
|
||||||
|
companies: sourceGame.metadata.companies,
|
||||||
|
game_modes: sourceGame.metadata.game_modes,
|
||||||
|
average_rating: sourceGame.metadata.average_rating ?? undefined,
|
||||||
|
first_release_date: sourceGame.metadata.first_release_date?.getTime() ?? undefined,
|
||||||
|
}
|
||||||
|
}).where(eq(appSchema.games.id, localGame.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fixSource (source: string, id: string)
|
||||||
|
{
|
||||||
|
const valid = await validateGameSource(source, id);
|
||||||
|
if (!valid.valid)
|
||||||
|
{
|
||||||
|
if (!valid.localGame) throw new Error("No Local Game");
|
||||||
|
if (!valid.localGame.source) throw new Error("No Valid Source");
|
||||||
|
|
||||||
|
const foundGame = await plugins.hooks.games.searchGame.promise({
|
||||||
|
igdb_id: valid.localGame.igdb_id ?? undefined,
|
||||||
|
ra_id: valid.localGame.ra_id ?? undefined,
|
||||||
|
source: valid.localGame.source
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundGame)
|
||||||
|
{
|
||||||
|
await db.update(appSchema.games).set({
|
||||||
|
source: foundGame.id.source,
|
||||||
|
source_id: foundGame.id.id,
|
||||||
|
metadata: {
|
||||||
|
age_ratings: foundGame.metadata.age_ratings,
|
||||||
|
genres: foundGame.metadata.genres,
|
||||||
|
player_count: foundGame.metadata.player_count ?? undefined,
|
||||||
|
companies: foundGame.metadata.companies,
|
||||||
|
game_modes: foundGame.metadata.game_modes,
|
||||||
|
average_rating: foundGame.metadata.average_rating ?? undefined,
|
||||||
|
first_release_date: foundGame.metadata.first_release_date?.getTime() ?? undefined,
|
||||||
|
}
|
||||||
|
}).where(eq(appSchema.games.id, valid.localGame.id));
|
||||||
|
return true;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
throw new Error("Could not find Source Game");
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
throw new Error("Game Source Already Valid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateGameSource (source: string, id: string): Promise<{
|
||||||
|
valid: boolean,
|
||||||
|
localGame?: { id: number; igdb_id: number | null; ra_id: number | null; source: string | null; },
|
||||||
|
reason?: string;
|
||||||
|
}>
|
||||||
|
{
|
||||||
|
const localGame = await getLocalGame(source, id);
|
||||||
|
if (!localGame) return { valid: true };
|
||||||
|
if (localGame.source && localGame.source_id)
|
||||||
|
{
|
||||||
|
const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id });
|
||||||
|
if (!sourceGame) return { valid: false, reason: "Source Missing", localGame };
|
||||||
|
// Store should be immutable
|
||||||
|
if (localGame.source !== 'store' && sourceGame.igdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined))
|
||||||
|
{
|
||||||
|
return { valid: false, reason: "Metadata Missmatch", localGame };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, localGame };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLocalLastPlayed (id: number)
|
||||||
|
{
|
||||||
|
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined>
|
||||||
{
|
{
|
||||||
const localGame = await getLocalGame(source, id);
|
const localGame = await getLocalGame(source, id);
|
||||||
if (localGame)
|
if (localGame)
|
||||||
{
|
{
|
||||||
if (localGame.platform_slug)
|
const commands = await plugins.hooks.games.buildLaunchCommands.promise({
|
||||||
|
source: localGame.source,
|
||||||
|
sourceId: localGame.source_id,
|
||||||
|
id: { source: 'local', id: String(localGame.id) },
|
||||||
|
systemSlug: localGame.platform.slug,
|
||||||
|
gamePath: localGame.path_fs,
|
||||||
|
mainGlob: localGame.main_glob,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (commands instanceof Error || !commands) return commands;
|
||||||
|
|
||||||
|
const validCommand = commands.find(c => c.valid);
|
||||||
|
if (validCommand)
|
||||||
{
|
{
|
||||||
if (localGame.path_fs)
|
return {
|
||||||
{
|
commands: commands.filter(c => c.valid),
|
||||||
try
|
gameId: { id: String(localGame.id), source: 'local' },
|
||||||
{
|
source: localGame.source ?? source,
|
||||||
const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
|
sourceId: localGame.source_id ? String(localGame.source_id) : id,
|
||||||
const validCommand = commands.find(c => c.valid);
|
};
|
||||||
if (validCommand)
|
|
||||||
{
|
|
||||||
return { command: validCommand, gameId: localGame.id, source: source, sourceId: id };
|
|
||||||
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator).join(',')}`);
|
|
||||||
}
|
|
||||||
} catch (error)
|
|
||||||
{
|
|
||||||
console.error(error);
|
|
||||||
return new CommandSearchError('error', getErrorMessage(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
return new CommandSearchError('error', 'Missing Path');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return new CommandSearchError('error', 'Missing Platform');
|
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
|
||||||
}
|
}
|
||||||
|
} else if (source === 'emulator')
|
||||||
|
{
|
||||||
|
const commands = await plugins.hooks.games.buildLaunchCommands.promise({
|
||||||
|
source,
|
||||||
|
sourceId: id,
|
||||||
|
id: { source: source, id: id },
|
||||||
|
systemSlug: "",
|
||||||
|
gamePath: null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (commands instanceof Error || !commands) return commands;
|
||||||
|
|
||||||
|
const validCommand = commands.find(c => c.valid);
|
||||||
|
if (validCommand)
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
commands: commands.filter(c => c.valid),
|
||||||
|
gameId: { id, source }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function buildStatusResponse (source: string, id: number)
|
export default function buildStatusResponse ()
|
||||||
{
|
{
|
||||||
let cleanup: (() => void) | undefined;
|
return new Elysia().ws('/status/:source/:id', {
|
||||||
let closed = false;
|
response: z.discriminatedUnion('status', [
|
||||||
return new Response(new ReadableStream({
|
z.object({ status: z.literal('error'), error: z.unknown() }),
|
||||||
async start (controller)
|
z.object({ status: z.literal('installed'), commands: z.array(z.any()), details: z.string().optional() }),
|
||||||
|
z.object({ status: z.literal('refresh'), localId: z.number().optional() }),
|
||||||
|
z.object({ status: z.literal(['queued']) }),
|
||||||
|
z.object({ status: z.literal('playing'), details: z.string() }),
|
||||||
|
z.object({ status: z.literal('install'), details: z.string(), sources: DownloadSourceSchema.array() }),
|
||||||
|
z.object({ status: z.literal('present'), details: z.string() }),
|
||||||
|
z.object({ status: z.literal(['download', 'extract']), progress: z.number() }),
|
||||||
|
]),
|
||||||
|
message (ws, data)
|
||||||
{
|
{
|
||||||
const encoder = new TextEncoder();
|
if (data === 'cancel')
|
||||||
|
|
||||||
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
|
|
||||||
{
|
{
|
||||||
const evntString = event ? `event: ${event}\n` : '';
|
const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob);
|
||||||
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
activeTask?.abort('cancel');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
await sendLatests();
|
async open (ws)
|
||||||
|
{
|
||||||
// seems to help with issue of buffers not flushing, keeping the connection open forcefully
|
sendLatests().catch(e => ws.send({ status: 'error', error: JSON.stringify(e) }));
|
||||||
const keepAlive = setInterval(() =>
|
const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id });
|
||||||
{
|
|
||||||
if (closed) return clearInterval(keepAlive);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
enqueue({}, 'ping');
|
|
||||||
} catch
|
|
||||||
{
|
|
||||||
closed = true;
|
|
||||||
clearInterval(keepAlive);
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const sourceId = `${source}-${id}`;
|
|
||||||
|
|
||||||
async function sendLatests ()
|
async function sendLatests ()
|
||||||
{
|
{
|
||||||
if (closed) return;
|
if (ws.readyState > 1) return;
|
||||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
|
const activeTask = taskQueue.findJob(InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }), InstallJob);
|
||||||
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
|
|
||||||
if (activeTask)
|
if (activeTask)
|
||||||
{
|
{
|
||||||
enqueue({
|
if (activeTask.status === 'queued')
|
||||||
progress: activeTask.progress,
|
{
|
||||||
status: activeTask.state as any
|
ws.send({ status: 'queued' });
|
||||||
});
|
} else
|
||||||
|
{
|
||||||
|
ws.send({ status: activeTask.state as InstallJobStates, progress: activeTask.progress });
|
||||||
|
}
|
||||||
|
|
||||||
} else if (activeGame && activeGame.gameId === localGame?.id)
|
} else if (taskQueue.hasActiveOfType(LaunchGameJob))
|
||||||
{
|
{
|
||||||
enqueue({ status: 'playing' as GameStatusType, details: 'Playing' });
|
ws.send({ status: 'playing', details: 'Playing' });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source) });
|
||||||
|
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
|
||||||
if (validCommand)
|
if (validCommand)
|
||||||
{
|
{
|
||||||
if (validCommand instanceof Error)
|
if (validCommand instanceof Error)
|
||||||
{
|
{
|
||||||
enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message });
|
ws.send({ status: 'error', error: validCommand.message });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
enqueue({ status: 'installed', details: validCommand.command.label });
|
ws.send({
|
||||||
|
status: 'installed',
|
||||||
|
details: validCommand.commands[0].label,
|
||||||
|
commands: validCommand.commands
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (source === 'romm')
|
} else if (!localGame && ws.data.params.source === 'store')
|
||||||
{
|
{
|
||||||
// TODO: Add Caching
|
const downloads = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id });
|
||||||
const remoteGame = await getRomApiRomsIdGet({ path: { id } });
|
const sources = downloads?.map(d => ({ id: d.id, name: d.id })) ?? [];
|
||||||
|
/*const storeGame = await getStoreGame(ws.data.params.id);
|
||||||
|
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||||
|
const size = Number(fileResponse.headers.get('content-length'));
|
||||||
const stats = await fs.statfs(config.get('downloadPath'));
|
const stats = await fs.statfs(config.get('downloadPath'));
|
||||||
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
|
|
||||||
|
if (size > stats.bsize * stats.bavail)
|
||||||
{
|
{
|
||||||
enqueue({ status: 'error', error: "Not Enough Free Space" });
|
ws.send({ status: 'error', error: "Not Enough Free Space" });
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
enqueue({ status: 'install', details: 'Install' });
|
ws.send({ status: 'install', details: 'Install' });
|
||||||
|
}*/
|
||||||
|
|
||||||
|
ws.send({ status: 'install', details: 'Install', sources });
|
||||||
|
} else if (!localGame)
|
||||||
|
{
|
||||||
|
const files = await plugins.hooks.games.fetchDownloads.promise({
|
||||||
|
source: ws.data.params.source,
|
||||||
|
id: ws.data.params.id
|
||||||
|
});
|
||||||
|
const sources = files?.map(d => ({ id: d.id, name: d.id })) ?? [];
|
||||||
|
|
||||||
|
let filesChecked: LocalDownloadFileEntry[] | undefined;
|
||||||
|
|
||||||
|
if (files && files.length)
|
||||||
|
{
|
||||||
|
filesChecked = await checkFiles(files[0].files, !!files[0].extract_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false))
|
||||||
|
{
|
||||||
|
ws.send({ status: 'present', details: "Files Exist On Disk, Import" });
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const size = filesChecked?.filter(f => f.exists !== true || f.matches !== true).reduce((p, f) => p += f.size ?? 0, 0);
|
||||||
|
const stats = await fs.statfs(config.get('downloadPath'));
|
||||||
|
if (size && size > stats.bsize * stats.bavail)
|
||||||
|
{
|
||||||
|
ws.send({ status: 'error', error: "Not Enough Free Space" });
|
||||||
|
} else if (filesChecked?.some(f => f.exists === true && f.matches === false))
|
||||||
|
{
|
||||||
|
ws.send({ status: 'install', details: 'Some Files Present, Install', sources });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ws.send({ status: 'install', details: 'Install', sources });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
ws.send({ status: 'error', error: "No Way To Launch" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispose: Function[] = [];
|
const dispose: Function[] = [];
|
||||||
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
|
const handleActiveExit = async (data: { error?: unknown; }) =>
|
||||||
{
|
{
|
||||||
if (data.error)
|
if (data.error)
|
||||||
{
|
{
|
||||||
enqueue({
|
ws.send({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: data.error
|
error: data.error
|
||||||
}, 'error');
|
});
|
||||||
}
|
}
|
||||||
await sendLatests();
|
await sendLatests();
|
||||||
};
|
};
|
||||||
events.on('activegameexit', handleActiveExit);
|
dispose.push(taskQueue.on('progress', (data) =>
|
||||||
dispose.push(() => events.off('activegameexit', handleActiveExit));
|
|
||||||
dispose.push(taskQueue.on('progress', ({ id, progress, state }) =>
|
|
||||||
{
|
{
|
||||||
if (id.endsWith(sourceId))
|
if (data.id === installJobId)
|
||||||
{
|
{
|
||||||
enqueue({ progress, status: state as any });
|
ws.send({ status: data.job.state as InstallJobStates, progress: data.progress });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
dispose.push(taskQueue.on('completed', ({ id }) =>
|
dispose.push(taskQueue.on('queued', (data) =>
|
||||||
{
|
{
|
||||||
if (id.endsWith(sourceId))
|
if (data.id === installJobId)
|
||||||
{
|
{
|
||||||
enqueue({}, 'refresh');
|
ws.send({ status: 'queued' });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
dispose.push(taskQueue.on('error', ({ id, error }) =>
|
dispose.push(taskQueue.on('ended', (data) =>
|
||||||
{
|
{
|
||||||
if (id.endsWith(sourceId))
|
if (data.id === installJobId)
|
||||||
{
|
{
|
||||||
enqueue({
|
ws.send({ status: 'refresh', localId: (data.job.job as InstallJob).localGameId });
|
||||||
|
} else if (data.job.job instanceof LaunchGameJob)
|
||||||
|
{
|
||||||
|
handleActiveExit({});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
dispose.push(taskQueue.on('error', (data) =>
|
||||||
|
{
|
||||||
|
if (data.id === installJobId)
|
||||||
|
{
|
||||||
|
ws.send({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: error
|
error: getErrorMessage(data.error)
|
||||||
}, 'error');
|
});
|
||||||
|
} else if (data.job.job instanceof LaunchGameJob)
|
||||||
|
{
|
||||||
|
handleActiveExit({ error: data.error });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
cleanup = () =>
|
(ws.data as any).cleanup = () =>
|
||||||
{
|
{
|
||||||
closed = true;
|
|
||||||
dispose.forEach(f => f());
|
dispose.forEach(f => f());
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
cancel (reason)
|
close (ws, code, reason)
|
||||||
{
|
{
|
||||||
cleanup?.();
|
(ws.data as any).cleanup?.();
|
||||||
cleanup = undefined;
|
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1,65 +1,506 @@
|
||||||
import getFolderSize from "get-folder-size";
|
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 } from "../../app";
|
import { config, db, emulatorsDb, plugins } from "../../app";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, or } from "drizzle-orm";
|
||||||
import * as schema from "../../schema/app";
|
import * as schema from "@schema/app";
|
||||||
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
|
import { RPC_URL } from "@shared/constants";
|
||||||
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
|
import { hashFile } from "@/bun/utils";
|
||||||
|
import { host } from "@/bun/utils/host";
|
||||||
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
|
import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
export async function calculateSize (installPath: string | null)
|
export async function calculateSize (installPath: string | null)
|
||||||
{
|
{
|
||||||
if (!installPath) return null;
|
if (!installPath) return null;
|
||||||
return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size;
|
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
|
||||||
|
return (await getFolderSize(finalPath)).size;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkInstalled (installPath: string | null)
|
export async function checkInstalled (installPath: string | null)
|
||||||
{
|
{
|
||||||
if (!installPath) return false;
|
if (!installPath) return false;
|
||||||
return fs.exists(path.join(config.get('downloadPath'), installPath));
|
const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath);
|
||||||
|
return fs.exists(finalPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalGameMatch (id: number, source: string)
|
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, id);
|
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
export function getLocalGameMatch (id: string, source: string)
|
||||||
|
{
|
||||||
|
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
|
||||||
|
platform?: typeof schema.platforms.$inferSelect | null;
|
||||||
|
screenshotIds?: number[];
|
||||||
|
})
|
||||||
{
|
{
|
||||||
const game: FrontEndGameType = {
|
const game: FrontEndGameType = {
|
||||||
id: { id: rom.id, source: 'romm' },
|
platform_display_name: g.platform?.name ?? null,
|
||||||
path_cover: `/api/romm/image/romm${rom.path_cover_large}`,
|
id: { id: String(g.id), source: 'local' },
|
||||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
updated_at: g.created_at,
|
||||||
updated_at: new Date(rom.updated_at),
|
path_covers: [`/api/romm/game/local/${g.id}/cover`],
|
||||||
slug: rom.slug,
|
source_id: g.source_id,
|
||||||
platform_id: rom.platform_id,
|
source: g.source,
|
||||||
platform_display_name: rom.platform_display_name,
|
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
||||||
name: rom.name,
|
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||||
path_fs: null,
|
path_fs: g.path_fs,
|
||||||
path_platform_cover: `/api/romm/image/romm/assets/platforms/${rom.platform_slug}.svg`,
|
last_played: g.last_played,
|
||||||
source: null,
|
slug: g.slug,
|
||||||
source_id: null,
|
name: g.name,
|
||||||
paths_screenshots: rom.merged_screenshots.map(s => `/api/romm/image/romm/${s}`),
|
platform_id: g.platform_id,
|
||||||
|
platform_slug: g.platform?.slug ?? null,
|
||||||
|
metadata: {
|
||||||
|
first_release_date: g.metadata?.first_release_date !== undefined ? new Date(g.metadata?.first_release_date) : null
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
|
export async function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
|
||||||
|
platform?: { name: string | null, slug: string | null; } | null;
|
||||||
|
screenshotIds?: number[];
|
||||||
|
})
|
||||||
{
|
{
|
||||||
const detailed: FrontEndGameTypeDetailed = {
|
|
||||||
...convertRomToFrontend(rom),
|
const exists = await checkInstalled(g.path_fs);
|
||||||
summary: rom.summary,
|
const fileSize = await calculateSize(g.path_fs);
|
||||||
fs_size_bytes: rom.fs_size_bytes,
|
|
||||||
local: false,
|
const game: FrontEndGameTypeDetailed = {
|
||||||
missing: rom.missing_from_fs
|
platform_display_name: g.platform?.name ?? "Local",
|
||||||
|
id: { id: String(g.id), source: 'local' },
|
||||||
|
updated_at: g.created_at,
|
||||||
|
path_covers: [`/api/romm/game/local/${g.id}/cover`],
|
||||||
|
source_id: g.source_id,
|
||||||
|
source: g.source,
|
||||||
|
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
||||||
|
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||||
|
path_fs: g.path_fs,
|
||||||
|
last_played: g.last_played,
|
||||||
|
slug: g.slug,
|
||||||
|
name: g.name,
|
||||||
|
platform_id: g.platform_id,
|
||||||
|
platform_slug: g.platform?.slug ?? null,
|
||||||
|
summary: g.summary,
|
||||||
|
fs_size_bytes: fileSize,
|
||||||
|
missing: !exists,
|
||||||
|
local: true,
|
||||||
|
ra_id: g.ra_id,
|
||||||
|
version: g.version,
|
||||||
|
version_source: g.version_source,
|
||||||
|
version_system: g.version_system,
|
||||||
|
igdb_id: g.igdb_id,
|
||||||
|
metadata: {
|
||||||
|
genres: g.metadata.genres ?? [],
|
||||||
|
companies: g.metadata.companies ?? [],
|
||||||
|
game_modes: g.metadata.game_modes ?? [],
|
||||||
|
age_ratings: g.metadata.age_ratings ?? [],
|
||||||
|
player_count: g.metadata.player_count ?? null,
|
||||||
|
average_rating: g.metadata.average_rating ?? null,
|
||||||
|
first_release_date: g.metadata.first_release_date ? new Date(g.metadata.first_release_date) : null
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (rom.merged_ra_metadata?.achievements)
|
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocalGameDetailed (match: any)
|
||||||
|
{
|
||||||
|
const localGame = await db.query.games.findFirst({
|
||||||
|
where: match,
|
||||||
|
with: {
|
||||||
|
screenshots: { columns: { id: true } },
|
||||||
|
platform: { columns: { name: true, slug: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (localGame)
|
||||||
{
|
{
|
||||||
detailed.achievements = {
|
return convertLocalToFrontendDetailed({ ...localGame, screenshotIds: localGame.screenshots.map(s => s.id) });
|
||||||
unlocked: rom.merged_ra_metadata.achievements?.map(a => a.num_awarded).length,
|
}
|
||||||
total: rom.merged_ra_metadata.achievements.length
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSourceGameDetailed (source: string, id: string, options?: { sourceOnly?: boolean; })
|
||||||
|
{
|
||||||
|
if (source === 'local')
|
||||||
|
{
|
||||||
|
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
|
||||||
|
if (localGame) return localGame;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
||||||
|
|
||||||
|
const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame });
|
||||||
|
if (localGame && options?.sourceOnly !== true)
|
||||||
|
{
|
||||||
|
return localGame;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remoteGame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean): Promise<LocalDownloadFileEntry[]>
|
||||||
|
{
|
||||||
|
return Promise.all(files.map(async f =>
|
||||||
|
{
|
||||||
|
// file is either zip or doesn't support sha checking
|
||||||
|
if (!f.sha1 || isArchive) return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry;
|
||||||
|
const localPath = path.join(config.get('downloadPath'), f.file_path, f.file_name);
|
||||||
|
if (await fs.exists(localPath))
|
||||||
|
{
|
||||||
|
if (f.size && f.size !== (await fs.stat(localPath)).size)
|
||||||
|
{
|
||||||
|
return { ...f, exists: true, matches: false } satisfies LocalDownloadFileEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingHash = await hashFile(localPath, 'sha1');
|
||||||
|
if (existingHash === f.sha1)
|
||||||
|
{
|
||||||
|
return { ...f, exists: true, matches: true } satisfies LocalDownloadFileEntry;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return { ...f, exists: true, matches: false } satisfies LocalDownloadFileEntry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findPlatform (info: {
|
||||||
|
system_slug: string; platform: {
|
||||||
|
igdb_id?: number;
|
||||||
|
igdb_slug?: string;
|
||||||
|
ra_id?: number;
|
||||||
|
moby_id?: number;
|
||||||
|
source: string;
|
||||||
|
source_id?: number;
|
||||||
|
source_slug?: string;
|
||||||
|
family_name?: string;
|
||||||
|
name?: string;
|
||||||
|
} | undefined;
|
||||||
|
}):
|
||||||
|
Promise<{
|
||||||
|
type: string | null;
|
||||||
|
slug?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
family_name?: string | null;
|
||||||
|
es_slug?: string | null;
|
||||||
|
coverUrl?: string | null;
|
||||||
|
}>
|
||||||
|
{
|
||||||
|
// Search for existing platform
|
||||||
|
const platformSearch = [eq(schema.platforms.slug, info.system_slug)];
|
||||||
|
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)];
|
||||||
|
|
||||||
|
if (info.platform)
|
||||||
|
{
|
||||||
|
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
|
||||||
|
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
|
||||||
|
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
|
||||||
|
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
|
||||||
|
|
||||||
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source));
|
||||||
|
if (info.platform.source_slug)
|
||||||
|
{
|
||||||
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug));
|
||||||
|
} else if (info.platform.source_id)
|
||||||
|
{
|
||||||
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id));
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
throw new Error("Must Provide at least one source id or slug");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
|
||||||
|
with: { system: true },
|
||||||
|
where: and(...esPlatformSearch)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (esPlatform)
|
||||||
|
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
|
||||||
|
|
||||||
|
let existingPlatform = await db.query.platforms.findFirst({ where: or(...platformSearch) });
|
||||||
|
|
||||||
|
if (!existingPlatform)
|
||||||
|
{
|
||||||
|
// TODO: use something else than the romm demo as CDN
|
||||||
|
|
||||||
|
const platformLookup = await plugins.hooks.games.platformLookup.promise({
|
||||||
|
slug: info.platform?.source_slug ?? info.system_slug
|
||||||
|
});
|
||||||
|
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`, { method: "HEAD" });
|
||||||
|
if (!platformCover.ok && platformLookup?.url_logo)
|
||||||
|
{
|
||||||
|
platformCover = await fetch(platformLookup.url_logo, { method: "HEAD" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!esPlatform && !info.platform)
|
||||||
|
{
|
||||||
|
// go to unknown platform
|
||||||
|
existingPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
|
||||||
|
|
||||||
|
if (existingPlatform)
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
type: "existing",
|
||||||
|
slug: existingPlatform.slug,
|
||||||
|
name: existingPlatform.name,
|
||||||
|
family_name: existingPlatform.family_name,
|
||||||
|
es_slug: existingPlatform.es_slug,
|
||||||
|
coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover`
|
||||||
|
};
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return { type: "unknown" };
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
type: "new",
|
||||||
|
slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '',
|
||||||
|
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
|
||||||
|
family_name: info.platform?.family_name,
|
||||||
|
es_slug: esPlatform?.system.name ?? undefined,
|
||||||
|
coverUrl: platformCover.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
type: "existing",
|
||||||
|
slug: existingPlatform.slug,
|
||||||
|
name: existingPlatform.name,
|
||||||
|
family_name: existingPlatform.family_name,
|
||||||
|
es_slug: existingPlatform.es_slug,
|
||||||
|
coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return detailed;
|
}
|
||||||
|
|
||||||
|
export async function createLocalGame (info: {
|
||||||
|
name: string;
|
||||||
|
system_slug: string | undefined;
|
||||||
|
source: string | undefined;
|
||||||
|
source_id: string | undefined;
|
||||||
|
slug: string | null | undefined;
|
||||||
|
path_fs: string | null | undefined;
|
||||||
|
summary: string | null | undefined;
|
||||||
|
igdb_id: number | undefined;
|
||||||
|
ra_id: number | undefined;
|
||||||
|
main_glob: string | undefined;
|
||||||
|
cover: Buffer<ArrayBufferLike> | undefined;
|
||||||
|
coverType: string | null | undefined;
|
||||||
|
version: string | undefined;
|
||||||
|
version_source: string | undefined;
|
||||||
|
screenshotUrls: string[];
|
||||||
|
version_system: string | undefined;
|
||||||
|
last_played?: Date;
|
||||||
|
metadata: LocalGameMetadata | undefined,
|
||||||
|
platform: {
|
||||||
|
igdb_id?: number;
|
||||||
|
igdb_slug?: string;
|
||||||
|
ra_id?: number;
|
||||||
|
moby_id?: number;
|
||||||
|
source: string;
|
||||||
|
source_id?: number;
|
||||||
|
source_slug?: string;
|
||||||
|
family_name?: string;
|
||||||
|
name?: string;
|
||||||
|
} | undefined;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const id = await db.transaction(async (tx) =>
|
||||||
|
{
|
||||||
|
// Search for existing platform
|
||||||
|
const platformSearch = [];
|
||||||
|
const esPlatformSearch = [];
|
||||||
|
if (info.system_slug)
|
||||||
|
{
|
||||||
|
platformSearch.push(eq(schema.platforms.slug, info.system_slug));
|
||||||
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.system, info.system_slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.platform)
|
||||||
|
{
|
||||||
|
if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id));
|
||||||
|
if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug));
|
||||||
|
if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id));
|
||||||
|
if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id));
|
||||||
|
|
||||||
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source));
|
||||||
|
if (info.platform.source_slug)
|
||||||
|
{
|
||||||
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug));
|
||||||
|
} else if (info.platform.source_id)
|
||||||
|
{
|
||||||
|
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id));
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
throw new Error("Must Provide at least one source id or slug");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
|
||||||
|
with: { system: true },
|
||||||
|
where: and(...esPlatformSearch)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (esPlatform)
|
||||||
|
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
|
||||||
|
|
||||||
|
let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
|
||||||
|
let platformId: number;
|
||||||
|
if (!existingPlatform)
|
||||||
|
{
|
||||||
|
// TODO: use something else than the romm demo as CDN
|
||||||
|
|
||||||
|
const platformLookup = await plugins.hooks.games.platformLookup.promise({
|
||||||
|
slug: info.platform?.source_slug ?? info.system_slug
|
||||||
|
});
|
||||||
|
let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`);
|
||||||
|
if (!platformCover.ok && platformLookup?.url_logo)
|
||||||
|
{
|
||||||
|
platformCover = await fetch(platformLookup.url_logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!esPlatform && !info.platform)
|
||||||
|
{
|
||||||
|
// go to unknown platform
|
||||||
|
existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
|
||||||
|
|
||||||
|
if (existingPlatform)
|
||||||
|
{
|
||||||
|
platformId = existingPlatform.id;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const [{ id }] = await tx.insert(schema.platforms).values({
|
||||||
|
slug: 'unknown',
|
||||||
|
name: "Unknown"
|
||||||
|
}).returning({ id: schema.platforms.id });
|
||||||
|
platformId = id;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
// Create new local platform
|
||||||
|
const platform: typeof schema.platforms.$inferInsert = {
|
||||||
|
slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '',
|
||||||
|
igdb_id: info.platform?.igdb_id,
|
||||||
|
igdb_slug: info.platform?.igdb_slug,
|
||||||
|
ra_id: info.platform?.ra_id,
|
||||||
|
cover: Buffer.from(await platformCover.arrayBuffer()),
|
||||||
|
cover_type: platformCover.headers.get('content-type'),
|
||||||
|
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
|
||||||
|
family_name: info.platform?.family_name,
|
||||||
|
es_slug: esPlatform?.system.name ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: add ES slug once I have better way to query ES
|
||||||
|
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
|
||||||
|
platformId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
platformId = existingPlatform.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create the rom
|
||||||
|
const game: typeof schema.games.$inferInsert = {
|
||||||
|
source_id: info.source_id,
|
||||||
|
source: info.source,
|
||||||
|
slug: info.slug,
|
||||||
|
path_fs: info.path_fs,
|
||||||
|
last_played: info.last_played,
|
||||||
|
platform_id: platformId,
|
||||||
|
igdb_id: info.igdb_id,
|
||||||
|
ra_id: info.ra_id,
|
||||||
|
summary: info.summary,
|
||||||
|
name: info.name,
|
||||||
|
cover: info.cover,
|
||||||
|
cover_type: info.coverType,
|
||||||
|
metadata: info.metadata,
|
||||||
|
main_glob: info.main_glob,
|
||||||
|
version: info.version,
|
||||||
|
version_source: info.version_source,
|
||||||
|
version_system: info.version_system
|
||||||
|
};
|
||||||
|
|
||||||
|
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
|
||||||
|
|
||||||
|
if (info.screenshotUrls.length <= 0 && info.igdb_id)
|
||||||
|
{
|
||||||
|
const matches = new Map<string, GameLookup[]>();
|
||||||
|
await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(info.igdb_id) });
|
||||||
|
info.screenshotUrls.push(...(matches.values().next().value?.[0].screenshotUrls ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
// pre-fetch screenshots
|
||||||
|
const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s)));
|
||||||
|
|
||||||
|
if (screenshots.length > 0)
|
||||||
|
{
|
||||||
|
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
||||||
|
{
|
||||||
|
const screenshot: typeof schema.screenshots.$inferInsert = {
|
||||||
|
game_id: id,
|
||||||
|
content: Buffer.from(await response.arrayBuffer()),
|
||||||
|
type: response.headers.get('content-type')
|
||||||
|
};
|
||||||
|
|
||||||
|
return screenshot;
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadGame (ctx: {
|
||||||
|
downloads: DownloadFileEntry[],
|
||||||
|
auth?: string,
|
||||||
|
id: string,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
setProgress?: (progress: number, state: "download" | "extract", info: Partial<Omit<ProgressStats, 'progress'>>) => void,
|
||||||
|
extract_path?: string;
|
||||||
|
path_fs?: string;
|
||||||
|
|
||||||
|
}): Promise<string[] | undefined>
|
||||||
|
{
|
||||||
|
const downloadedFiles = await plugins.hooks.downloadFiles.promise({
|
||||||
|
id: ctx.id,
|
||||||
|
auth: ctx.auth,
|
||||||
|
files: ctx.downloads,
|
||||||
|
downloadPath: config.get('downloadPath'),
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
updateProgress: (stats) => ctx.setProgress?.(stats.progress, 'download', stats)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!downloadedFiles)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalFiles = await plugins.hooks.postDownloadFiles.promise({
|
||||||
|
files: downloadedFiles.files,
|
||||||
|
source: downloadedFiles.source,
|
||||||
|
extract_path: ctx.extract_path,
|
||||||
|
downloadPath: config.get('downloadPath'),
|
||||||
|
path_fs: ctx.path_fs
|
||||||
|
}) ?? downloadedFiles.files;
|
||||||
|
|
||||||
|
return finalFiles;
|
||||||
}
|
}
|
||||||
74
src/bun/api/jobs/bios-download-job.ts
Normal file
74
src/bun/api/jobs/bios-download-job.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
||||||
|
import { config, plugins } from "../app";
|
||||||
|
import { simulateProgress } from "@/bun/utils";
|
||||||
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||||
|
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
|
interface BiosDownloadJobData extends DownloadJobData
|
||||||
|
{
|
||||||
|
emulator: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
|
||||||
|
{
|
||||||
|
static id = "bios-download-job" as const;
|
||||||
|
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
|
||||||
|
group: string = "bios-download";
|
||||||
|
data: BiosDownloadJobData;
|
||||||
|
dryRun: boolean;
|
||||||
|
|
||||||
|
constructor(emulator: string, init?: { dryRun?: boolean; })
|
||||||
|
{
|
||||||
|
this.data = {
|
||||||
|
emulator,
|
||||||
|
name: "Download Emulator Bios"
|
||||||
|
};
|
||||||
|
this.dryRun = init?.dryRun ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<IJob<BiosDownloadJobData, "download">, BiosDownloadJobData, "download">)
|
||||||
|
{
|
||||||
|
const emulator = await getStoreEmulatorPackage(this.data.emulator);
|
||||||
|
if (!emulator) throw new Error("Could Not Find Emulator");
|
||||||
|
this.data.name = `${emulator.name} Bios`;
|
||||||
|
this.data.preview_url = emulator.logo;
|
||||||
|
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||||
|
const biosFolder = path.join(config.get('downloadPath'), "bios", this.data.emulator);
|
||||||
|
await ensureDir(biosFolder);
|
||||||
|
const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.data.emulator, systems, biosFolder });
|
||||||
|
|
||||||
|
if (!files) throw new Error("Could not find source to download from");
|
||||||
|
|
||||||
|
if (this.dryRun)
|
||||||
|
{
|
||||||
|
await simulateProgress((p) => context.setProgress(p, 'download'), context.abortSignal);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (files.auth)
|
||||||
|
headers['Authorization'] = files.auth;
|
||||||
|
|
||||||
|
const downloader = new Downloader('bios-download', files.files, biosFolder, {
|
||||||
|
signal: context.abortSignal,
|
||||||
|
headers,
|
||||||
|
onProgress: (stats) =>
|
||||||
|
{
|
||||||
|
context.setProgress(stats.progress, "download");
|
||||||
|
this.data.downloaded = stats.downloaded;
|
||||||
|
this.data.speed = stats.speed;
|
||||||
|
this.data.total = stats.total;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloader.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exposeData ()
|
||||||
|
{
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/bun/api/jobs/emulator-download-job.ts
Normal file
155
src/bun/api/jobs/emulator-download-job.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { DownloadJobData, EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared';
|
||||||
|
import { getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
||||||
|
import { config, plugins } from "../app";
|
||||||
|
import path from 'node:path';
|
||||||
|
import Seven from 'node-7z';
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
|
import { ensureDir, move } from "fs-extra";
|
||||||
|
import { isArchive, simulateProgress } from "@/bun/utils";
|
||||||
|
import { path7za } from "7zip-bin";
|
||||||
|
import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService";
|
||||||
|
import { $ } from "bun";
|
||||||
|
import { EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
|
type EmulatorDownloadStates = "download" | "extract";
|
||||||
|
|
||||||
|
interface EmulatorDownloadJobData extends DownloadJobData
|
||||||
|
{
|
||||||
|
emulator: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, EmulatorDownloadStates>
|
||||||
|
{
|
||||||
|
static id = "download-emulator" as const;
|
||||||
|
downloadSource: string;
|
||||||
|
emulatorPackage?: EmulatorPackageType;
|
||||||
|
dryRun: boolean;
|
||||||
|
isUpdate: boolean;
|
||||||
|
data: EmulatorDownloadJobData = {
|
||||||
|
name: "Download Emulator",
|
||||||
|
emulator: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; })
|
||||||
|
{
|
||||||
|
this.data.emulator = emulator;
|
||||||
|
this.downloadSource = downloadSource;
|
||||||
|
this.dryRun = init?.dryRun ?? false;
|
||||||
|
this.isUpdate = init?.isUpdate ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<EmulatorDownloadJob, EmulatorDownloadJobData, EmulatorDownloadStates>)
|
||||||
|
{
|
||||||
|
this.emulatorPackage = await getStoreEmulatorPackage(this.data.emulator);
|
||||||
|
if (!this.emulatorPackage) throw new Error("Emulator not found");
|
||||||
|
this.data.name = this.emulatorPackage.name;
|
||||||
|
this.data.preview_url = this.emulatorPackage.logo;
|
||||||
|
const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource);
|
||||||
|
|
||||||
|
const emulatorsFolder = getEmulatorPath(this.data.emulator);
|
||||||
|
|
||||||
|
if (this.dryRun)
|
||||||
|
{
|
||||||
|
await simulateProgress(p => context.setProgress(p, "download"), context.abortSignal);
|
||||||
|
await simulateProgress(p => context.setProgress(p, "extract"), context.abortSignal);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
||||||
|
const downloader = new Downloader(this.data.emulator,
|
||||||
|
[{ url, file_name: path.basename(url.pathname), file_path: this.data.emulator }],
|
||||||
|
tmpFolder,
|
||||||
|
{
|
||||||
|
signal: context.abortSignal,
|
||||||
|
onProgress: (stats) =>
|
||||||
|
{
|
||||||
|
context.setProgress(stats.progress, 'download');
|
||||||
|
this.data.total = stats.total;
|
||||||
|
this.data.downloaded = stats.downloaded;
|
||||||
|
this.data.speed = stats.speed;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const destinationPaths = await downloader.start();
|
||||||
|
context.abortSignal.throwIfAborted();
|
||||||
|
if (destinationPaths)
|
||||||
|
{
|
||||||
|
const archive = isArchive(destinationPaths[0]);
|
||||||
|
const isAppImage = destinationPaths[0].endsWith(".AppImage");
|
||||||
|
|
||||||
|
if (!archive && !isAppImage)
|
||||||
|
{
|
||||||
|
throw new Error("Invalid Download Type");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (archive)
|
||||||
|
{
|
||||||
|
if (destinationPaths[0])
|
||||||
|
{
|
||||||
|
let destinationPath = destinationPaths[0];
|
||||||
|
if (destinationPath.endsWith('.tar'))
|
||||||
|
{
|
||||||
|
context.setProgress(0, "extract");
|
||||||
|
await ensureDir(emulatorsFolder);
|
||||||
|
await $`tar -xf ${destinationPath} -C ${emulatorsFolder}`;
|
||||||
|
await fs.rm(destinationPath, { recursive: true });
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
await new Promise((resolve, reject) =>
|
||||||
|
{
|
||||||
|
const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true });
|
||||||
|
seven.on('progress', p => context.setProgress(p.percent, "extract"));
|
||||||
|
seven.on('error', e => reject(e));
|
||||||
|
seven.on('end', () => resolve(true));
|
||||||
|
});
|
||||||
|
await fs.rm(destinationPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if 1 root folder we need to get rid of
|
||||||
|
const contents = await fs.readdir(emulatorsFolder);
|
||||||
|
if (contents.length === 1)
|
||||||
|
{
|
||||||
|
const stat = await fs.stat(path.join(emulatorsFolder, contents[0]));
|
||||||
|
if (stat.isDirectory())
|
||||||
|
{
|
||||||
|
console.log("Found 1 root folder, using that instead");
|
||||||
|
const tmpEmulatorsFolder = `${emulatorsFolder} (1)`;
|
||||||
|
await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true });
|
||||||
|
await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
await ensureDir(emulatorsFolder);
|
||||||
|
for (const destPath of destinationPaths)
|
||||||
|
{
|
||||||
|
await fs.rename(destPath, path.join(emulatorsFolder, path.basename(destPath)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3));
|
||||||
|
|
||||||
|
const execs: EmulatorSourceEntryType[] = [];
|
||||||
|
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: this.data.emulator, sources: execs });
|
||||||
|
|
||||||
|
await plugins.hooks.emulators.emulatorPostInstall.promise({
|
||||||
|
emulator: this.data.emulator,
|
||||||
|
emulatorPackage: this.emulatorPackage,
|
||||||
|
path: execs.find(e => e.type === 'store')?.binPath ?? emulatorsFolder,
|
||||||
|
info,
|
||||||
|
update: this.isUpdate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
exposeData ()
|
||||||
|
{
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
64
src/bun/api/jobs/ensure-store.ts
Normal file
64
src/bun/api/jobs/ensure-store.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
||||||
|
import { getStoreRootFolder } from "../store/services/gamesService";
|
||||||
|
import z from "zod";
|
||||||
|
import { runBunPackageCommand } from "../plugins/services";
|
||||||
|
import { PluginRegistry } from "@/shared/constants";
|
||||||
|
import path from "node:path";
|
||||||
|
import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json';
|
||||||
|
import { IsPluginAllowed } from "@/bun/utils";
|
||||||
|
|
||||||
|
export default class EnsureStore implements IJob<never, string>
|
||||||
|
{
|
||||||
|
static id = "update-store" as const;
|
||||||
|
static dataSchema = z.never();
|
||||||
|
packageName: string;
|
||||||
|
storeVersion: string;
|
||||||
|
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store";
|
||||||
|
this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<EnsureStore, never, string>)
|
||||||
|
{
|
||||||
|
const storeFolder = getStoreRootFolder();
|
||||||
|
await ensureDir(storeFolder);
|
||||||
|
const storePackageFile = Bun.file(path.join(storeFolder, "package.json"));
|
||||||
|
if (!await storePackageFile.exists())
|
||||||
|
{
|
||||||
|
await storePackageFile.write(JSON.stringify({ dependencies: {} }, null, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json();
|
||||||
|
|
||||||
|
if (IsPluginAllowed(sdkPkg.name))
|
||||||
|
{
|
||||||
|
if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
|
||||||
|
{
|
||||||
|
let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']);
|
||||||
|
console.log(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// probably just means we couldn't find a version of the sdk, just install latest
|
||||||
|
if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
|
||||||
|
{
|
||||||
|
let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']);
|
||||||
|
console.log(response);
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
console.log("Ignoring SDK package");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.CUSTOM_STORE_PATH) return;
|
||||||
|
|
||||||
|
if (!storePackage.dependencies?.['@simeonradivoev/gameflow-store'])
|
||||||
|
{
|
||||||
|
context.setProgress(0.5, "Adding Store");
|
||||||
|
let response = await runBunPackageCommand(["add", `${this.packageName}@${this.storeVersion}`, "--registry", PluginRegistry, '--omit', 'peer']);
|
||||||
|
console.log(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/bun/api/jobs/import-job.ts
Normal file
143
src/bun/api/jobs/import-job.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { eq, inArray, or } from "drizzle-orm";
|
||||||
|
import { db, plugins } from "../app";
|
||||||
|
import { createLocalGame, downloadGame } from "../games/services/utils";
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
||||||
|
import * as schema from "@schema/app";
|
||||||
|
import { DownloadJobData, GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
import { isUrl } from "@/shared/utils";
|
||||||
|
import { basename } from "node:path";
|
||||||
|
import path from 'node:path';
|
||||||
|
import { isArchive } from "@/bun/utils";
|
||||||
|
|
||||||
|
interface ImportJobData extends DownloadJobData
|
||||||
|
{
|
||||||
|
localId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ImportJob implements IJob<ImportJobData, string>
|
||||||
|
{
|
||||||
|
static id = "import-job" as const;
|
||||||
|
static query = (q: { source: string; id: string; }) => `${ImportJob.id}-${q.source}-${q.id}`;
|
||||||
|
data: ImportJobData = {
|
||||||
|
localId: null,
|
||||||
|
name: "Import Game"
|
||||||
|
};
|
||||||
|
group?: 'import-job';
|
||||||
|
gamePath: string;
|
||||||
|
source: string;
|
||||||
|
id: string;
|
||||||
|
platformId: number;
|
||||||
|
|
||||||
|
constructor(source: string, id: string, gamePath: string, platformId: number)
|
||||||
|
{
|
||||||
|
this.gamePath = gamePath;
|
||||||
|
this.source = source;
|
||||||
|
this.id = id;
|
||||||
|
this.platformId = platformId;
|
||||||
|
}
|
||||||
|
|
||||||
|
exposeData ()
|
||||||
|
{
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<IJob<ImportJobData, string>, ImportJobData, string>): Promise<any>
|
||||||
|
{
|
||||||
|
const matchesMap = new Map<string, GameLookup[]>();
|
||||||
|
await plugins.hooks.games.gameLookup.promise(matchesMap, { source: this.source, id: this.id });
|
||||||
|
const matches = matchesMap.values().next().value;
|
||||||
|
if (!matches || matches.length <= 0) throw Error("Could not Find Game");
|
||||||
|
const match = matches[0];
|
||||||
|
this.data.name = match.name;
|
||||||
|
this.data.preview_url = match.coverUrl;
|
||||||
|
|
||||||
|
let cover: Buffer<ArrayBufferLike> | undefined = undefined;
|
||||||
|
let coverType: string | undefined = undefined;
|
||||||
|
if (match.coverUrl)
|
||||||
|
{
|
||||||
|
const coverResponse = await fetch(match.coverUrl);
|
||||||
|
if (coverResponse.ok)
|
||||||
|
{
|
||||||
|
cover = Buffer.from(await coverResponse.arrayBuffer());
|
||||||
|
coverType = coverResponse.headers.get('content-type') ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformMatch = match.platforms.find(p => p.id === this.platformId);
|
||||||
|
|
||||||
|
const finalFiles: string[] = [];
|
||||||
|
|
||||||
|
if (isUrl(this.gamePath))
|
||||||
|
{
|
||||||
|
const archive = isArchive(this.gamePath);
|
||||||
|
const downloadedFiles = await downloadGame({
|
||||||
|
downloads: [{
|
||||||
|
file_path: this.id,
|
||||||
|
file_name: basename(this.gamePath),
|
||||||
|
url: new URL(this.gamePath)
|
||||||
|
}],
|
||||||
|
extract_path: archive ? '.tmp' : undefined,
|
||||||
|
path_fs: path.join('roms', platformMatch?.slug ?? this.source, this.id),
|
||||||
|
abortSignal: context.abortSignal,
|
||||||
|
id: `game-${this.source}-${this.id}`,
|
||||||
|
setProgress: (progress, state, info) =>
|
||||||
|
{
|
||||||
|
context.setProgress(progress, state);
|
||||||
|
this.data.speed = info.speed;
|
||||||
|
this.data.total = info.total;
|
||||||
|
this.data.downloaded = info.downloaded;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (downloadedFiles)
|
||||||
|
finalFiles.push(...downloadedFiles);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
finalFiles.push(this.gamePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localSearchFilters: any[] = [];
|
||||||
|
if (match.igdb_id) localSearchFilters.push(eq(schema.games.igdb_id, match.igdb_id));
|
||||||
|
if (match.slug) localSearchFilters.push(eq(schema.games.slug, match.slug));
|
||||||
|
localSearchFilters.push(eq(schema.games.name, match.name));
|
||||||
|
localSearchFilters.push(inArray(schema.games.path_fs, finalFiles));
|
||||||
|
const existingLocalGame = await db.query.games.findFirst({ where: or(...localSearchFilters) });
|
||||||
|
context.abortSignal.throwIfAborted();
|
||||||
|
|
||||||
|
if (existingLocalGame) throw new Error("Game Already Exists");
|
||||||
|
|
||||||
|
this.data.localId = await createLocalGame({
|
||||||
|
name: match.name,
|
||||||
|
system_slug: platformMatch?.slug,
|
||||||
|
source: undefined,
|
||||||
|
source_id: undefined,
|
||||||
|
slug: match.slug,
|
||||||
|
path_fs: finalFiles[0],
|
||||||
|
summary: match.summary,
|
||||||
|
igdb_id: match.igdb_id,
|
||||||
|
ra_id: undefined,
|
||||||
|
main_glob: undefined,
|
||||||
|
cover,
|
||||||
|
coverType,
|
||||||
|
version: undefined,
|
||||||
|
version_source: undefined,
|
||||||
|
screenshotUrls: match.screenshotUrls,
|
||||||
|
version_system: undefined,
|
||||||
|
platform: platformMatch ? {
|
||||||
|
source_slug: platformMatch.slug,
|
||||||
|
source_id: platformMatch.id,
|
||||||
|
source: this.source,
|
||||||
|
name: platformMatch.displayName
|
||||||
|
} : undefined,
|
||||||
|
metadata: {
|
||||||
|
game_modes: match.game_modes,
|
||||||
|
companies: match.companies,
|
||||||
|
first_release_date: match.first_release_date ?? undefined,
|
||||||
|
player_count: match.player_count,
|
||||||
|
age_ratings: match.age_ratings,
|
||||||
|
average_rating: match.average_rating,
|
||||||
|
genres: match.genres,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,215 +1,124 @@
|
||||||
import { IJob, JobContext } from "../task-queue";
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
||||||
import { mkdir } from 'node:fs/promises';
|
|
||||||
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 { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
|
import { config, events, plugins } from "../app";
|
||||||
import { config, db, emulatorsDb, jar } from "../app";
|
import { simulateProgress } from "@/bun/utils";
|
||||||
import unzip from 'unzip-stream';
|
import z from "zod";
|
||||||
import { Readable, Transform } from "node:stream";
|
import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils";
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
interface JobConfig
|
interface JobConfig
|
||||||
{
|
{
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
dryDownload?: boolean;
|
dryDownload?: boolean;
|
||||||
|
downloadId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InstallJob implements IJob
|
export type InstallJobStates = 'download' | 'extract';
|
||||||
|
|
||||||
|
export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
|
||||||
{
|
{
|
||||||
public id: number;
|
static id = "install-job" as const;
|
||||||
|
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
|
||||||
|
static dataSchema = z.never();
|
||||||
|
public gameId: string;
|
||||||
public source: string;
|
public source: string;
|
||||||
public sourceId: number;
|
|
||||||
|
|
||||||
public config?: JobConfig;
|
public config?: JobConfig;
|
||||||
|
// The local game ID of newly created entry, if successful
|
||||||
|
public localGameId?: number;
|
||||||
|
public group = InstallJob.id;
|
||||||
|
public localPath?: string;
|
||||||
|
data: DownloadJobData = {
|
||||||
|
name: "Install Game"
|
||||||
|
};
|
||||||
|
|
||||||
constructor(id: number, source: string, sourceId: number, config?: JobConfig)
|
constructor(id: string, source: string, config?: JobConfig)
|
||||||
{
|
{
|
||||||
this.id = id;
|
this.gameId = id;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.sourceId = sourceId;
|
|
||||||
this.source = source;
|
this.source = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start (cx: JobContext)
|
public async start (cx: JobContext<InstallJob, DownloadJobData, InstallJobStates>)
|
||||||
{
|
{
|
||||||
cx.setProgress(0, 'download');
|
cx.setProgress(0, 'download');
|
||||||
fs.mkdir(config.get('downloadPath'), { recursive: true });
|
await fs.mkdir(config.get('downloadPath'), { recursive: true });
|
||||||
|
|
||||||
|
const downloadPath = config.get('downloadPath');
|
||||||
|
const finalFiles: string[] = [];
|
||||||
|
let info: DownloadInfo | undefined;
|
||||||
|
|
||||||
if (this.config?.dryRun !== true)
|
if (this.config?.dryRun !== true)
|
||||||
{
|
{
|
||||||
const downloadPath = config.get('downloadPath');
|
const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
|
||||||
|
info = allDownloads?.[0];
|
||||||
|
|
||||||
if (this.config?.dryDownload !== true)
|
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))
|
||||||
{
|
{
|
||||||
/*
|
const downloadedFiles = await downloadGame({
|
||||||
// download files for rom
|
downloads: files.filter(f => !f.exists || !f.matches),
|
||||||
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
extract_path: info.extract_path,
|
||||||
downloadUrl.searchParams.set('rom_ids', String(this.id));
|
path_fs: info.path_fs,
|
||||||
const downloader = new DownloaderHelper(downloadUrl.href, downloadPath, {
|
abortSignal: cx.abortSignal,
|
||||||
headers: {
|
auth: info.auth,
|
||||||
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
|
id: `game-${this.source}-${this.gameId}`,
|
||||||
},
|
setProgress: (process, state, info) =>
|
||||||
fileName: `${this.id}.zip`,
|
|
||||||
// Romm doesn't support resume download
|
|
||||||
override: true
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.abortSignal.addEventListener('abort', downloader.stop);
|
|
||||||
|
|
||||||
downloader.on('progress.throttled', e =>
|
|
||||||
{
|
|
||||||
cx.setProgress(e.progress, 'download');
|
|
||||||
});
|
|
||||||
|
|
||||||
downloader.on('error', (e) =>
|
|
||||||
{
|
|
||||||
cx.abort(e);
|
|
||||||
});
|
|
||||||
const finishPromise = new Promise<string>(resolve =>
|
|
||||||
{
|
|
||||||
downloader.on("end", ({ filePath }) => resolve(filePath));
|
|
||||||
});
|
|
||||||
|
|
||||||
await downloader.start().catch(err => console.error(err));
|
|
||||||
const zipFilePath = await finishPromise;
|
|
||||||
|
|
||||||
cx.setProgress(0, 'extract');
|
|
||||||
|
|
||||||
const zip = new StreamZip.async({ file: zipFilePath });
|
|
||||||
const totalCount = await zip.entriesCount;
|
|
||||||
let extractCount = 0;
|
|
||||||
zip.on('extract', async (entry, file) =>
|
|
||||||
{
|
|
||||||
console.log(`Extracted ${entry.name} to ${file}`);
|
|
||||||
cx.setProgress(extractCount / totalCount * 100, 'extract');
|
|
||||||
extractCount++;
|
|
||||||
});
|
|
||||||
await zip.extract(null, downloadPath);
|
|
||||||
await zip.close();
|
|
||||||
|
|
||||||
await fs.rm(zipFilePath);*/
|
|
||||||
|
|
||||||
cx.setProgress(0, 'download');
|
|
||||||
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
|
||||||
downloadUrl.searchParams.set('rom_ids', String(this.id));
|
|
||||||
const res = await fetch(downloadUrl, {
|
|
||||||
headers: {
|
|
||||||
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalBytes = Number(res.headers.get("content-length")) || 0;
|
|
||||||
let bytesReceived = 0;
|
|
||||||
|
|
||||||
const progressStream = new Transform({
|
|
||||||
transform (chunk, encoding, callback)
|
|
||||||
{
|
{
|
||||||
bytesReceived += chunk.length;
|
cx.setProgress(process, state);
|
||||||
if (totalBytes > 0)
|
this.data.downloaded = info.downloaded;
|
||||||
{
|
this.data.speed = info.speed;
|
||||||
const percent = (bytesReceived / totalBytes) * 100;
|
this.data.total = info.total;
|
||||||
cx.setProgress(percent, 'download');
|
},
|
||||||
}
|
|
||||||
this.push(chunk);
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((resolve, reject) =>
|
if (downloadedFiles)
|
||||||
{
|
finalFiles.push(...downloadedFiles);
|
||||||
Readable.fromWeb(res.body as any).pipe(progressStream).pipe(unzip.Extract({ path: downloadPath })).on('close', resolve).on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rom = (await getRomApiRomsIdGet({ path: { id: this.id }, throwOnError: true })).data;
|
if (this.config?.dryDownload === true && info.extract_path)
|
||||||
const romPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
|
|
||||||
|
|
||||||
if (this.config?.dryDownload === true)
|
|
||||||
{
|
{
|
||||||
rom.files.length;
|
await ensureDir(path.join(downloadPath, info.extract_path));
|
||||||
await mkdir(path.join(downloadPath, rom.fs_path, rom.fs_name), { recursive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pre-fetch screenshots
|
const coverResponse = await fetch(info.coverUrl);
|
||||||
const screenshots = await Promise.all(rom.merged_screenshots.map(s => fetch(`${config.get('rommAddress')}${s}`)));
|
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
||||||
|
|
||||||
const rommAddress = config.get('rommAddress');
|
cx.abortSignal.throwIfAborted();
|
||||||
const coverResponse = await fetch(`${rommAddress}${rom.path_cover_large}`);
|
|
||||||
|
|
||||||
if (cx.abortSignal.aborted) return;
|
this.localGameId = await createLocalGame({
|
||||||
|
cover,
|
||||||
await db.transaction(async (tx) =>
|
coverType: coverResponse.headers.get('content-type'),
|
||||||
{
|
system_slug: info.system_slug,
|
||||||
// Search for existing platform
|
source_id: info.source_id,
|
||||||
const platformSearch = [];
|
source: this.source,
|
||||||
if (romPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, romPlatform.igdb_id));
|
slug: info.slug,
|
||||||
if (romPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, romPlatform.igdb_slug));
|
path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined),
|
||||||
if (romPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, romPlatform.ra_id));
|
summary: info.summary,
|
||||||
if (romPlatform.slug) platformSearch.push(eq(schema.platforms.slug, romPlatform.slug));
|
igdb_id: info.igdb_id,
|
||||||
if (romPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, romPlatform.moby_id));
|
ra_id: info.ra_id,
|
||||||
|
name: info.name,
|
||||||
const esPlatform = await emulatorsDb
|
main_glob: info.main_glob,
|
||||||
.select({ slug: emulatorSchema.systemMappings.system, romm_slug: emulatorSchema.systemMappings.sourceSlug })
|
version: info.version,
|
||||||
.from(emulatorSchema.systemMappings)
|
version_source: info.version_source,
|
||||||
.where(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug)));
|
screenshotUrls: info.screenshotUrls,
|
||||||
|
version_system: info.version_system,
|
||||||
const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
|
metadata: info.metadata,
|
||||||
let platformId: number;
|
platform: info.platform
|
||||||
if (!existingPlatform)
|
|
||||||
{
|
|
||||||
// Create new local platform
|
|
||||||
const platformCover = await fetch(`${rommAddress}/assets/platforms/${romPlatform.slug.toLocaleLowerCase()}.svg`);
|
|
||||||
const platform: typeof schema.platforms.$inferInsert = {
|
|
||||||
slug: romPlatform.slug,
|
|
||||||
igdb_id: romPlatform.igdb_id,
|
|
||||||
igdb_slug: romPlatform.igdb_slug,
|
|
||||||
ra_id: romPlatform.ra_id,
|
|
||||||
cover: Buffer.from(await platformCover.arrayBuffer()),
|
|
||||||
cover_type: platformCover.headers.get('content-type'),
|
|
||||||
name: romPlatform.name,
|
|
||||||
family_name: romPlatform.family_name,
|
|
||||||
es_slug: esPlatform.length > 0 ? esPlatform[0].slug : 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: rom.id,
|
|
||||||
source: 'romm',
|
|
||||||
slug: rom.slug,
|
|
||||||
path_fs: path.join(rom.fs_path, rom.fs_name),
|
|
||||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
|
||||||
platform_id: platformId,
|
|
||||||
igdb_id: rom.igdb_id,
|
|
||||||
ra_id: rom.ra_id,
|
|
||||||
summary: rom.summary,
|
|
||||||
name: rom.name,
|
|
||||||
cover: Buffer.from(await coverResponse.arrayBuffer()),
|
|
||||||
cover_type: coverResponse.headers.get('content-type')
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save screenshots and update database
|
|
||||||
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
|
|
||||||
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;
|
|
||||||
})));
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
|
if (this.source && this.gameId) await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info });
|
||||||
|
events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 });
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
191
src/bun/api/jobs/jobs.ts
Normal file
191
src/bun/api/jobs/jobs.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import Elysia from "elysia";
|
||||||
|
import z, { _ZodType } from "zod";
|
||||||
|
import { taskQueue } from "../app";
|
||||||
|
import { LoginJob } from "./login-job";
|
||||||
|
import TwitchLoginJob from "./twitch-login-job";
|
||||||
|
import EnsureStore from "./ensure-store";
|
||||||
|
import { EmulatorDownloadJob } from "./emulator-download-job";
|
||||||
|
import { getErrorMessage } from "@/bun/utils";
|
||||||
|
import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue";
|
||||||
|
import { LaunchGameJob } from "./launch-game-job";
|
||||||
|
import { BiosDownloadJob } from "./bios-download-job";
|
||||||
|
import { InstallJob } from "./install-job";
|
||||||
|
import ReloadPluginsJob from "./reload-plugins-job";
|
||||||
|
import { FrontEndJob } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
|
function registerJob<
|
||||||
|
const Path extends string,
|
||||||
|
Schema,
|
||||||
|
const States extends string,
|
||||||
|
> (_job: {
|
||||||
|
id: Path;
|
||||||
|
query?: (q: any) => string;
|
||||||
|
} & (new (...args: any[]) => IJob<Schema, States>))
|
||||||
|
{
|
||||||
|
return new Elysia().ws(_job.id, {
|
||||||
|
body: z.discriminatedUnion('type', [
|
||||||
|
z.object({ type: z.literal('cancel') })
|
||||||
|
]),
|
||||||
|
query: z.record(z.string(), z.any()),
|
||||||
|
response: z.discriminatedUnion('type', [
|
||||||
|
z.object({
|
||||||
|
type: z.literal(['data', 'started', 'progress']),
|
||||||
|
state: z.string().optional(),
|
||||||
|
progress: z.number(),
|
||||||
|
data: z.custom<Schema>()
|
||||||
|
}),
|
||||||
|
z.object({ type: z.literal(['completed', 'ended']), data: z.custom<Schema>() }),
|
||||||
|
z.object({ type: z.literal('waiting') }),
|
||||||
|
z.object({ type: z.literal('error'), error: z.string() })
|
||||||
|
]),
|
||||||
|
open (ws)
|
||||||
|
{
|
||||||
|
const jobId = (_job.query ? _job.query(ws.data.query) : _job.id);
|
||||||
|
const job = taskQueue.findJob(jobId, _job);
|
||||||
|
if (job)
|
||||||
|
{
|
||||||
|
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() as Schema });
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
ws.send({ type: 'waiting' });
|
||||||
|
}
|
||||||
|
|
||||||
|
(ws.data as any).cleanup = [
|
||||||
|
taskQueue.on('started', ({ id, job }) =>
|
||||||
|
{
|
||||||
|
if (id === jobId)
|
||||||
|
{
|
||||||
|
ws.send({ type: 'started', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
taskQueue.on('progress', ({ id, job }) =>
|
||||||
|
{
|
||||||
|
if (id === jobId)
|
||||||
|
{
|
||||||
|
ws.send({ type: 'progress', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
taskQueue.on('completed', ({ id, job }) =>
|
||||||
|
{
|
||||||
|
if (id === jobId)
|
||||||
|
{
|
||||||
|
ws.send({ type: 'completed', data: job.job.exposeData?.() });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
taskQueue.on('ended', ({ id, job }) =>
|
||||||
|
{
|
||||||
|
if (id === jobId)
|
||||||
|
{
|
||||||
|
ws.send({ type: 'ended', data: job.job.exposeData?.() });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
taskQueue.on('error', ({ id, error }) =>
|
||||||
|
{
|
||||||
|
if (id === jobId)
|
||||||
|
{
|
||||||
|
ws.send({ type: 'error', error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
close (ws)
|
||||||
|
{
|
||||||
|
(ws.data as any).cleanup.forEach((d: Function) => d());
|
||||||
|
},
|
||||||
|
message (_, message)
|
||||||
|
{
|
||||||
|
if (message.type === 'cancel')
|
||||||
|
{
|
||||||
|
const jobId = (_job.query ? _job.query(this.query) : _job.id);
|
||||||
|
taskQueue.findJob(jobId, _job)?.abort('cancel');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jobs = new Elysia({ prefix: '/api/jobs' })
|
||||||
|
.ws('/list', {
|
||||||
|
response: z.discriminatedUnion('type', [
|
||||||
|
z.object({ type: z.literal("allJobs"), active: z.custom<FrontEndJob>().array(), queued: z.custom<FrontEndJob>().array() }),
|
||||||
|
z.object({ type: z.literal("started"), job: z.custom<FrontEndJob>() }),
|
||||||
|
z.object({ type: z.literal("progress"), job: z.custom<FrontEndJob>() }),
|
||||||
|
z.object({ type: z.literal("queued"), job: z.custom<FrontEndJob>() }),
|
||||||
|
z.object({ type: z.literal("aborted"), id: z.string() }),
|
||||||
|
z.object({ type: z.literal("ended"), id: z.string() }),
|
||||||
|
]),
|
||||||
|
body: z.discriminatedUnion('type', [
|
||||||
|
z.object({ type: z.literal("cancel"), id: z.string() })
|
||||||
|
]),
|
||||||
|
message (ws, message)
|
||||||
|
{
|
||||||
|
switch (message.type)
|
||||||
|
{
|
||||||
|
case "cancel":
|
||||||
|
taskQueue.cancelJob(message.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
open (ws)
|
||||||
|
{
|
||||||
|
ws.send({
|
||||||
|
type: 'allJobs',
|
||||||
|
active: taskQueue.getActiveJobs().map(j =>
|
||||||
|
{
|
||||||
|
const job: FrontEndJob = {
|
||||||
|
id: j.id,
|
||||||
|
data: j.job.exposeData?.(),
|
||||||
|
progress: j.progress,
|
||||||
|
state: j.state,
|
||||||
|
status: j.status
|
||||||
|
};
|
||||||
|
|
||||||
|
return job;
|
||||||
|
}),
|
||||||
|
queued: taskQueue.getQueuedJobs()?.map(j =>
|
||||||
|
{
|
||||||
|
const job: FrontEndJob = {
|
||||||
|
id: j.id,
|
||||||
|
data: j.job.exposeData?.(),
|
||||||
|
progress: j.progress,
|
||||||
|
state: j.state,
|
||||||
|
status: j.status
|
||||||
|
};
|
||||||
|
|
||||||
|
return job;
|
||||||
|
}) ?? []
|
||||||
|
});
|
||||||
|
|
||||||
|
(ws.data as any).dispose = [taskQueue.on('started', (e: BaseEvent) =>
|
||||||
|
{
|
||||||
|
ws.send({ type: "started", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
|
||||||
|
}),
|
||||||
|
taskQueue.on('progress', (e: BaseEvent) =>
|
||||||
|
{
|
||||||
|
ws.send({ type: "progress", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
|
||||||
|
}),
|
||||||
|
taskQueue.on('queued', (e: BaseEvent) =>
|
||||||
|
{
|
||||||
|
ws.send({ type: "queued", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
|
||||||
|
}),
|
||||||
|
taskQueue.on('abort', (e: BaseEvent) =>
|
||||||
|
{
|
||||||
|
ws.send({ type: "aborted", id: e.id });
|
||||||
|
}),
|
||||||
|
taskQueue.on('ended', (e: BaseEvent) =>
|
||||||
|
{
|
||||||
|
ws.send({ type: "ended", id: e.id });
|
||||||
|
})];
|
||||||
|
},
|
||||||
|
close (ws, code, reason)
|
||||||
|
{
|
||||||
|
(ws.data as any).dispose.forEach((d: any) => d());
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.use(registerJob(LaunchGameJob))
|
||||||
|
.use(registerJob(LoginJob))
|
||||||
|
.use(registerJob(TwitchLoginJob))
|
||||||
|
.use(registerJob(EnsureStore))
|
||||||
|
.use(registerJob(BiosDownloadJob))
|
||||||
|
.use(registerJob(InstallJob))
|
||||||
|
.use(registerJob(ReloadPluginsJob))
|
||||||
|
.use(registerJob(EmulatorDownloadJob));
|
||||||
277
src/bun/api/jobs/launch-game-job.ts
Normal file
277
src/bun/api/jobs/launch-game-job.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import z from "zod";
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
||||||
|
import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk";
|
||||||
|
import { config, db, events, plugins } from "../app";
|
||||||
|
import * as appSchema from "@schema/app";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { updateLocalLastPlayed } from "../games/services/statusService";
|
||||||
|
import { getErrorMessage } from "@/bun/utils";
|
||||||
|
import { CommandEntry, FrontEndId, SaveSlots } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
|
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>
|
||||||
|
{
|
||||||
|
static id = "launch-game" as const;
|
||||||
|
static dataSchema = z.nullable(ActiveGameSchema);
|
||||||
|
group = "launch-game";
|
||||||
|
activeGame: ActiveGameType | null;
|
||||||
|
gameId: FrontEndId;
|
||||||
|
validCommand: CommandEntry;
|
||||||
|
gameSource?: string;
|
||||||
|
gameSourceId?: string;
|
||||||
|
changedSaveFiles: Map<string, { subPath: string, cwd: string; }>;
|
||||||
|
saveSlots: SaveSlots = {};
|
||||||
|
|
||||||
|
constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
|
||||||
|
{
|
||||||
|
this.gameId = gameId;
|
||||||
|
this.validCommand = validCommand;
|
||||||
|
this.gameSource = source;
|
||||||
|
this.gameSourceId = sourceId;
|
||||||
|
this.activeGame = null;
|
||||||
|
this.changedSaveFiles = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async postPlay (gameInfo: { platformSlug?: string; })
|
||||||
|
{
|
||||||
|
if (this.gameId.source === 'local')
|
||||||
|
{
|
||||||
|
await updateLocalLastPlayed(Number(this.gameId.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = this.gameSource ?? this.gameId.source;
|
||||||
|
const id = this.gameSourceId ?? this.gameId.id;
|
||||||
|
|
||||||
|
await new Promise(async (resolve) =>
|
||||||
|
{
|
||||||
|
await plugins.hooks.games.postPlay.promise(
|
||||||
|
{
|
||||||
|
source,
|
||||||
|
id,
|
||||||
|
command: this.validCommand,
|
||||||
|
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
|
||||||
|
validChangedSaveFiles: {},
|
||||||
|
saveFolderSlots: this.saveSlots,
|
||||||
|
gameInfo
|
||||||
|
}).catch(e =>
|
||||||
|
{
|
||||||
|
console.error(e);
|
||||||
|
events.emit('notification', { message: getErrorMessage(e), type: 'error' });
|
||||||
|
}).then(() => resolve(false));
|
||||||
|
const timeoutHandler = () => resolve(false);
|
||||||
|
setTimeout(timeoutHandler, 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; })
|
||||||
|
{
|
||||||
|
return plugins.hooks.games.prePlay.promise({
|
||||||
|
source: this.gameSource ?? this.gameId.source,
|
||||||
|
id: this.gameSourceId ?? this.gameId.id,
|
||||||
|
saveFolderSlots: this.saveSlots,
|
||||||
|
command: this.validCommand,
|
||||||
|
setProgress: setProgress,
|
||||||
|
gameInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>, z.infer<typeof LaunchGameJob.dataSchema>, string>)
|
||||||
|
{
|
||||||
|
let gameInfo: { name?: string, source_id?: string, source?: string; platformSlug?: string; } | undefined = undefined;
|
||||||
|
if (this.gameId.source === 'emulator')
|
||||||
|
{
|
||||||
|
gameInfo = { name: this.gameId.id };
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const localGame = await db.query.games.findFirst({
|
||||||
|
where: eq(appSchema.games.id, Number(this.gameId.id)), columns: {
|
||||||
|
name: true,
|
||||||
|
source_id: true,
|
||||||
|
source: true,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
platform: {
|
||||||
|
columns: {
|
||||||
|
es_slug: true,
|
||||||
|
slug: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (localGame)
|
||||||
|
gameInfo = {
|
||||||
|
name: localGame.name ?? undefined,
|
||||||
|
source_id: localGame.source_id ?? undefined,
|
||||||
|
source: localGame.source ?? undefined,
|
||||||
|
platformSlug: localGame.platform.es_slug ?? localGame.platform.slug
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
|
||||||
|
autoValidCommand: this.validCommand,
|
||||||
|
game: {
|
||||||
|
source: this.gameSource,
|
||||||
|
sourceId: this.gameSourceId,
|
||||||
|
id: this.gameId,
|
||||||
|
platformSlug: gameInfo?.platformSlug
|
||||||
|
},
|
||||||
|
dryRun: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(async (resolve, reject) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
let game: any;
|
||||||
|
if (!commandArgs)
|
||||||
|
{
|
||||||
|
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(e));
|
||||||
|
|
||||||
|
if (Array.isArray(this.validCommand.command))
|
||||||
|
{
|
||||||
|
let command = this.validCommand.command;
|
||||||
|
if (process.env.FLATPAK_BUILD) command = ['flatpak-spawn', '--host', `--directory=${config.get('downloadPath')}`, ...command];
|
||||||
|
|
||||||
|
const bunGame = Bun.spawn(command, {
|
||||||
|
cwd: this.validCommand.startDir,
|
||||||
|
signal: context.abortSignal,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...this.validCommand.env
|
||||||
|
},
|
||||||
|
onExit (subprocess, exitCode, signalCode, error)
|
||||||
|
{
|
||||||
|
if (error)
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
|
reject(error);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
context.setProgress(0, "playing");
|
||||||
|
|
||||||
|
game = bunGame;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
|
||||||
|
let command = this.validCommand.command;
|
||||||
|
|
||||||
|
if (process.env.FLATPAK_BUILD) command = `flatpak-spawn --host --directory=${config.get('downloadPath')} ${command}`;
|
||||||
|
|
||||||
|
// ES-DE commands require shell execution. Some emulators fail otherwise.
|
||||||
|
const spawnGame = spawn(command, {
|
||||||
|
shell: this.validCommand.shell ?? true,
|
||||||
|
cwd: this.validCommand.startDir,
|
||||||
|
signal: context.abortSignal,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...this.validCommand.env
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
context.setProgress(0, "playing");
|
||||||
|
|
||||||
|
spawnGame.stdout.on('data', data => console.log(data));
|
||||||
|
spawnGame.on('close', (code) =>
|
||||||
|
{
|
||||||
|
resolve(code);
|
||||||
|
});
|
||||||
|
spawnGame.on('error', e =>
|
||||||
|
{
|
||||||
|
console.error(e);
|
||||||
|
resolve(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
game = spawnGame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (this.validCommand.metadata.emulatorBin)
|
||||||
|
{
|
||||||
|
this.saveSlots = commandArgs.savesPath ?? {};
|
||||||
|
|
||||||
|
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
|
||||||
|
|
||||||
|
let command = [this.validCommand.metadata.emulatorBin, ...commandArgs.args];
|
||||||
|
if (process.env.FLATPAK_BUILD) command = ['flatpak-spawn', '--host', `--directory=${config.get('downloadPath')}`, ...command];
|
||||||
|
|
||||||
|
// We have full control over launching integrated emulators better to use bun spawn
|
||||||
|
const bunGame = Bun.spawn(command, {
|
||||||
|
cwd: this.validCommand.startDir,
|
||||||
|
signal: context.abortSignal,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...commandArgs.env
|
||||||
|
},
|
||||||
|
onExit (subprocess, exitCode, signalCode, error)
|
||||||
|
{
|
||||||
|
if (error)
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
|
reject(error);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
context.setProgress(0, "playing");
|
||||||
|
|
||||||
|
// TODO: this isn't really useful, maybe add it later if needed
|
||||||
|
/*if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath))
|
||||||
|
{
|
||||||
|
const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal });
|
||||||
|
console.log("Starting To Watch", commandArgs.savesPath, "for save file changes");
|
||||||
|
savesWatcher.on('change', (type, filename) =>
|
||||||
|
{
|
||||||
|
if (typeof filename === 'string')
|
||||||
|
{
|
||||||
|
console.log("Save File Changed", filename);
|
||||||
|
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bunGame.exited.then(() =>
|
||||||
|
{
|
||||||
|
savesWatcher.close();
|
||||||
|
console.log("Closing Save File Watching for", commandArgs.savesPath);
|
||||||
|
});
|
||||||
|
}*/
|
||||||
|
|
||||||
|
game = bunGame;
|
||||||
|
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
reject(new Error("No Emulator Bin"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeGame = {
|
||||||
|
process: game,
|
||||||
|
name: gameInfo?.name ?? "Unknown",
|
||||||
|
gameId: this.gameId,
|
||||||
|
source: this.gameSource,
|
||||||
|
sourceId: this.gameSourceId,
|
||||||
|
command: this.validCommand
|
||||||
|
};
|
||||||
|
} catch (e)
|
||||||
|
{
|
||||||
|
context.abort(e);
|
||||||
|
resolve(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.postPlay({ platformSlug: gameInfo?.platformSlug });
|
||||||
|
}
|
||||||
|
|
||||||
|
exposeData ()
|
||||||
|
{
|
||||||
|
return this.activeGame;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
63
src/bun/api/jobs/login-job.ts
Normal file
63
src/bun/api/jobs/login-job.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import Elysia, { status } from "elysia";
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
||||||
|
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
||||||
|
import { host, localIp } from "@/bun/utils/host";
|
||||||
|
import cors from "@elysiajs/cors";
|
||||||
|
import { tryLoginAndSave } from "../auth";
|
||||||
|
import { config } from "../app";
|
||||||
|
import z from "zod";
|
||||||
|
import { delay } from "@/shared/utils";
|
||||||
|
|
||||||
|
export class LoginJob implements IJob<z.infer<typeof LoginJob.dataSchema>, "base">
|
||||||
|
{
|
||||||
|
endsAt: Date;
|
||||||
|
startedAt: Date;
|
||||||
|
url: string;
|
||||||
|
static id = "login-job" as const;
|
||||||
|
static dataSchema = z.object({ endsAt: z.date(), startedAt: z.date(), url: z.url() });
|
||||||
|
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
this.endsAt = new Date(new Date().getTime() + 300000);
|
||||||
|
this.startedAt = new Date();
|
||||||
|
this.url = `http://${localIp}:${LOGIN_PORT}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
exposeData = (): z.infer<typeof LoginJob.dataSchema> => ({ endsAt: this.endsAt, startedAt: this.startedAt, url: this.url });
|
||||||
|
|
||||||
|
async start (context: JobContext<LoginJob, z.infer<typeof LoginJob.dataSchema>, "base">): Promise<void>
|
||||||
|
{
|
||||||
|
const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } })
|
||||||
|
.use(cors())
|
||||||
|
.get(`/`, ({ headers }) => process.env.PUBLIC_ACCESS ? fetch(`${SERVER_URL(host)}/auth/qr/`, { headers: headers as any }) : Bun.file(`./dist/auth/qr/index.html`))
|
||||||
|
.get(`/*`, ({ path, headers }) => process.env.PUBLIC_ACCESS ? fetch(`${SERVER_URL(host)}/auth/qr/${path}`, { headers: headers as any }) : Bun.file(`./dist/${path}`))
|
||||||
|
.get('/status', () => ({ expires_at: this.endsAt, max_time: 300000 }))
|
||||||
|
.post('/cancel', () => context.abort("cancel"))
|
||||||
|
.get('/defaults', () => ({ host: config.get('rommAddress'), username: config.get('rommUser') ?? '' }))
|
||||||
|
.post(`/login`, async ({ body }) =>
|
||||||
|
{
|
||||||
|
const response = await tryLoginAndSave(body as any);
|
||||||
|
if (response.response.ok)
|
||||||
|
{
|
||||||
|
context.abort("success");
|
||||||
|
return status("Accepted");
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loginServer.listen({});
|
||||||
|
await delay(this.endsAt, context.abortSignal);
|
||||||
|
} catch
|
||||||
|
{
|
||||||
|
} finally
|
||||||
|
{
|
||||||
|
await loginServer.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
62
src/bun/api/jobs/plugin-operation-job.ts
Normal file
62
src/bun/api/jobs/plugin-operation-job.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import z from "zod";
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
||||||
|
import { plugins } from "../app";
|
||||||
|
import { canUninstall, runBunPackageCommand } from "../plugins/services";
|
||||||
|
import { getPlugin, registerPlugin, unregisterPlugin } from "../plugins/register-plugins";
|
||||||
|
import { PluginRegistry } from "@/shared/constants";
|
||||||
|
|
||||||
|
export default class PluginOperationJob implements IJob<never, string>
|
||||||
|
{
|
||||||
|
static id = "plugin-operation-job" as const;
|
||||||
|
static dataSchema = z.never();
|
||||||
|
group = "plugin-operations";
|
||||||
|
operation: "add" | "update" | "remove";
|
||||||
|
plugin: string;
|
||||||
|
|
||||||
|
constructor(operation: "add" | "update" | "remove", plugin: string)
|
||||||
|
{
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.operation = operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<IJob<never, string>, never, string>)
|
||||||
|
{
|
||||||
|
switch (this.operation)
|
||||||
|
{
|
||||||
|
case "add":
|
||||||
|
//TODO: find the latest compatible version with the current sdk version
|
||||||
|
const addResponse = await runBunPackageCommand(["add", this.plugin, '--omit', 'peer', "--registry", PluginRegistry]);
|
||||||
|
console.log(addResponse);
|
||||||
|
const addPlugin = await getPlugin(this.plugin, plugins);
|
||||||
|
if (!addPlugin) throw new Error(`${this.plugin} Not Found`);
|
||||||
|
await registerPlugin(addPlugin, 'store', plugins);
|
||||||
|
break;
|
||||||
|
case "update":
|
||||||
|
const existingPlugin = plugins.plugins[this.plugin];
|
||||||
|
if (!existingPlugin) throw new Error(`${this.plugin} Not Found`);
|
||||||
|
if (!existingPlugin.update?.new) throw new Error(`No Update Found`);
|
||||||
|
let updatePlugin = await getPlugin(this.plugin, plugins);
|
||||||
|
if (!updatePlugin) throw new Error(`${this.plugin} Not Found`);
|
||||||
|
await unregisterPlugin(this.plugin, plugins);
|
||||||
|
const updateResponse = await runBunPackageCommand(["update", `${this.plugin}@${existingPlugin.update?.new}`, '--omit', 'peer', "--registry", PluginRegistry, '--latest']);
|
||||||
|
console.log(updateResponse);
|
||||||
|
updatePlugin = await getPlugin(this.plugin, plugins);
|
||||||
|
if (!updatePlugin) throw new Error(`Something Went Wrong during update. Missing Plugin: ${this.plugin}`);
|
||||||
|
await registerPlugin(updatePlugin, existingPlugin.source, plugins);
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
const removePlugin = plugins.plugins[this.plugin];
|
||||||
|
if (!removePlugin) throw new Error(`${this.plugin} Not Found`);
|
||||||
|
if (!canUninstall(removePlugin.description, removePlugin.source))
|
||||||
|
{
|
||||||
|
throw new Error("Uninstall Not Allowed");
|
||||||
|
}
|
||||||
|
const response = await runBunPackageCommand(['remove', this.plugin, "--registry", PluginRegistry, '--omit', 'peer']);
|
||||||
|
console.log(response);
|
||||||
|
await unregisterPlugin(this.plugin, plugins);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/bun/api/jobs/reload-plugins-job.ts
Normal file
15
src/bun/api/jobs/reload-plugins-job.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import z from "zod";
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
||||||
|
import { plugins } from "../app";
|
||||||
|
|
||||||
|
export default class ReloadPluginsJob implements IJob<never, string>
|
||||||
|
{
|
||||||
|
static id = "reload-plugins-job" as const;
|
||||||
|
static dataSchema = z.never();
|
||||||
|
group = "reload-plugins";
|
||||||
|
|
||||||
|
async start (context: JobContext<IJob<never, string>, never, string>)
|
||||||
|
{
|
||||||
|
await plugins.reloadAll(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/bun/api/jobs/self-update-job.ts
Normal file
121
src/bun/api/jobs/self-update-job.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import z from "zod";
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
||||||
|
import { events } from "../app";
|
||||||
|
import { Downloader } from "@/bun/utils/downloader";
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from "node:os";
|
||||||
|
import winUpdateScript from '@/bun/utils/update-gameflow-windows.bat' with { type: "text" };
|
||||||
|
import linuxUpdateScript from '@/bun/utils/update-gameflow-linux.sh' with { type: "text" };
|
||||||
|
import mustache from "mustache";
|
||||||
|
import pkg from '~/package.json';
|
||||||
|
import { sleep } from "bun";
|
||||||
|
|
||||||
|
export default class SelfUpdateJob implements IJob<never, string>
|
||||||
|
{
|
||||||
|
static id = "self-update-job" as const;
|
||||||
|
static dataSchema = z.never();
|
||||||
|
group = "self-update";
|
||||||
|
|
||||||
|
async downloadUpdate (url: URL, dest: string | undefined, filename: string, ctx: JobContext<IJob<never, string>, never, string>)
|
||||||
|
{
|
||||||
|
const downloader = new Downloader('update',
|
||||||
|
[{
|
||||||
|
url: url,
|
||||||
|
file_path: "",
|
||||||
|
file_name: filename
|
||||||
|
}],
|
||||||
|
dest,
|
||||||
|
{
|
||||||
|
onProgress (stats)
|
||||||
|
{
|
||||||
|
ctx.setProgress(stats.progress, "Downloading Update");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return downloader.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start (context: JobContext<IJob<never, string>, never, string>)
|
||||||
|
{
|
||||||
|
context.setProgress(0, "Downloading Update");
|
||||||
|
await sleep(1000);
|
||||||
|
const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest');
|
||||||
|
if (latest.ok)
|
||||||
|
{
|
||||||
|
const data = await latest.json();
|
||||||
|
let validAsset: any | undefined;
|
||||||
|
switch (process.platform)
|
||||||
|
{
|
||||||
|
case "win32":
|
||||||
|
validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.zip`).match(e.name));
|
||||||
|
if (!validAsset)
|
||||||
|
{
|
||||||
|
validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-*.zip`).match(e.name));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "linux":
|
||||||
|
validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.AppImage`).match(e.name));
|
||||||
|
if (!validAsset)
|
||||||
|
{
|
||||||
|
validAsset = data.assets.find((e: any) => new Bun.Glob(`*.AppImage`).match(e.name));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
events.emit('notification', { message: "Unsupported Platfrom", title: 'Failed Update', type: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validAsset)
|
||||||
|
{
|
||||||
|
events.emit('notification', { message: "Could not find download", title: 'Failed Update', type: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Found Download", validAsset.browser_download_url);
|
||||||
|
console.log("Starting Download");
|
||||||
|
|
||||||
|
switch (process.platform)
|
||||||
|
{
|
||||||
|
case "linux":
|
||||||
|
const appimage = process.env.APPIMAGE;
|
||||||
|
if (!appimage)
|
||||||
|
{
|
||||||
|
events.emit('notification', {
|
||||||
|
message: "Only AppImage supported",
|
||||||
|
title: 'Failed Update',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const linuxDownloads = await this.downloadUpdate(new URL(validAsset.browser_download_url), undefined, path.basename(appimage), context);
|
||||||
|
if (!linuxDownloads) return;
|
||||||
|
const shPath = path.join(os.tmpdir(), "update-gameflow.sh");
|
||||||
|
await Bun.write(shPath, mustache.render(linuxUpdateScript, {
|
||||||
|
tempFile: linuxDownloads[0],
|
||||||
|
appImagePath: appimage
|
||||||
|
}));
|
||||||
|
context.setProgress(0, "Restarting App To Update");
|
||||||
|
events.emit('exitapp');
|
||||||
|
Bun.spawn(["bash", shPath], { detached: true });
|
||||||
|
process.exit(0);
|
||||||
|
case "win32":
|
||||||
|
const winDownloads = await this.downloadUpdate(new URL(validAsset.browser_download_url), undefined, "Gameflow-update.zip", context);
|
||||||
|
if (!winDownloads) return;
|
||||||
|
const batPath = path.join(os.tmpdir(), "update-gameflow.bat");
|
||||||
|
await Bun.write(batPath, mustache.render(winUpdateScript, {
|
||||||
|
tempFile: winDownloads[0],
|
||||||
|
installDir: path.dirname(process.execPath),
|
||||||
|
extractDir: path.join(os.tmpdir(), 'gameflow-update-extract'),
|
||||||
|
exePath: `${pkg.bin}.exe`
|
||||||
|
}));
|
||||||
|
context.setProgress(0, "Restarting App To Update");
|
||||||
|
events.emit('exitapp');
|
||||||
|
Bun.spawn(["cmd", "/c", "start", "cmd", "/c", batPath], { detached: true });
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
events.emit('notification', { message: latest.statusText, title: 'Failed Update', type: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/bun/api/jobs/test-download-job.ts
Normal file
30
src/bun/api/jobs/test-download-job.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
|
||||||
|
import { sleep } from "bun";
|
||||||
|
|
||||||
|
export class TestDownloadJob implements IJob<DownloadJobData, string>
|
||||||
|
{
|
||||||
|
data: DownloadJobData = {
|
||||||
|
speed: 1686,
|
||||||
|
downloaded: 0,
|
||||||
|
total: 6615841,
|
||||||
|
name: "Test Download Job"
|
||||||
|
};
|
||||||
|
|
||||||
|
group = "test-download";
|
||||||
|
|
||||||
|
async start (context: JobContext<IJob<DownloadJobData, string>, DownloadJobData, string>): Promise<any>
|
||||||
|
{
|
||||||
|
for (let i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
await sleep(1000);
|
||||||
|
context.setProgress(i / 10 * 100, 'download');
|
||||||
|
if (context.abortSignal.aborted) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exposeData (): DownloadJobData
|
||||||
|
{
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
115
src/bun/api/jobs/twitch-login-job.ts
Normal file
115
src/bun/api/jobs/twitch-login-job.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk";
|
||||||
|
import secrets from "../secrets";
|
||||||
|
import open from "open";
|
||||||
|
import z from "zod";
|
||||||
|
import { delay } from "@/shared/utils";
|
||||||
|
import { plugins } from "../app";
|
||||||
|
|
||||||
|
|
||||||
|
interface TwitchDevice
|
||||||
|
{
|
||||||
|
device_code: string,
|
||||||
|
expires_in: number,
|
||||||
|
expires_at: Date,
|
||||||
|
started_at: Date,
|
||||||
|
interval: number,
|
||||||
|
user_code: string,
|
||||||
|
verification_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type States = "Retrieving Device" | "Waiting For Authentication";
|
||||||
|
|
||||||
|
export default class TwitchLoginJob implements IJob<z.infer<typeof TwitchLoginJob.dataSchema>, States>
|
||||||
|
{
|
||||||
|
twitchScopes = "analytics:read:extensions analytics:read:games user:read:email";
|
||||||
|
device?: TwitchDevice;
|
||||||
|
clientId: string;
|
||||||
|
openInBrowser: boolean;
|
||||||
|
static id = 'twitch-login-job' as const;
|
||||||
|
static dataSchema = z.object({ expires_at: z.date(), started_at: z.date(), url: z.url(), user_code: z.string() }).or(z.undefined());
|
||||||
|
|
||||||
|
constructor(clientId: string, openInBrowser: boolean)
|
||||||
|
{
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.openInBrowser = openInBrowser;
|
||||||
|
}
|
||||||
|
|
||||||
|
exposeData = (): z.infer<typeof TwitchLoginJob.dataSchema> => this.device ? ({
|
||||||
|
expires_at: this.device.expires_at,
|
||||||
|
started_at: this.device.started_at,
|
||||||
|
url: this.device.verification_uri,
|
||||||
|
user_code: this.device.user_code
|
||||||
|
}) : undefined;
|
||||||
|
|
||||||
|
async start (context: JobContext<TwitchLoginJob, z.infer<typeof TwitchLoginJob.dataSchema>, States>): Promise<any>
|
||||||
|
{
|
||||||
|
context.setProgress(0, "Retrieving Device");
|
||||||
|
let res = await fetch("https://id.twitch.tv/oauth2/device", {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: this.clientId,
|
||||||
|
scopes: this.twitchScopes
|
||||||
|
}),
|
||||||
|
signal: context.abortSignal
|
||||||
|
});
|
||||||
|
|
||||||
|
const device: TwitchDevice = await res.json();
|
||||||
|
const expiredTimeout = setTimeout(() => context.abort('expired'), device.expires_in * 1000);
|
||||||
|
device.expires_at = new Date(new Date().getTime() + device.expires_in * 1000);
|
||||||
|
device.started_at = new Date();
|
||||||
|
this.device = device;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (this.openInBrowser)
|
||||||
|
open(device.verification_uri);
|
||||||
|
this.device = device;
|
||||||
|
context.setProgress(50, "Waiting For Authentication");
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (context.abortSignal.aborted) break;
|
||||||
|
await delay(device.interval * 1000, context.abortSignal);
|
||||||
|
|
||||||
|
res = await fetch("https://id.twitch.tv/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: this.clientId,
|
||||||
|
scopes: this.twitchScopes,
|
||||||
|
device_code: this.device.device_code,
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
||||||
|
}),
|
||||||
|
signal: context.abortSignal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200)
|
||||||
|
{
|
||||||
|
const data: {
|
||||||
|
access_token: string,
|
||||||
|
expires_in: number,
|
||||||
|
refresh_token: string,
|
||||||
|
scope: string[],
|
||||||
|
token_type: string;
|
||||||
|
} = await res.json();
|
||||||
|
|
||||||
|
secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
|
||||||
|
secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
|
||||||
|
secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
|
||||||
|
|
||||||
|
await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (res.status !== 400)
|
||||||
|
{
|
||||||
|
console.error(res.statusText);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally
|
||||||
|
{
|
||||||
|
clearTimeout(expiredTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Notification } from '@shared/constants';
|
|
||||||
|
import { FrontendNotification } from '@simeonradivoev/gameflow-sdk/shared';
|
||||||
import { events } from './app';
|
import { events } from './app';
|
||||||
|
|
||||||
export default function buildNotificationsStream ()
|
export default function buildNotificationsStream ()
|
||||||
|
|
@ -10,7 +11,7 @@ export default function buildNotificationsStream ()
|
||||||
{
|
{
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
function enqueue (data: Notification, event?: 'notification')
|
function enqueue (data: FrontendNotification, event?: 'notification')
|
||||||
{
|
{
|
||||||
const evntString = event ? `event: ${event}\n` : '';
|
const evntString = event ? `event: ${event}\n` : '';
|
||||||
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
||||||
|
|
@ -30,7 +31,7 @@ export default function buildNotificationsStream ()
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
const notificationHandler = (notification: Notification) =>
|
const notificationHandler = (notification: FrontendNotification) =>
|
||||||
{
|
{
|
||||||
enqueue(notification, 'notification');
|
enqueue(notification, 'notification');
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
||||||
|
import desc from './package.json';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { config } from "@/bun/api/app";
|
||||||
|
|
||||||
|
export default class CEMUIntegration implements PluginType
|
||||||
|
{
|
||||||
|
emulator = 'CEMU';
|
||||||
|
|
||||||
|
async load (ctx: PluginLoadingContextType)
|
||||||
|
{
|
||||||
|
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
||||||
|
{
|
||||||
|
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] };
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
|
||||||
|
{
|
||||||
|
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||||
|
validChangedSaveFiles[this.emulator] = {
|
||||||
|
cwd: saveFolderSlots[this.emulator].cwd,
|
||||||
|
shared: true,
|
||||||
|
subPath: '*.{tga,xml,dat}',
|
||||||
|
isGlob: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||||
|
{
|
||||||
|
const args: string[] = [];
|
||||||
|
|
||||||
|
args.push(`--fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`);
|
||||||
|
|
||||||
|
const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator);
|
||||||
|
|
||||||
|
args.push(`--mlc=${savesPath}`);
|
||||||
|
|
||||||
|
if (ctx.autoValidCommand.metadata.romPath)
|
||||||
|
{
|
||||||
|
args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { args, savesPath: { [this.emulator]: { cwd: savesPath } } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "com.simeonradivoev.gameflow.cemu",
|
||||||
|
"displayName": "CEMU Integration",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "CEMU Emulator Integration",
|
||||||
|
"main": "./cemu.ts",
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png",
|
||||||
|
"category": "emulators",
|
||||||
|
"keywords": [
|
||||||
|
"integration",
|
||||||
|
"emulator",
|
||||||
|
"wiiu",
|
||||||
|
"cemu"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
|
||||||
|
import { config } from "@/bun/api/app";
|
||||||
|
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
||||||
|
import path from 'node:path';
|
||||||
|
import desc from './package.json';
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import { getSavePaths, getType } from "./utils";
|
||||||
|
|
||||||
|
export default class DOLPHINIntegration implements PluginType
|
||||||
|
{
|
||||||
|
emulator = 'DOLPHIN';
|
||||||
|
|
||||||
|
async load (ctx: PluginLoadingContextType)
|
||||||
|
{
|
||||||
|
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
||||||
|
{
|
||||||
|
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "saves"] };
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||||
|
{
|
||||||
|
await Bun.write(path.join(ctx.path, "portable.txt"), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||||
|
{
|
||||||
|
const args: string[] = [];
|
||||||
|
|
||||||
|
const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator);
|
||||||
|
args.push(`--user=${storageFolder}`);
|
||||||
|
|
||||||
|
args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`);
|
||||||
|
args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`);
|
||||||
|
args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`);
|
||||||
|
args.push(`--config=Dolphin.Interface.ConfirmStop=False`);
|
||||||
|
args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`);
|
||||||
|
args.push(`--config=Dolphin.Analytics.PermissionAsked=True`);
|
||||||
|
|
||||||
|
const resolution = config.get('emulatorResolution');
|
||||||
|
const resolutionMapping = {
|
||||||
|
"720p": 2,
|
||||||
|
"1080p": 3,
|
||||||
|
"1440p": 4,
|
||||||
|
"4k": 6
|
||||||
|
};
|
||||||
|
args.push(`--config=GFX.Settings.InternalResolution=${resolutionMapping[resolution] ?? 1}`);
|
||||||
|
args.push(`--config=GFX.Settings.wideScreenHack=${config.get('emulatorWidescreen') ? "True" : "False"}`);
|
||||||
|
args.push(`--config=GFX.Settings.AspectRatio=${config.get('emulatorWidescreen') ? "1" : "0"}`);
|
||||||
|
|
||||||
|
const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator);
|
||||||
|
|
||||||
|
args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`);
|
||||||
|
args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`);
|
||||||
|
args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`);
|
||||||
|
args.push(`--config=Dolphin.Core.GCIFolderAPath=${path.join(savesPath, 'GC')}`);
|
||||||
|
|
||||||
|
if (!ctx.dryRun)
|
||||||
|
{
|
||||||
|
await ensureDir(path.join(savesPath, 'GC', "JAP"));
|
||||||
|
await ensureDir(path.join(savesPath, 'GC', "EUR"));
|
||||||
|
await ensureDir(path.join(savesPath, 'GC', "USA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalSavesPath: string | undefined = undefined;
|
||||||
|
if (ctx.autoValidCommand.metadata.romPath)
|
||||||
|
{
|
||||||
|
args.push("--batch");
|
||||||
|
args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`);
|
||||||
|
|
||||||
|
finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder;
|
||||||
|
return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { args };
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) =>
|
||||||
|
{
|
||||||
|
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||||
|
validChangedSaveFiles[this.emulator] = {
|
||||||
|
cwd: saveFolderSlots[this.emulator].cwd,
|
||||||
|
subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir),
|
||||||
|
shared: false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "com.simeonradivoev.gameflow.dolphin",
|
||||||
|
"displayName": "DOLPHIN Integration",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "DOLPHIN Emulator Integration",
|
||||||
|
"main": "./dolphin.ts",
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg",
|
||||||
|
"category": "emulators",
|
||||||
|
"keywords": [
|
||||||
|
"integration",
|
||||||
|
"emulator",
|
||||||
|
"wii",
|
||||||
|
"gc",
|
||||||
|
"dolphin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { join } from "path";
|
||||||
|
import { platform } from "os";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
type DolphinLocation =
|
||||||
|
| { type: "path"; toolPath: string; }
|
||||||
|
| { type: "appimage"; appImagePath: string; };
|
||||||
|
|
||||||
|
async function findDolphinTool (bundledDir?: string): Promise<DolphinLocation>
|
||||||
|
{
|
||||||
|
const os = platform();
|
||||||
|
const toolName = os === "win32" ? "DolphinTool.exe" : "dolphin-tool";
|
||||||
|
|
||||||
|
if (bundledDir)
|
||||||
|
{
|
||||||
|
if (os === "linux")
|
||||||
|
{
|
||||||
|
const glob = new Bun.Glob("*.AppImage");
|
||||||
|
for await (const file of glob.scan(bundledDir))
|
||||||
|
{
|
||||||
|
return { type: "appimage", appImagePath: join(bundledDir, file) };
|
||||||
|
}
|
||||||
|
throw new Error(`No AppImage found in ${bundledDir}`);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
return { type: "path", toolPath: join(bundledDir, toolName) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback 1: check PATH
|
||||||
|
const inPath = Bun.which(toolName);
|
||||||
|
if (inPath) return { type: "path", toolPath: inPath };
|
||||||
|
|
||||||
|
// Fallback 2: platform default install locations
|
||||||
|
if (os === "win32")
|
||||||
|
{
|
||||||
|
const candidates = [
|
||||||
|
"C:/Program Files/Dolphin/DolphinTool.exe",
|
||||||
|
"C:/Program Files (x86)/Dolphin/DolphinTool.exe",
|
||||||
|
];
|
||||||
|
for (const candidate of candidates)
|
||||||
|
{
|
||||||
|
if (await Bun.file(candidate).exists())
|
||||||
|
{
|
||||||
|
return { type: "path", toolPath: candidate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (os === "darwin")
|
||||||
|
{
|
||||||
|
const candidate = "/Applications/Dolphin.app/Contents/MacOS/dolphin-tool";
|
||||||
|
if (await Bun.file(candidate).exists())
|
||||||
|
{
|
||||||
|
return { type: "path", toolPath: candidate };
|
||||||
|
}
|
||||||
|
} else if (os === "linux")
|
||||||
|
{
|
||||||
|
const home = process.env.HOME ?? "";
|
||||||
|
const candidates = [
|
||||||
|
join(home, "Applications/Dolphin-x86_64.AppImage"),
|
||||||
|
join(home, "Applications/Dolphin.AppImage"),
|
||||||
|
"/opt/Dolphin-x86_64.AppImage",
|
||||||
|
];
|
||||||
|
for (const candidate of candidates)
|
||||||
|
{
|
||||||
|
if (await Bun.file(candidate).exists())
|
||||||
|
{
|
||||||
|
return { type: "appimage", appImagePath: candidate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Could not find ${toolName}. Install Dolphin or pass its folder path explicitly.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDolphinTool (args: string[], location: DolphinLocation): Promise<string>
|
||||||
|
{
|
||||||
|
if (location.type === "path")
|
||||||
|
{
|
||||||
|
const proc = Bun.spawnSync([location.toolPath, ...args]);
|
||||||
|
if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`);
|
||||||
|
return proc.stdout.toString();
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const mount = Bun.spawn([location.appImagePath, "--appimage-mount"], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const mountPoint = (await new Response(mount.stdout).text()).trim();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const proc = Bun.spawnSync([`${mountPoint}/usr/bin/dolphin-tool`, ...args]);
|
||||||
|
if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`);
|
||||||
|
return proc.stdout.toString();
|
||||||
|
} finally
|
||||||
|
{
|
||||||
|
mount.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readGameId (romPath: string, location: DolphinLocation): Promise<string>
|
||||||
|
{
|
||||||
|
const output = await runDolphinTool(["header", "-i", romPath], location);
|
||||||
|
const match = output.match(/Game ID:\s*(\w{6})/);
|
||||||
|
if (!match) throw new Error("Could not read game ID");
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegion (regionCode: string)
|
||||||
|
{
|
||||||
|
switch (regionCode)
|
||||||
|
{
|
||||||
|
case "E": return "USA";
|
||||||
|
case "P": return "EUR";
|
||||||
|
case "J": return "JAP";
|
||||||
|
default: return "USA";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGCSavePaths (romPath: string, savesPath: string, location: DolphinLocation)
|
||||||
|
{
|
||||||
|
const gameId = await readGameId(romPath, location);
|
||||||
|
const region = getRegion(gameId[3]);
|
||||||
|
|
||||||
|
const makerCode = gameId.slice(4, 6); // e.g. "01" or "7D" — already the right format
|
||||||
|
const gameCode = gameId.slice(0, 4); // e.g. "GZLE" or "GM5E"
|
||||||
|
const cardPath = join(savesPath, "GC", region);
|
||||||
|
|
||||||
|
const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`);
|
||||||
|
const saves: string[] = [];
|
||||||
|
for await (const file of glob.scan(cardPath))
|
||||||
|
{
|
||||||
|
saves.push(path.join("GC", region, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
return saves;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getType (romPath: string, bundledEmulatorDir?: string): Promise<"gamecube" | "wii">
|
||||||
|
{
|
||||||
|
const location = await findDolphinTool(bundledEmulatorDir);
|
||||||
|
const gameId = await readGameId(romPath, location);
|
||||||
|
const isGameCube = gameId[0] === "G" || gameId[0] === "D";
|
||||||
|
return isGameCube ? "gamecube" : "wii";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise<string[]>
|
||||||
|
{
|
||||||
|
const location = await findDolphinTool(bundledEmulatorDir);
|
||||||
|
const gameId = await readGameId(romPath, location);
|
||||||
|
const isGameCube = gameId[0] === "G" || gameId[0] === "D";
|
||||||
|
|
||||||
|
if (isGameCube)
|
||||||
|
{
|
||||||
|
return getGCSavePaths(romPath, savesPath, location);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase();
|
||||||
|
const rootFolder = join(savesPath, "Wii", "title", "00010000", folder);
|
||||||
|
const files = await fs.readdir(rootFolder, { recursive: true });
|
||||||
|
return files.map(f => path.join("Wii", "title", "00010000", f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
[UI]
|
||||||
|
SettingsVersion = 1
|
||||||
|
InhibitScreensaver = true
|
||||||
|
ConfirmShutdown = true
|
||||||
|
StartPaused = false
|
||||||
|
PauseOnFocusLoss = false
|
||||||
|
StartFullscreen = false
|
||||||
|
DoubleClickTogglesFullscreen = true
|
||||||
|
HideMouseCursor = false
|
||||||
|
RenderToSeparateWindow = false
|
||||||
|
HideMainWindowWhenRunning = false
|
||||||
|
DisableWindowResize = false
|
||||||
|
Theme = darkfusion
|
||||||
|
SetupWizardIncomplete = false
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore]
|
||||||
|
CdvdVerboseReads = false
|
||||||
|
CdvdDumpBlocks = false
|
||||||
|
CdvdShareWrite = false
|
||||||
|
EnablePatches = true
|
||||||
|
EnableCheats = false
|
||||||
|
EnablePINE = false
|
||||||
|
EnableNoInterlacingPatches = false
|
||||||
|
EnableRecordingTools = true
|
||||||
|
EnableGameFixes = true
|
||||||
|
SaveStateOnShutdown = false
|
||||||
|
EnableDiscordPresence = false
|
||||||
|
InhibitScreensaver = true
|
||||||
|
ConsoleToStdio = false
|
||||||
|
HostFs = false
|
||||||
|
BackupSavestate = true
|
||||||
|
SavestateZstdCompression = true
|
||||||
|
McdEnableEjection = true
|
||||||
|
McdFolderAutoManage = true
|
||||||
|
WarnAboutUnsafeSettings = true
|
||||||
|
GzipIsoIndexTemplate = $(f).pindex.tmp
|
||||||
|
BlockDumpSaveDirectory =
|
||||||
|
EnableFastBoot = true
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/Speedhacks]
|
||||||
|
EECycleRate = 0
|
||||||
|
EECycleSkip = 0
|
||||||
|
fastCDVD = false
|
||||||
|
IntcStat = true
|
||||||
|
WaitLoop = true
|
||||||
|
vuFlagHack = true
|
||||||
|
vuThread = true
|
||||||
|
vu1Instant = true
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/CPU]
|
||||||
|
FPU.DenormalsAreZero = true
|
||||||
|
FPU.FlushToZero = true
|
||||||
|
FPU.Roundmode = 3
|
||||||
|
AffinityControlMode = 0
|
||||||
|
VU0.DenormalsAreZero = true
|
||||||
|
VU0.FlushToZero = true
|
||||||
|
VU0.Roundmode = 3
|
||||||
|
VU1.DenormalsAreZero = true
|
||||||
|
VU1.FlushToZero = true
|
||||||
|
VU1.Roundmode = 3
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/CPU/Recompiler]
|
||||||
|
EnableEE = true
|
||||||
|
EnableIOP = true
|
||||||
|
EnableEECache = false
|
||||||
|
EnableVU0 = true
|
||||||
|
EnableVU1 = true
|
||||||
|
EnableFastmem = true
|
||||||
|
PauseOnTLBMiss = false
|
||||||
|
vu0Overflow = true
|
||||||
|
vu0ExtraOverflow = false
|
||||||
|
vu0SignOverflow = false
|
||||||
|
vu0Underflow = false
|
||||||
|
vu1Overflow = true
|
||||||
|
vu1ExtraOverflow = false
|
||||||
|
vu1SignOverflow = false
|
||||||
|
vu1Underflow = false
|
||||||
|
fpuOverflow = true
|
||||||
|
fpuExtraOverflow = false
|
||||||
|
fpuFullMode = false
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/GS]
|
||||||
|
VsyncQueueSize = 2
|
||||||
|
FrameLimitEnable = true
|
||||||
|
VsyncEnable = 0
|
||||||
|
FramerateNTSC = 59.94
|
||||||
|
FrameratePAL = 50
|
||||||
|
SyncToHostRefreshRate = false
|
||||||
|
AspectRatio = {{ASPECT_RATIO}}
|
||||||
|
FMVAspectRatioSwitch = Off
|
||||||
|
ScreenshotSize = 0
|
||||||
|
ScreenshotFormat = 0
|
||||||
|
ScreenshotQuality = 50
|
||||||
|
StretchY = 100
|
||||||
|
CropLeft = 0
|
||||||
|
CropTop = 0
|
||||||
|
CropRight = 0
|
||||||
|
CropBottom = 0
|
||||||
|
pcrtc_antiblur = true
|
||||||
|
disable_interlace_offset = false
|
||||||
|
pcrtc_offsets = false
|
||||||
|
pcrtc_overscan = false
|
||||||
|
IntegerScaling = false
|
||||||
|
UseDebugDevice = false
|
||||||
|
UseBlitSwapChain = false
|
||||||
|
disable_shader_cache = false
|
||||||
|
DisableDualSourceBlend = false
|
||||||
|
DisableFramebufferFetch = false
|
||||||
|
DisableThreadedPresentation = false
|
||||||
|
SkipDuplicateFrames = false
|
||||||
|
OsdShowMessages = true
|
||||||
|
OsdShowSpeed = false
|
||||||
|
OsdShowFPS = false
|
||||||
|
OsdShowCPU = false
|
||||||
|
OsdShowGPU = false
|
||||||
|
OsdShowResolution = false
|
||||||
|
OsdShowGSStats = false
|
||||||
|
OsdShowIndicators = true
|
||||||
|
OsdShowSettings = false
|
||||||
|
OsdShowInputs = false
|
||||||
|
OsdShowFrameTimes = false
|
||||||
|
HWSpinGPUForReadbacks = false
|
||||||
|
HWSpinCPUForReadbacks = false
|
||||||
|
paltex = false
|
||||||
|
autoflush_sw = true
|
||||||
|
preload_frame_with_gs_data = false
|
||||||
|
mipmap = true
|
||||||
|
UserHacks = false
|
||||||
|
UserHacks_align_sprite_X = false
|
||||||
|
UserHacks_AutoFlush = false
|
||||||
|
UserHacks_CPU_FB_Conversion = false
|
||||||
|
UserHacks_ReadTCOnClose = false
|
||||||
|
UserHacks_DisableDepthSupport = false
|
||||||
|
UserHacks_DisablePartialInvalidation = false
|
||||||
|
UserHacks_Disable_Safe_Features = false
|
||||||
|
UserHacks_merge_pp_sprite = false
|
||||||
|
UserHacks_WildHack = false
|
||||||
|
UserHacks_TextureInsideRt = 0
|
||||||
|
UserHacks_TargetPartialInvalidation = false
|
||||||
|
UserHacks_EstimateTextureRegion = false
|
||||||
|
fxaa = false
|
||||||
|
ShadeBoost = false
|
||||||
|
dump = false
|
||||||
|
save = false
|
||||||
|
savef = false
|
||||||
|
savet = false
|
||||||
|
savez = false
|
||||||
|
DumpReplaceableTextures = false
|
||||||
|
DumpReplaceableMipmaps = false
|
||||||
|
DumpTexturesWithFMVActive = false
|
||||||
|
DumpDirectTextures = true
|
||||||
|
DumpPaletteTextures = true
|
||||||
|
LoadTextureReplacements = false
|
||||||
|
LoadTextureReplacementsAsync = true
|
||||||
|
PrecacheTextureReplacements = false
|
||||||
|
EnableVideoCapture = true
|
||||||
|
EnableVideoCaptureParameters = false
|
||||||
|
VideoCaptureAutoResolution = false
|
||||||
|
EnableAudioCapture = true
|
||||||
|
EnableAudioCaptureParameters = false
|
||||||
|
linear_present_mode = 1
|
||||||
|
deinterlace_mode = 0
|
||||||
|
OsdScale = 100
|
||||||
|
Renderer = 14
|
||||||
|
mipmap_hw = -1
|
||||||
|
accurate_blending_unit = 1
|
||||||
|
crc_hack_level = -1
|
||||||
|
filter = 2
|
||||||
|
texture_preloading = 2
|
||||||
|
GSDumpCompression = 2
|
||||||
|
HWDownloadMode = 0
|
||||||
|
CASMode = 0
|
||||||
|
CASSharpness = 50
|
||||||
|
dithering_ps2 = 2
|
||||||
|
MaxAnisotropy = 0
|
||||||
|
extrathreads = 3
|
||||||
|
extrathreads_height = 4
|
||||||
|
TVShader = 0
|
||||||
|
UserHacks_SkipDraw_Start = 0
|
||||||
|
UserHacks_SkipDraw_End = 0
|
||||||
|
UserHacks_Half_Bottom_Override = -1
|
||||||
|
UserHacks_HalfPixelOffset = 0
|
||||||
|
UserHacks_round_sprite_offset = 0
|
||||||
|
UserHacks_TCOffsetX = 0
|
||||||
|
UserHacks_TCOffsetY = 0
|
||||||
|
UserHacks_CPUSpriteRenderBW = 0
|
||||||
|
UserHacks_CPUCLUTRender = 0
|
||||||
|
UserHacks_GPUTargetCLUTMode = 0
|
||||||
|
TriFilter = -1
|
||||||
|
OverrideTextureBarriers = -1
|
||||||
|
OverrideGeometryShaders = -1
|
||||||
|
ShadeBoost_Brightness = 50
|
||||||
|
ShadeBoost_Contrast = 50
|
||||||
|
ShadeBoost_Saturation = 50
|
||||||
|
png_compression_level = 1
|
||||||
|
saven = 0
|
||||||
|
savel = 5000
|
||||||
|
CaptureContainer = mp4
|
||||||
|
VideoCaptureCodec =
|
||||||
|
VideoCaptureParameters =
|
||||||
|
AudioCaptureCodec =
|
||||||
|
AudioCaptureParameters =
|
||||||
|
VideoCaptureBitrate = 6000
|
||||||
|
VideoCaptureWidth = 640
|
||||||
|
VideoCaptureHeight = 480
|
||||||
|
AudioCaptureBitrate = 160
|
||||||
|
Adapter = (Default)
|
||||||
|
HWDumpDirectory =
|
||||||
|
SWDumpDirectory =
|
||||||
|
|
||||||
|
|
||||||
|
[SPU2/Debug]
|
||||||
|
Global_Enable = false
|
||||||
|
Show_Messages = false
|
||||||
|
Show_Messages_Key_On_Off = false
|
||||||
|
Show_Messages_Voice_Off = false
|
||||||
|
Show_Messages_DMA_Transfer = false
|
||||||
|
Show_Messages_AutoDMA = false
|
||||||
|
Show_Messages_Overruns = false
|
||||||
|
Show_Messages_CacheStats = false
|
||||||
|
Log_Register_Access = false
|
||||||
|
Log_DMA_Transfers = false
|
||||||
|
Log_WAVE_Output = false
|
||||||
|
Dump_Info = false
|
||||||
|
Dump_Memory = false
|
||||||
|
Dump_Regs = false
|
||||||
|
|
||||||
|
|
||||||
|
[SPU2/Mixing]
|
||||||
|
FinalVolume = 100
|
||||||
|
|
||||||
|
|
||||||
|
[SPU2/Output]
|
||||||
|
OutputModule = cubeb
|
||||||
|
BackendName =
|
||||||
|
DeviceName =
|
||||||
|
Latency = 60
|
||||||
|
OutputLatency = 20
|
||||||
|
OutputLatencyMinimal = false
|
||||||
|
SynchMode = 0
|
||||||
|
SpeakerConfiguration = 0
|
||||||
|
DplDecodingLevel = 0
|
||||||
|
|
||||||
|
|
||||||
|
[DEV9/Eth]
|
||||||
|
EthEnable = false
|
||||||
|
EthApi = Unset
|
||||||
|
EthDevice =
|
||||||
|
EthLogDNS = false
|
||||||
|
InterceptDHCP = false
|
||||||
|
PS2IP = 0.0.0.0
|
||||||
|
Mask = 0.0.0.0
|
||||||
|
Gateway = 0.0.0.0
|
||||||
|
DNS1 = 0.0.0.0
|
||||||
|
DNS2 = 0.0.0.0
|
||||||
|
AutoMask = true
|
||||||
|
AutoGateway = true
|
||||||
|
ModeDNS1 = Auto
|
||||||
|
ModeDNS2 = Auto
|
||||||
|
|
||||||
|
|
||||||
|
[DEV9/Eth/Hosts]
|
||||||
|
Count = 0
|
||||||
|
|
||||||
|
|
||||||
|
[DEV9/Hdd]
|
||||||
|
HddEnable = false
|
||||||
|
HddFile = DEV9hdd.raw
|
||||||
|
HddSizeSectors = 83886080
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/Gamefixes]
|
||||||
|
VuAddSubHack = false
|
||||||
|
FpuMulHack = false
|
||||||
|
FpuNegDivHack = false
|
||||||
|
XgKickHack = false
|
||||||
|
EETimingHack = false
|
||||||
|
InstantDMAHack = false
|
||||||
|
SoftwareRendererFMVHack = false
|
||||||
|
SkipMPEGHack = false
|
||||||
|
OPHFlagHack = false
|
||||||
|
DMABusyHack = false
|
||||||
|
VIFFIFOHack = false
|
||||||
|
VIF1StallHack = false
|
||||||
|
GIFFIFOHack = false
|
||||||
|
GoemonTlbHack = false
|
||||||
|
IbitHack = false
|
||||||
|
VUSyncHack = false
|
||||||
|
VUOverflowHack = false
|
||||||
|
BlitInternalFPSHack = false
|
||||||
|
FullVU0SyncHack = false
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/Profiler]
|
||||||
|
Enabled = false
|
||||||
|
RecBlocks_EE = true
|
||||||
|
RecBlocks_IOP = true
|
||||||
|
RecBlocks_VU0 = true
|
||||||
|
RecBlocks_VU1 = true
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/Debugger]
|
||||||
|
ShowDebuggerOnStart = false
|
||||||
|
AlignMemoryWindowStart = true
|
||||||
|
FontWidth = 8
|
||||||
|
FontHeight = 12
|
||||||
|
WindowWidth = 0
|
||||||
|
WindowHeight = 0
|
||||||
|
MemoryViewBytesPerRow = 16
|
||||||
|
|
||||||
|
|
||||||
|
[EmuCore/TraceLog]
|
||||||
|
Enabled = false
|
||||||
|
EE.bitset = 0
|
||||||
|
IOP.bitset = 0
|
||||||
|
|
||||||
|
|
||||||
|
[USB1]
|
||||||
|
Type = None
|
||||||
|
|
||||||
|
|
||||||
|
[USB2]
|
||||||
|
Type = None
|
||||||
|
|
||||||
|
|
||||||
|
[Achievements]
|
||||||
|
Enabled = false
|
||||||
|
TestMode = false
|
||||||
|
UnofficialTestMode = false
|
||||||
|
RichPresence = true
|
||||||
|
ChallengeMode = false
|
||||||
|
Leaderboards = true
|
||||||
|
Notifications = true
|
||||||
|
SoundEffects = true
|
||||||
|
PrimedIndicators = true
|
||||||
|
|
||||||
|
|
||||||
|
[Filenames]
|
||||||
|
BIOS =
|
||||||
|
|
||||||
|
|
||||||
|
[Framerate]
|
||||||
|
NominalScalar = 1
|
||||||
|
TurboScalar = 2
|
||||||
|
SlomoScalar = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
[MemoryCards]
|
||||||
|
Slot1_Enable = true
|
||||||
|
Slot1_Filename = Mcd001.ps2
|
||||||
|
Slot2_Enable = true
|
||||||
|
Slot2_Filename = Mcd002.ps2
|
||||||
|
Multitap1_Slot2_Enable = false
|
||||||
|
Multitap1_Slot2_Filename = Mcd-Multitap1-Slot02.ps2
|
||||||
|
Multitap1_Slot3_Enable = false
|
||||||
|
Multitap1_Slot3_Filename = Mcd-Multitap1-Slot03.ps2
|
||||||
|
Multitap1_Slot4_Enable = false
|
||||||
|
Multitap1_Slot4_Filename = Mcd-Multitap1-Slot04.ps2
|
||||||
|
Multitap2_Slot2_Enable = false
|
||||||
|
Multitap2_Slot2_Filename = Mcd-Multitap2-Slot02.ps2
|
||||||
|
Multitap2_Slot3_Enable = false
|
||||||
|
Multitap2_Slot3_Filename = Mcd-Multitap2-Slot03.ps2
|
||||||
|
Multitap2_Slot4_Enable = false
|
||||||
|
Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2
|
||||||
|
|
||||||
|
|
||||||
|
[InputSources]
|
||||||
|
Keyboard = true
|
||||||
|
Mouse = true
|
||||||
|
SDL = true
|
||||||
|
SDLControllerEnhancedMode = false
|
||||||
|
|
||||||
|
|
||||||
|
[Hotkeys]
|
||||||
|
ToggleFullscreen = SDL-0/Start & SDL-0/LeftStick
|
||||||
|
CycleInterlaceMode = Keyboard/F5
|
||||||
|
CycleMipmapMode = Keyboard/Insert
|
||||||
|
GSDumpMultiFrame = Keyboard/Control & Keyboard/Shift & Keyboard/F8
|
||||||
|
Screenshot = Keyboard/F8
|
||||||
|
GSDumpSingleFrame = Keyboard/Shift & Keyboard/F8
|
||||||
|
ZoomIn = Keyboard/Control & Keyboard/Plus
|
||||||
|
ZoomOut = Keyboard/Control & Keyboard/Minus
|
||||||
|
InputRecToggleMode = Keyboard/Shift & Keyboard/R
|
||||||
|
LoadStateFromSlot = SDL-0/Back & SDL-0/LeftShoulder
|
||||||
|
SaveStateToSlot = SDL-0/Back & SDL-0/RightShoulder
|
||||||
|
ShutdownVM = SDL-0/Back & SDL-0/Start
|
||||||
|
ToggleFrameLimit = Keyboard/F4
|
||||||
|
TogglePause = SDL-0/Back & SDL-0/A
|
||||||
|
ToggleSlowMotion = SDL-0/Back & SDL-0/+LeftTrigger
|
||||||
|
ToggleTurbo = SDL-0/Back & SDL-0/+RightTrigger
|
||||||
|
HoldTurbo = Keyboard/Period
|
||||||
|
ResetVM = SDL-0/Back & SDL-0/LeftStick
|
||||||
|
OpenPauseMenu = SDL-0/Back & SDL-0/RightStick
|
||||||
|
IncreaseUpscaleMultiplier = SDL-0/Start & SDL-0/DPadUp
|
||||||
|
DecreaseUpscaleMultiplier = SDL-0/Start & SDL-0/DPadDown
|
||||||
|
CycleAspectRatio = SDL-0/Start & SDL-0/DPadRight
|
||||||
|
ToggleSoftwareRendering = SDL-0/Start & SDL-0/DPadLeft
|
||||||
|
ToggleSoftwareRendering = Keyboard/F9
|
||||||
|
NextSaveStateSlot = SDL-0/Start & SDL-0/RightShoulder
|
||||||
|
PreviousSaveStateSlot = SDL-0/Start & SDL-0/LeftShoulder
|
||||||
|
|
||||||
|
[Pad1]
|
||||||
|
Type = DualShock2
|
||||||
|
Deadzone = 0.000000
|
||||||
|
AxisScale = 1.330000
|
||||||
|
LargeMotorScale = 1.000000
|
||||||
|
SmallMotorScale = 1.000000
|
||||||
|
PressureModifier = 0.5
|
||||||
|
Up = SDL-0/DPadUp
|
||||||
|
Right = SDL-0/DPadRight
|
||||||
|
Down = SDL-0/DPadDown
|
||||||
|
Left = SDL-0/DPadLeft
|
||||||
|
Triangle = SDL-0/Y
|
||||||
|
Circle = SDL-0/B
|
||||||
|
Cross = SDL-0/A
|
||||||
|
Square = SDL-0/X
|
||||||
|
Select = SDL-0/Back
|
||||||
|
Start = SDL-0/Start
|
||||||
|
L1 = SDL-0/LeftShoulder
|
||||||
|
L2 = SDL-0/+LeftTrigger
|
||||||
|
R1 = SDL-0/RightShoulder
|
||||||
|
R2 = SDL-0/+RightTrigger
|
||||||
|
L3 = SDL-0/LeftStick
|
||||||
|
R3 = SDL-0/RightStick
|
||||||
|
LUp = SDL-0/-LeftY
|
||||||
|
LRight = SDL-0/+LeftX
|
||||||
|
LDown = SDL-0/+LeftY
|
||||||
|
LLeft = SDL-0/-LeftX
|
||||||
|
RUp = SDL-0/-RightY
|
||||||
|
RRight = SDL-0/+RightX
|
||||||
|
RDown = SDL-0/+RightY
|
||||||
|
RLeft = SDL-0/-RightX
|
||||||
|
Analog = SDL-0/Guide
|
||||||
|
LargeMotor = SDL-0/LargeMotor
|
||||||
|
SmallMotor = SDL-0/SmallMotor
|
||||||
|
Pressure = Keyboard/S
|
||||||
|
|
||||||
|
[Pad2]
|
||||||
|
Type = DualShock2
|
||||||
|
Deadzone = 0.000000
|
||||||
|
AxisScale = 1.330000
|
||||||
|
LargeMotorScale = 1.000000
|
||||||
|
SmallMotorScale = 1.000000
|
||||||
|
PressureModifier = 0.300000
|
||||||
|
Up = SDL-1/DPadUp
|
||||||
|
Right = SDL-1/DPadRight
|
||||||
|
Down = SDL-1/DPadDown
|
||||||
|
Left = SDL-1/DPadLeft
|
||||||
|
Triangle = SDL-1/Y
|
||||||
|
Circle = SDL-1/B
|
||||||
|
Cross = SDL-1/A
|
||||||
|
Square = SDL-1/X
|
||||||
|
Select = SDL-1/Back
|
||||||
|
Start = SDL-1/Start
|
||||||
|
L1 = SDL-1/LeftShoulder
|
||||||
|
L2 = SDL-1/+LeftTrigger
|
||||||
|
R1 = SDL-1/RightShoulder
|
||||||
|
R2 = SDL-1/+RightTrigger
|
||||||
|
L3 = SDL-1/LeftStick
|
||||||
|
R3 = SDL-1/RightStick
|
||||||
|
Analog = SDL-1/Guide
|
||||||
|
LUp = SDL-1/-LeftY
|
||||||
|
LRight = SDL-1/+LeftX
|
||||||
|
LDown = SDL-1/+LeftY
|
||||||
|
LLeft = SDL-1/-LeftX
|
||||||
|
RUp = SDL-1/-RightY
|
||||||
|
RRight = SDL-1/+RightX
|
||||||
|
RDown = SDL-1/+RightY
|
||||||
|
RLeft = SDL-1/-RightX
|
||||||
|
LargeMotor = SDL-1/LargeMotor
|
||||||
|
SmallMotor = SDL-1/SmallMotor
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "com.simeonradivoev.gameflow.pcsx2",
|
||||||
|
"displayName": "PCSX2 Integration",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "PCSX2 Emulator Integration",
|
||||||
|
"main": "./pcsx2.ts",
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png",
|
||||||
|
"category": "emulators",
|
||||||
|
"keywords": [
|
||||||
|
"integration",
|
||||||
|
"emulator",
|
||||||
|
"ps2",
|
||||||
|
"pcsx2"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
|
||||||
|
import { config } from "@/bun/api/app";
|
||||||
|
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
||||||
|
import defaultConfig from './PCSX2.ini' with { type: 'file' };
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import desc from './package.json';
|
||||||
|
import ini from 'ini';
|
||||||
|
import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
|
export default class PCSX2Integration implements PluginType
|
||||||
|
{
|
||||||
|
emulator = "PCSX2";
|
||||||
|
|
||||||
|
async load (ctx: PluginLoadingContextType)
|
||||||
|
{
|
||||||
|
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
||||||
|
{
|
||||||
|
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"];
|
||||||
|
|
||||||
|
if (ctx.source?.type === 'store')
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
id: desc.name,
|
||||||
|
supportLevel: "full",
|
||||||
|
capabilities: [...baseCapabilities, "config", "resolution"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
|
||||||
|
{
|
||||||
|
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||||
|
validChangedSaveFiles[this.emulator] = {
|
||||||
|
cwd: saveFolderSlots[this.emulator].cwd,
|
||||||
|
shared: true,
|
||||||
|
subPath: '*.ps2',
|
||||||
|
isGlob: true,
|
||||||
|
fixedSize: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||||
|
{
|
||||||
|
const args: string[] = [];
|
||||||
|
if (ctx.autoValidCommand.metadata.romPath)
|
||||||
|
{
|
||||||
|
args.push(ctx.autoValidCommand.metadata.romPath);
|
||||||
|
args.push("-batch");
|
||||||
|
}
|
||||||
|
if (config.get('launchInFullscreen'))
|
||||||
|
{
|
||||||
|
args.push("-fullscreen");
|
||||||
|
}
|
||||||
|
args.push(...["-bigpicture", "-portable", "--"]);
|
||||||
|
|
||||||
|
if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
|
||||||
|
{
|
||||||
|
let pscx2Path = '';
|
||||||
|
if (process.platform === 'win32')
|
||||||
|
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
|
||||||
|
else
|
||||||
|
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis');
|
||||||
|
|
||||||
|
const configPath = path.join(pscx2Path, 'PCSX2.ini');
|
||||||
|
const existingConfigFile = Bun.file(configPath);
|
||||||
|
|
||||||
|
const configFile = await existingConfigFile.exists() ? ini.parse(await existingConfigFile.text()) : ini.parse(await Bun.file(defaultConfig).text());
|
||||||
|
|
||||||
|
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
|
||||||
|
const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator);
|
||||||
|
const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator);
|
||||||
|
const resolutionMapping = {
|
||||||
|
"720p": 2,
|
||||||
|
"1080p": 3,
|
||||||
|
"1440p": 4,
|
||||||
|
"4k": 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
BIOS_PATH: biosFolder,
|
||||||
|
SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'),
|
||||||
|
SAVE_STATES_PATH: path.join(savesFolder, 'states'),
|
||||||
|
MEMORY_CARDS_PATH: path.join(savesFolder, 'saves'),
|
||||||
|
CACHE_PATH: path.join(storageFolder, 'cache'),
|
||||||
|
COVERS_PATH: path.join(storageFolder, 'covers'),
|
||||||
|
TEXTURES_PATH: path.join(storageFolder, 'textures'),
|
||||||
|
VIDEOS_PATH: path.join(storageFolder, 'videos'),
|
||||||
|
LOGS_PATH: path.join(storageFolder, 'logs'),
|
||||||
|
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'),
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(Object.values(paths).map(p => ensureDir(p)));
|
||||||
|
|
||||||
|
configFile.EmuCore ??= {};
|
||||||
|
configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen');
|
||||||
|
configFile['EmuCore/GS'] ??= {};
|
||||||
|
configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2";
|
||||||
|
configFile['EmuCore/GS'].upscale_multiplier = resolutionMapping[config.get('emulatorResolution')] ?? 1;
|
||||||
|
configFile.Folders ??= {};
|
||||||
|
configFile.Folders.Bios = paths.BIOS_PATH;
|
||||||
|
configFile.Folders.Snapshots = paths.SNAPSHOTS_PATH;
|
||||||
|
configFile.Folders.SaveStates = paths.SAVE_STATES_PATH;
|
||||||
|
configFile.Folders.MemoryCards = paths.MEMORY_CARDS_PATH;
|
||||||
|
configFile.Folders.Cache = paths.CACHE_PATH;
|
||||||
|
configFile.Folders.Covers = paths.COVERS_PATH;
|
||||||
|
configFile.Folders.Textures = paths.TEXTURES_PATH;
|
||||||
|
configFile.Folders.Videos = paths.VIDEOS_PATH;
|
||||||
|
configFile.Folders.Logs = paths.LOGS_PATH;
|
||||||
|
configFile.GameList ??= {};
|
||||||
|
configFile.GameList.RecursivePaths = paths.RECURSIVE_PATHS;
|
||||||
|
|
||||||
|
await Bun.write(configPath, ini.stringify(configFile));
|
||||||
|
|
||||||
|
return { args, savesPath: { [this.emulator]: { cwd: paths.MEMORY_CARDS_PATH } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { args };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
[ControlMapping]
|
||||||
|
Up = 10-19
|
||||||
|
Down = 10-20
|
||||||
|
Left = 10-21
|
||||||
|
Right = 10-22
|
||||||
|
Circle = 10-190
|
||||||
|
Cross = 10-189
|
||||||
|
Square = 10-191
|
||||||
|
Triangle = 10-188
|
||||||
|
Start = 10-197
|
||||||
|
Select = 10-196
|
||||||
|
L = 10-193
|
||||||
|
R = 10-192
|
||||||
|
An.Up = 10-4003
|
||||||
|
An.Down = 10-4002
|
||||||
|
An.Left = 10-4001
|
||||||
|
An.Right = 10-4000
|
||||||
|
Fast-forward = 1-193:10-4010,1-135
|
||||||
|
Rewind = 10-196:10-4008
|
||||||
|
Save State = 10-196:10-192,1-132
|
||||||
|
Load State = 10-196:10-193,1-133
|
||||||
|
Previous Slot = 10-197:10-193,1-137
|
||||||
|
Next Slot = 10-197:10-192,1-136
|
||||||
|
Pause = 10-196:10-107,1-111
|
||||||
|
Screenshot = 10-196:10-190
|
||||||
|
Exit App = 10-196:10-197
|
||||||
|
SpeedToggle = 10-196:10-4010
|
||||||
|
|
@ -0,0 +1,477 @@
|
||||||
|
[General]
|
||||||
|
FirstRun = False
|
||||||
|
RunCount = 4
|
||||||
|
Enable Logging = True
|
||||||
|
AutoRun = True
|
||||||
|
Browse = False
|
||||||
|
IgnoreBadMemAccess = True
|
||||||
|
CurrentDirectory = /home
|
||||||
|
ShowDebuggerOnLoad = False
|
||||||
|
CheckForNewVersion = True
|
||||||
|
Language = en_US
|
||||||
|
ForceLagSync2 = False
|
||||||
|
DiscordPresence = True
|
||||||
|
UISound = False
|
||||||
|
AutoLoadSaveState = 0
|
||||||
|
EnableCheats = True
|
||||||
|
CwCheatRefreshRate = 77
|
||||||
|
CwCheatScrollPosition = 0.000000
|
||||||
|
GameListScrollPosition = 0.000000
|
||||||
|
ScreenshotsAsPNG = False
|
||||||
|
UseFFV1 = False
|
||||||
|
DumpFrames = False
|
||||||
|
DumpVideoOutput = False
|
||||||
|
DumpAudio = False
|
||||||
|
SaveLoadResetsAVdumping = False
|
||||||
|
StateSlot = 0
|
||||||
|
EnableStateUndo = True
|
||||||
|
StateLoadUndoGame = NA
|
||||||
|
StateUndoLastSaveGame = NA
|
||||||
|
StateUndoLastSaveSlot = -5
|
||||||
|
RewindFlipFrequency = 0
|
||||||
|
ShowOnScreenMessage = True
|
||||||
|
ShowRegionOnGameIcon = False
|
||||||
|
ShowIDOnGameIcon = True
|
||||||
|
GameGridScale = 1.000000
|
||||||
|
GridView1 = True
|
||||||
|
GridView2 = False
|
||||||
|
GridView3 = False
|
||||||
|
RightAnalogUp = 0
|
||||||
|
RightAnalogDown = 0
|
||||||
|
RightAnalogLeft = 0
|
||||||
|
RightAnalogRight = 0
|
||||||
|
RightAnalogPress = 0
|
||||||
|
RightAnalogCustom = False
|
||||||
|
RightAnalogDisableDiagonal = False
|
||||||
|
SwipeUp = 0
|
||||||
|
SwipeDown = 0
|
||||||
|
SwipeLeft = 0
|
||||||
|
SwipeRight = 0
|
||||||
|
SwipeSensitivity = 1.000000
|
||||||
|
SwipeSmoothing = 0.300000
|
||||||
|
DoubleTapGesture = 0
|
||||||
|
GestureControlEnabled = False
|
||||||
|
ReportingHost = default
|
||||||
|
AutoSaveSymbolMap = False
|
||||||
|
CacheFullIsoInRam = False
|
||||||
|
RemoteISOPort = 0
|
||||||
|
LastRemoteISOServer =
|
||||||
|
LastRemoteISOPort = 0
|
||||||
|
RemoteISOManualConfig = False
|
||||||
|
RemoteShareOnStartup = False
|
||||||
|
RemoteISOSubdir = /
|
||||||
|
RemoteDebuggerOnStartup = False
|
||||||
|
InternalScreenRotation = 1
|
||||||
|
BackgroundAnimation = 1
|
||||||
|
PauseWhenMinimized = False
|
||||||
|
DumpDecryptedEboots = False
|
||||||
|
MemStickInserted = True
|
||||||
|
EnablePlugins = True
|
||||||
|
[CPU]
|
||||||
|
CPUCore = 1
|
||||||
|
SeparateSASThread = True
|
||||||
|
SeparateIOThread = True
|
||||||
|
IOTimingMethod = 0
|
||||||
|
FastMemoryAccess = True
|
||||||
|
FunctionReplacements = True
|
||||||
|
HideSlowWarnings = False
|
||||||
|
HideStateWarnings = False
|
||||||
|
PreloadFunctions = False
|
||||||
|
JitDisableFlags = 0x00000000
|
||||||
|
CPUSpeed = 0
|
||||||
|
[Graphics]
|
||||||
|
EnableCardboardVR = False
|
||||||
|
CardboardScreenSize = 50
|
||||||
|
CardboardXShift = 0
|
||||||
|
CardboardYShift = 0
|
||||||
|
ShowFPSCounter = 0
|
||||||
|
GraphicsBackend = 3 (VULKAN)
|
||||||
|
FailedGraphicsBackends =
|
||||||
|
DisabledGraphicsBackends =
|
||||||
|
VulkanDevice =
|
||||||
|
CameraDevice =
|
||||||
|
RenderingMode = 1
|
||||||
|
SoftwareRenderer = False
|
||||||
|
HardwareTransform = True
|
||||||
|
SoftwareSkinning = True
|
||||||
|
TextureFiltering = 1
|
||||||
|
BufferFiltering = 1
|
||||||
|
AndroidHwScale = 1
|
||||||
|
HighQualityDepth = 1
|
||||||
|
FrameSkip = 0
|
||||||
|
FrameSkipType = 0
|
||||||
|
AutoFrameSkip = False
|
||||||
|
FrameRate = 0
|
||||||
|
FrameRate2 = -1
|
||||||
|
UnthrottlingMode = CONTINUOUS
|
||||||
|
AnisotropyLevel = 4
|
||||||
|
VertexDecCache = False
|
||||||
|
TextureBackoffCache = False
|
||||||
|
TextureSecondaryCache = False
|
||||||
|
FullScreenMulti = False
|
||||||
|
SmallDisplayZoomType = 2
|
||||||
|
SmallDisplayOffsetX = 0.500000
|
||||||
|
SmallDisplayOffsetY = 0.500000
|
||||||
|
SmallDisplayZoomLevel = 1.000000
|
||||||
|
ImmersiveMode = True
|
||||||
|
SustainedPerformanceMode = False
|
||||||
|
IgnoreScreenInsets = True
|
||||||
|
ReplaceTextures = True
|
||||||
|
SaveNewTextures = False
|
||||||
|
IgnoreTextureFilenames = False
|
||||||
|
TexScalingLevel = 1
|
||||||
|
TexScalingType = 0
|
||||||
|
TexDeposterize = False
|
||||||
|
TexHardwareScaling = False
|
||||||
|
VSyncInterval = False
|
||||||
|
BloomHack = 0
|
||||||
|
SplineBezierQuality = 2
|
||||||
|
HardwareTessellation = False
|
||||||
|
TextureShader = Off
|
||||||
|
ShaderChainRequires60FPS = False
|
||||||
|
MemBlockTransferGPU = True
|
||||||
|
DisableSlowFramebufEffects = False
|
||||||
|
FragmentTestCache = True
|
||||||
|
LogFrameDrops = False
|
||||||
|
InflightFrames = 2
|
||||||
|
RenderDuplicateFrames = False
|
||||||
|
[Sound]
|
||||||
|
Enable = True
|
||||||
|
AudioBackend = 0
|
||||||
|
ExtraAudioBuffering = False
|
||||||
|
GlobalVolume = 10
|
||||||
|
ReverbVolume = 10
|
||||||
|
AltSpeedVolume = -1
|
||||||
|
AudioDevice =
|
||||||
|
AutoAudioDevice = False
|
||||||
|
[Control]
|
||||||
|
HapticFeedback = False
|
||||||
|
ShowTouchCross = True
|
||||||
|
ShowTouchCircle = True
|
||||||
|
ShowTouchSquare = True
|
||||||
|
ShowTouchTriangle = True
|
||||||
|
Custom0Mapping = 0x0000000000000000
|
||||||
|
Custom0Image = 0
|
||||||
|
Custom0Shape = 0
|
||||||
|
Custom0Toggle = False
|
||||||
|
Custom1Mapping = 0x0000000000000000
|
||||||
|
Custom1Image = 1
|
||||||
|
Custom1Shape = 0
|
||||||
|
Custom1Toggle = False
|
||||||
|
Custom2Mapping = 0x0000000000000000
|
||||||
|
Custom2Image = 2
|
||||||
|
Custom2Shape = 0
|
||||||
|
Custom2Toggle = False
|
||||||
|
Custom3Mapping = 0x0000000000000000
|
||||||
|
Custom3Image = 3
|
||||||
|
Custom3Shape = 0
|
||||||
|
Custom3Toggle = False
|
||||||
|
Custom4Mapping = 0x0000000000000000
|
||||||
|
Custom4Image = 4
|
||||||
|
Custom4Shape = 0
|
||||||
|
Custom4Toggle = False
|
||||||
|
Custom5Mapping = 0x0000000000000000
|
||||||
|
Custom5Image = 0
|
||||||
|
Custom5Shape = 1
|
||||||
|
Custom5Toggle = False
|
||||||
|
Custom6Mapping = 0x0000000000000000
|
||||||
|
Custom6Image = 1
|
||||||
|
Custom6Shape = 1
|
||||||
|
Custom6Toggle = False
|
||||||
|
Custom7Mapping = 0x0000000000000000
|
||||||
|
Custom7Image = 2
|
||||||
|
Custom7Shape = 1
|
||||||
|
Custom7Toggle = False
|
||||||
|
Custom8Mapping = 0x0000000000000000
|
||||||
|
Custom8Image = 3
|
||||||
|
Custom8Shape = 1
|
||||||
|
Custom8Toggle = False
|
||||||
|
Custom9Mapping = 0x0000000000000000
|
||||||
|
Custom9Image = 4
|
||||||
|
Custom9Shape = 1
|
||||||
|
Custom9Toggle = False
|
||||||
|
ShowTouchPause = False
|
||||||
|
ShowTouchControls = False
|
||||||
|
DisableDpadDiagonals = False
|
||||||
|
GamepadOnlyFocused = False
|
||||||
|
TouchButtonStyle = 1
|
||||||
|
TouchButtonOpacity = 65
|
||||||
|
TouchButtonHideSeconds = 20
|
||||||
|
AutoCenterTouchAnalog = False
|
||||||
|
AnalogAutoRotSpeed = 8.000000
|
||||||
|
TouchSnapToGrid = False
|
||||||
|
TouchSnapGridSize = 64
|
||||||
|
ActionButtonSpacing2 = 1.000000
|
||||||
|
ActionButtonCenterX = -1.000000
|
||||||
|
ActionButtonCenterY = -1.000000
|
||||||
|
ActionButtonScale = 1.150000
|
||||||
|
DPadX = -1.000000
|
||||||
|
DPadY = -1.000000
|
||||||
|
DPadScale = 1.150000
|
||||||
|
ShowTouchDpad = True
|
||||||
|
DPadSpacing = 1.000000
|
||||||
|
StartKeyX = -1.000000
|
||||||
|
StartKeyY = -1.000000
|
||||||
|
StartKeyScale = 1.150000
|
||||||
|
ShowTouchStart = True
|
||||||
|
SelectKeyX = -1.000000
|
||||||
|
SelectKeyY = -1.000000
|
||||||
|
SelectKeyScale = 1.150000
|
||||||
|
ShowTouchSelect = True
|
||||||
|
UnthrottleKeyX = -1.000000
|
||||||
|
UnthrottleKeyY = -1.000000
|
||||||
|
UnthrottleKeyScale = 1.150000
|
||||||
|
ShowTouchUnthrottle = True
|
||||||
|
LKeyX = -1.000000
|
||||||
|
LKeyY = -1.000000
|
||||||
|
LKeyScale = 1.150000
|
||||||
|
ShowTouchLTrigger = True
|
||||||
|
RKeyX = -1.000000
|
||||||
|
RKeyY = -1.000000
|
||||||
|
RKeyScale = 1.150000
|
||||||
|
ShowTouchRTrigger = True
|
||||||
|
AnalogStickX = -1.000000
|
||||||
|
AnalogStickY = -1.000000
|
||||||
|
AnalogStickScale = 1.150000
|
||||||
|
ShowAnalogStick = True
|
||||||
|
RightAnalogStickX = -1.000000
|
||||||
|
RightAnalogStickY = -1.000000
|
||||||
|
RightAnalogStickScale = 1.150000
|
||||||
|
ShowRightAnalogStick = False
|
||||||
|
fcombo0X = -1.000000
|
||||||
|
fcombo0Y = -1.000000
|
||||||
|
comboKeyScale0 = 1.150000
|
||||||
|
ShowComboKey0 = False
|
||||||
|
fcombo1X = -1.000000
|
||||||
|
fcombo1Y = -1.000000
|
||||||
|
comboKeyScale1 = 1.150000
|
||||||
|
ShowComboKey1 = False
|
||||||
|
fcombo2X = -1.000000
|
||||||
|
fcombo2Y = -1.000000
|
||||||
|
comboKeyScale2 = 1.150000
|
||||||
|
ShowComboKey2 = False
|
||||||
|
fcombo3X = -1.000000
|
||||||
|
fcombo3Y = -1.000000
|
||||||
|
comboKeyScale3 = 1.150000
|
||||||
|
ShowComboKey3 = False
|
||||||
|
fcombo4X = -1.000000
|
||||||
|
fcombo4Y = -1.000000
|
||||||
|
comboKeyScale4 = 1.150000
|
||||||
|
ShowComboKey4 = False
|
||||||
|
fcombo5X = -1.000000
|
||||||
|
fcombo5Y = -1.000000
|
||||||
|
comboKeyScale5 = 1.150000
|
||||||
|
ShowComboKey5 = False
|
||||||
|
fcombo6X = -1.000000
|
||||||
|
fcombo6Y = -1.000000
|
||||||
|
comboKeyScale6 = 1.150000
|
||||||
|
ShowComboKey6 = False
|
||||||
|
fcombo7X = -1.000000
|
||||||
|
fcombo7Y = -1.000000
|
||||||
|
comboKeyScale7 = 1.150000
|
||||||
|
ShowComboKey7 = False
|
||||||
|
fcombo8X = -1.000000
|
||||||
|
fcombo8Y = -1.000000
|
||||||
|
comboKeyScale8 = 1.150000
|
||||||
|
ShowComboKey8 = False
|
||||||
|
fcombo9X = -1.000000
|
||||||
|
fcombo9Y = -1.000000
|
||||||
|
comboKeyScale9 = 1.150000
|
||||||
|
ShowComboKey9 = False
|
||||||
|
AnalogDeadzone = 0.150000
|
||||||
|
AnalogInverseDeadzone = 0.000000
|
||||||
|
AnalogSensitivity = 1.100000
|
||||||
|
AnalogIsCircular = False
|
||||||
|
AnalogLimiterDeadzone = 0.600000
|
||||||
|
LeftStickHeadScale = 1.000000
|
||||||
|
RightStickHeadScale = 1.000000
|
||||||
|
HideStickBackground = False
|
||||||
|
UseMouse = False
|
||||||
|
MapMouse = False
|
||||||
|
ConfineMap = False
|
||||||
|
MouseSensitivity = 0.100000
|
||||||
|
MouseSmoothing = 0.900000
|
||||||
|
SystemControls = True
|
||||||
|
AllowMappingCombos = True
|
||||||
|
[Network]
|
||||||
|
EnableWlan = False
|
||||||
|
EnableAdhocServer = False
|
||||||
|
proAdhocServer = socom.cc
|
||||||
|
PortOffset = 10000
|
||||||
|
MinTimeout = 0
|
||||||
|
ForcedFirstConnect = False
|
||||||
|
EnableUPnP = False
|
||||||
|
UPnPUseOriginalPort = False
|
||||||
|
EnableNetworkChat = False
|
||||||
|
ChatButtonPosition = 0
|
||||||
|
ChatScreenPosition = 0
|
||||||
|
EnableQuickChat = True
|
||||||
|
QuickChat1 = Quick Chat 1
|
||||||
|
QuickChat2 = Quick Chat 2
|
||||||
|
QuickChat3 = Quick Chat 3
|
||||||
|
QuickChat4 = Quick Chat 4
|
||||||
|
QuickChat5 = Quick Chat 5
|
||||||
|
[SystemParam]
|
||||||
|
PSPModel = 1
|
||||||
|
PSPFirmwareVersion = 660
|
||||||
|
NickName = PPSSPP
|
||||||
|
MacAddress = ec:fd:62:d4:ec:73
|
||||||
|
Language = 1
|
||||||
|
ParamTimeFormat = 0
|
||||||
|
ParamDateFormat = 0
|
||||||
|
TimeZone = 0
|
||||||
|
DayLightSavings = False
|
||||||
|
ButtonPreference = 1
|
||||||
|
LockParentalLevel = 0
|
||||||
|
WlanAdhocChannel = 0
|
||||||
|
WlanPowerSave = False
|
||||||
|
EncryptSave = True
|
||||||
|
SavedataUpgradeVersion = True
|
||||||
|
MemStickSize = 16
|
||||||
|
[Debugger]
|
||||||
|
DisasmWindowX = -1
|
||||||
|
DisasmWindowY = -1
|
||||||
|
DisasmWindowW = -1
|
||||||
|
DisasmWindowH = -1
|
||||||
|
GEWindowX = -1
|
||||||
|
GEWindowY = -1
|
||||||
|
GEWindowW = -1
|
||||||
|
GEWindowH = -1
|
||||||
|
ConsoleWindowX = -1
|
||||||
|
ConsoleWindowY = -1
|
||||||
|
FontWidth = 8
|
||||||
|
FontHeight = 12
|
||||||
|
DisplayStatusBar = True
|
||||||
|
ShowBottomTabTitles = True
|
||||||
|
ShowDeveloperMenu = False
|
||||||
|
SkipDeadbeefFilling = False
|
||||||
|
FuncHashMap = False
|
||||||
|
MemInfoDetailed = False
|
||||||
|
DrawFrameGraph = False
|
||||||
|
[Upgrade]
|
||||||
|
UpgradeMessage =
|
||||||
|
UpgradeVersion =
|
||||||
|
DismissedVersion =
|
||||||
|
[Theme]
|
||||||
|
ItemStyleFg = 0xffffffff
|
||||||
|
ItemStyleBg = 0x55000000
|
||||||
|
ItemFocusedStyleFg = 0xffffffff
|
||||||
|
ItemFocusedStyleBg = 0xffedc24c
|
||||||
|
ItemDownStyleFg = 0xffffffff
|
||||||
|
ItemDownStyleBg = 0xffbd9939
|
||||||
|
ItemDisabledStyleFg = 0x80eeeeee
|
||||||
|
ItemDisabledStyleBg = 0x55e0d4af
|
||||||
|
ItemHighlightedStyleFg = 0xffffffff
|
||||||
|
ItemHighlightedStyleBg = 0x55bdbb39
|
||||||
|
ButtonStyleFg = 0xffffffff
|
||||||
|
ButtonStyleBg = 0x55000000
|
||||||
|
ButtonFocusedStyleFg = 0xffffffff
|
||||||
|
ButtonFocusedStyleBg = 0xffedc24c
|
||||||
|
ButtonDownStyleFg = 0xffffffff
|
||||||
|
ButtonDownStyleBg = 0xffbd9939
|
||||||
|
ButtonDisabledStyleFg = 0x80eeeeee
|
||||||
|
ButtonDisabledStyleBg = 0x55e0d4af
|
||||||
|
ButtonHighlightedStyleFg = 0xffffffff
|
||||||
|
ButtonHighlightedStyleBg = 0x55bdbb39
|
||||||
|
HeaderStyleFg = 0xffffffff
|
||||||
|
InfoStyleFg = 0xffffffff
|
||||||
|
InfoStyleBg = 0x00000000
|
||||||
|
PopupTitleStyleFg = 0xffe3be59
|
||||||
|
PopupStyleFg = 0xffffffff
|
||||||
|
PopupStyleBg = 0xff303030
|
||||||
|
[Recent]
|
||||||
|
MaxRecent = 60
|
||||||
|
[Log]
|
||||||
|
SYSTEMEnabled = True
|
||||||
|
SYSTEMLevel = 2
|
||||||
|
BOOTEnabled = True
|
||||||
|
BOOTLevel = 2
|
||||||
|
COMMONEnabled = True
|
||||||
|
COMMONLevel = 2
|
||||||
|
CPUEnabled = True
|
||||||
|
CPULevel = 2
|
||||||
|
FILESYSEnabled = True
|
||||||
|
FILESYSLevel = 2
|
||||||
|
G3DEnabled = True
|
||||||
|
G3DLevel = 2
|
||||||
|
HLEEnabled = True
|
||||||
|
HLELevel = 2
|
||||||
|
JITEnabled = True
|
||||||
|
JITLevel = 2
|
||||||
|
LOADEREnabled = True
|
||||||
|
LOADERLevel = 2
|
||||||
|
MEEnabled = True
|
||||||
|
MELevel = 2
|
||||||
|
MEMMAPEnabled = True
|
||||||
|
MEMMAPLevel = 2
|
||||||
|
SASMIXEnabled = True
|
||||||
|
SASMIXLevel = 2
|
||||||
|
SAVESTATEEnabled = True
|
||||||
|
SAVESTATELevel = 2
|
||||||
|
FRAMEBUFEnabled = True
|
||||||
|
FRAMEBUFLevel = 2
|
||||||
|
AUDIOEnabled = True
|
||||||
|
AUDIOLevel = 2
|
||||||
|
IOEnabled = True
|
||||||
|
IOLevel = 2
|
||||||
|
SCEAUDIOEnabled = True
|
||||||
|
SCEAUDIOLevel = 2
|
||||||
|
SCECTRLEnabled = True
|
||||||
|
SCECTRLLevel = 2
|
||||||
|
SCEDISPEnabled = True
|
||||||
|
SCEDISPLevel = 2
|
||||||
|
SCEFONTEnabled = True
|
||||||
|
SCEFONTLevel = 2
|
||||||
|
SCEGEEnabled = True
|
||||||
|
SCEGELevel = 2
|
||||||
|
SCEINTCEnabled = True
|
||||||
|
SCEINTCLevel = 2
|
||||||
|
SCEIOEnabled = True
|
||||||
|
SCEIOLevel = 2
|
||||||
|
SCEKERNELEnabled = True
|
||||||
|
SCEKERNELLevel = 2
|
||||||
|
SCEMODULEEnabled = True
|
||||||
|
SCEMODULELevel = 2
|
||||||
|
SCENETEnabled = True
|
||||||
|
SCENETLevel = 2
|
||||||
|
SCERTCEnabled = True
|
||||||
|
SCERTCLevel = 2
|
||||||
|
SCESASEnabled = True
|
||||||
|
SCESASLevel = 2
|
||||||
|
SCEUTILEnabled = True
|
||||||
|
SCEUTILLevel = 2
|
||||||
|
SCEMISCEnabled = True
|
||||||
|
SCEMISCLevel = 2
|
||||||
|
ACHIEVEMENTSEnabled = True
|
||||||
|
ACHIEVEMENTSLevel = 2
|
||||||
|
HTTPEnabled = True
|
||||||
|
HTTPLevel = 2
|
||||||
|
PRINTFEnabled = True
|
||||||
|
PRINTFLevel = 2
|
||||||
|
[PostShaderSetting]
|
||||||
|
BloomSettingValue1 = 0.600000
|
||||||
|
BloomSettingValue2 = 0.500000
|
||||||
|
CartoonSettingValue1 = 0.500000
|
||||||
|
ColorCorrectionSettingValue1 = 1.000000
|
||||||
|
ColorCorrectionSettingValue2 = 1.000000
|
||||||
|
ColorCorrectionSettingValue3 = 1.000000
|
||||||
|
ColorCorrectionSettingValue4 = 1.000000
|
||||||
|
ScanlinesSettingValue1 = 1.000000
|
||||||
|
ScanlinesSettingValue2 = 0.500000
|
||||||
|
SharpenSettingValue1 = 1.500000
|
||||||
|
[Achievements]
|
||||||
|
AchievementsEnable = False
|
||||||
|
AchievementsChallengeMode = False
|
||||||
|
AchievementsEncoreMode = False
|
||||||
|
AchievementsUnofficial = False
|
||||||
|
AchievementsLogBadMemReads = False
|
||||||
|
AchievementsUserName =
|
||||||
|
AchievementsSoundEffects = True
|
||||||
|
AchievementsUnlockAudioFile =
|
||||||
|
AchievementsLeaderboardSubmitAudioFile =
|
||||||
|
AchievementsLeaderboardTrackerPos = 3
|
||||||
|
AchievementsLeaderboardStartedOrFailedPos = 3
|
||||||
|
AchievementsLeaderboardSubmittedPos = 3
|
||||||
|
AchievementsProgressPos = 3
|
||||||
|
AchievementsChallengePos = 3
|
||||||
|
AchievementsUnlockedPos = 4
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "com.simeonradivoev.gameflow.ppsspp",
|
||||||
|
"displayName": "PPSSPP Integration",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "PPSSPP Emulator Integration",
|
||||||
|
"main": "./ppsspp.ts",
|
||||||
|
"icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png",
|
||||||
|
"category": "emulators",
|
||||||
|
"keywords": [
|
||||||
|
"integration",
|
||||||
|
"emulator",
|
||||||
|
"psp",
|
||||||
|
"ppsspp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk";
|
||||||
|
import desc from './package.json';
|
||||||
|
import { config } from "@/bun/api/app";
|
||||||
|
import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' };
|
||||||
|
import configControlsFilePathWin32 from './win32/controls.ini' with { type: 'file' };
|
||||||
|
import configFilePathLinux from './linux/ppsspp.ini' with { type: 'file' };
|
||||||
|
import configControlsFilePathLinux from './linux/controls.ini' with { type: 'file' };
|
||||||
|
import path from "node:path";
|
||||||
|
import Mustache from "mustache";
|
||||||
|
import { ensureDir } from "fs-extra";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import ini from 'ini';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared";
|
||||||
|
|
||||||
|
export default class PPSSPPIntegration implements PluginType
|
||||||
|
{
|
||||||
|
emulator = "PPSSPP";
|
||||||
|
|
||||||
|
async load (ctx: PluginLoadingContextType)
|
||||||
|
{
|
||||||
|
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||||
|
{
|
||||||
|
const stat = await fs.stat(ctx.path);
|
||||||
|
if (stat.isDirectory())
|
||||||
|
{
|
||||||
|
await Bun.write(path.join(ctx.path, "portable.txt"), "");
|
||||||
|
if (process.platform === 'win32')
|
||||||
|
{
|
||||||
|
await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
||||||
|
{
|
||||||
|
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"];
|
||||||
|
|
||||||
|
if (ctx.source?.type === 'store')
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
id: desc.name,
|
||||||
|
supportLevel: "full",
|
||||||
|
capabilities: [...baseCapabilities, "config", "resolution"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
|
||||||
|
{
|
||||||
|
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||||
|
validChangedSaveFiles[this.emulator] = {
|
||||||
|
cwd: saveFolderSlots[this.emulator].cwd,
|
||||||
|
shared: true,
|
||||||
|
subPath: '*.{SFO,sfo,PNG,png}',
|
||||||
|
isGlob: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||||
|
{
|
||||||
|
const args: string[] = [];
|
||||||
|
if (ctx.autoValidCommand.metadata.romPath)
|
||||||
|
{
|
||||||
|
args.push(ctx.autoValidCommand.metadata.romPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push("--escape-exit", "--pause-menu-exit");
|
||||||
|
if (config.get('launchInFullscreen'))
|
||||||
|
{
|
||||||
|
args.push("--fullscreen");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
|
||||||
|
{
|
||||||
|
let defaultConfigPath: string | undefined = undefined;
|
||||||
|
let defaultControlsPath: string | undefined = undefined;
|
||||||
|
|
||||||
|
switch (process.platform)
|
||||||
|
{
|
||||||
|
case "win32":
|
||||||
|
defaultConfigPath = configFilePathWin32;
|
||||||
|
defaultControlsPath = configControlsFilePathWin32;
|
||||||
|
break;
|
||||||
|
case 'linux':
|
||||||
|
defaultConfigPath = configFilePathLinux;
|
||||||
|
defaultControlsPath = configControlsFilePathLinux;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ppssppPath = '';
|
||||||
|
if (process.platform === 'win32')
|
||||||
|
{
|
||||||
|
ppssppPath = path.join(config.get('downloadPath'), 'saves', this.emulator, 'PSP', 'SYSTEM');
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
//TODO: Use way to set custom memstick path when they support it
|
||||||
|
ensureDir(path.join(homedir(), '.config', 'ppsspp'));
|
||||||
|
ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM');
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(ppssppPath);
|
||||||
|
|
||||||
|
if (defaultConfigPath)
|
||||||
|
{
|
||||||
|
const resolutionMapping: Record<string, number> = {
|
||||||
|
"720p": 2,
|
||||||
|
"1080p": 4,
|
||||||
|
"1440p": 6,
|
||||||
|
"4k": 8
|
||||||
|
};
|
||||||
|
const configPath = path.join(ppssppPath, 'ppsspp.ini');
|
||||||
|
const configFile = Bun.file(configPath);
|
||||||
|
|
||||||
|
const ppssppConfig = await configFile.exists() ? ini.parse(await configFile.text()) : ini.parse(await Bun.file(defaultConfigPath).text());
|
||||||
|
|
||||||
|
ppssppConfig.Graphics ??= {};
|
||||||
|
ppssppConfig.Graphics.InternalResolution = resolutionMapping[config.get('emulatorResolution')] ?? 0;
|
||||||
|
ppssppConfig.Graphics.FullScreen = config.get('launchInFullscreen');
|
||||||
|
|
||||||
|
await Bun.write(configPath, ini.stringify(ppssppConfig));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultControlsPath)
|
||||||
|
{
|
||||||
|
const controlsFileContents = await Bun.file(defaultControlsPath).text();
|
||||||
|
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
savesPath: {
|
||||||
|
[this.emulator]: {
|
||||||
|
cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { args };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
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