feat: Bundled NW.js with appimages
feat: Implemented self update feat: Added rclone saves for emulators fix: Fixed auto focus in builds feat: Added helper cards on empty library
This commit is contained in:
parent
587956c792
commit
813785f4f3
59 changed files with 1210 additions and 480 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;
|
||||||
|
|
@ -4,4 +4,4 @@ Comment=GameFlow Deck
|
||||||
Exec=gameflow
|
Exec=gameflow
|
||||||
Icon=com.simeonradivoev.gameflow-deck
|
Icon=com.simeonradivoev.gameflow-deck
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Game;
|
Categories=Games;
|
||||||
|
|
@ -1,23 +1,36 @@
|
||||||
{
|
{
|
||||||
"app-id": "com.simeonradivoev.gameflow-deck",
|
"app-id": "com.simeonradivoev.gameflow-deck",
|
||||||
"runtime": "org.kde.Platform",
|
"runtime": "org.freedesktop.Platform",
|
||||||
"runtime-version": "6.10",
|
"runtime-version": "25.08",
|
||||||
"sdk": "org.kde.Sdk",
|
"sdk": "org.freedesktop.Sdk",
|
||||||
"command": "/app/bin/gameflow",
|
"command": "/app/bin/gameflow",
|
||||||
"base": "io.qt.qtwebengine.BaseApp",
|
|
||||||
"base-version": "6.10",
|
|
||||||
"finish-args": [
|
"finish-args": [
|
||||||
"--share=ipc",
|
"--share=ipc",
|
||||||
"--share=network",
|
"--share=network",
|
||||||
"--socket=pulseaudio",
|
"--socket=pulseaudio",
|
||||||
"--socket=wayland",
|
"--socket=wayland",
|
||||||
|
"--socket=inherit-wayland-socket",
|
||||||
"--socket=x11",
|
"--socket=x11",
|
||||||
|
"--socket=fallback-x11",
|
||||||
|
"--socket=session-bus",
|
||||||
|
"--socket=system-bus",
|
||||||
"--device=all",
|
"--device=all",
|
||||||
"--filesystem=host",
|
"--filesystem=host",
|
||||||
"--filesystem=home",
|
"--filesystem=home",
|
||||||
|
"--filesystem=~/.steam/steam:rw",
|
||||||
|
"--filesystem=~/.steam:rw",
|
||||||
|
"--filesystem=~/.var/app/com.valvesoftware.Steam:rw",
|
||||||
|
"--filesystem=/run/udev:ro",
|
||||||
|
"--filesystem=/run/media",
|
||||||
|
"--filesystem=xdg-documents",
|
||||||
|
"--filesystem=xdg-desktop",
|
||||||
|
"--filesystem=xdg-run/gamescope-0:rw",
|
||||||
"--env=PKG_CONFIG_LIBDIR=/app/lib",
|
"--env=PKG_CONFIG_LIBDIR=/app/lib",
|
||||||
"--env=FLATPAK_BUILD=true",
|
"--env=FLATPAK_BUILD=true",
|
||||||
"--allow=devel"
|
"--allow=devel",
|
||||||
|
"--talk-name=org.freedesktop.portal.OpenURI",
|
||||||
|
"--talk-name=org.freedesktop.Flatpak",
|
||||||
|
"--talk-name=org.a11y.Bus"
|
||||||
],
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
|
|
@ -29,7 +42,6 @@
|
||||||
"mkdir -p /app/lib",
|
"mkdir -p /app/lib",
|
||||||
"install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png",
|
"install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png",
|
||||||
"install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop",
|
"install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop",
|
||||||
"mv libvips-cpp.so.* /app/lib",
|
|
||||||
"mv * /app/share/gameflow/",
|
"mv * /app/share/gameflow/",
|
||||||
"mv /app/share/gameflow/gameflow /app/bin",
|
"mv /app/share/gameflow/gameflow /app/bin",
|
||||||
"mv /app/share/gameflow/bun /app/bin",
|
"mv /app/share/gameflow/bun /app/bin",
|
||||||
|
|
@ -39,15 +51,15 @@
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"type": "dir",
|
"type": "dir",
|
||||||
"path": "../build/linux"
|
"path": "../../build/linux"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "../flatpak/com.simeonradivoev.gameflow-deck.desktop"
|
"path": "com.simeonradivoev.gameflow-deck.desktop"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "../src/mainview/public/256x256.png"
|
"path": "../../src/mainview/public/256x256.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "script",
|
"type": "script",
|
||||||
|
|
@ -72,23 +84,22 @@
|
||||||
"only-arches": [
|
"only-arches": [
|
||||||
"aarch64"
|
"aarch64"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "file",
|
|
||||||
"path": "../node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3",
|
|
||||||
"only-arches": [
|
|
||||||
"x86_64"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "webview",
|
"name": "NW.js",
|
||||||
"buildsystem": "cmake-ninja",
|
"buildsystem": "simple",
|
||||||
|
"build-commands": [
|
||||||
|
"mkdir -p /app/bin/nw",
|
||||||
|
"mv * /app/bin/nw",
|
||||||
|
"chmod +x /app/bin/nw/nw"
|
||||||
|
],
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"type": "dir",
|
"type": "archive",
|
||||||
"path": "../flatpak/webview"
|
"url": "https://dl.nwjs.io/v0.110.1/nwjs-v0.110.1-linux-x64.tar.gz",
|
||||||
|
"sha256": "d9a9ed2255e9ee87c9dd1860d9c7a479cea5279dcd80d3e80e23b083d325554a"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
|
@ -83,7 +83,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
type: "zip"
|
type: "zip"
|
||||||
directory: ${{ github.workspace }}
|
directory: ${{ github.workspace }}
|
||||||
filename: "Gameflow-Windows.zip"
|
filename: "Gameflow-win32-x64.zip"
|
||||||
path: "canary-build-Windows"
|
path: "canary-build-Windows"
|
||||||
|
|
||||||
- name: Publish Release
|
- name: Publish Release
|
||||||
|
|
@ -96,4 +96,4 @@ jobs:
|
||||||
omitBodyDuringUpdate: true
|
omitBodyDuringUpdate: true
|
||||||
replacesArtifacts: true
|
replacesArtifacts: true
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-Windows.zip"
|
artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-*.zip"
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -30,3 +30,4 @@ gameflow-deck.code-workspace
|
||||||
src/tests/mock-roms/db.sqlite
|
src/tests/mock-roms/db.sqlite
|
||||||
src/tests/mock-config
|
src/tests/mock-config
|
||||||
bin
|
bin
|
||||||
|
.config/flatpak/repo
|
||||||
9
.vscode/settings.json
vendored
9
.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]": {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"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"
|
||||||
|
|
@ -22,7 +23,6 @@
|
||||||
"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:prod": "NODE_ENV=production bun run build",
|
"build:prod": "NODE_ENV=production bun run build",
|
||||||
"build:prod:dynamic": "NODE_ENV=production NON_COMPILED=true bun run build",
|
|
||||||
"build:linux": "TARGET=bun-linux-x64 bun run build",
|
"build:linux": "TARGET=bun-linux-x64 bun run build",
|
||||||
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
|
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
|
||||||
"run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml",
|
"run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml",
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
"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",
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"package:Linux": "bun run build:prod:appimage",
|
"package:Linux": "bun run build:prod:appimage",
|
||||||
"package:Windows": "bun run build:prod",
|
"package:Windows": "bun run build:prod",
|
||||||
"download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium",
|
"download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium",
|
||||||
|
"download:nwjs": "bun scripts/download-nw.ts",
|
||||||
"build:audiosprites": "bun ./scripts/generate-audio-sprites.ts",
|
"build:audiosprites": "bun ./scripts/generate-audio-sprites.ts",
|
||||||
"tsc": "tsc --noEmit"
|
"tsc": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import fs from 'node:fs/promises';
|
||||||
import { appBuilderPath, } from 'app-builder-bin';
|
import { appBuilderPath, } from 'app-builder-bin';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
|
import mustache from "mustache";
|
||||||
|
|
||||||
const APP_DIR = process.env.BUILD_DIR ?? `./build/${process.platform}`;
|
const APP_DIR = process.env.BUILD_DIR ?? `./build/${process.platform}`;
|
||||||
const BINARY_NAME = pkg.bin;
|
const BINARY_NAME = pkg.bin;
|
||||||
const ICON = "./src/mainview/public/256x256.png";
|
const ICON = "./src/mainview/public/256x256.png";
|
||||||
const DESKTOP = "./flatpak/com.simeonradivoev.gameflow-deck.desktop";
|
|
||||||
const TMP_FOLDER = ".";
|
const TMP_FOLDER = ".";
|
||||||
|
|
||||||
const APP_NAME = pkg.displayName ?? pkg.name;
|
const APP_NAME = pkg.displayName ?? pkg.name;
|
||||||
|
|
@ -27,24 +27,45 @@ await fs.rename(path.join(APPDIR, `usr`, 'share', BINARY_NAME), path.join(APPDIR
|
||||||
await fs.rename(path.join(APPDIR, `usr`, 'share', `libwebview-${process.arch}.so`), path.join(APPDIR, `usr`, 'lib', `libwebview-${process.arch}.so`));
|
await fs.rename(path.join(APPDIR, `usr`, 'share', `libwebview-${process.arch}.so`), path.join(APPDIR, `usr`, 'lib', `libwebview-${process.arch}.so`));
|
||||||
await fs.rename(path.join(APPDIR, `usr`, 'share', `7za`), path.join(APPDIR, `usr`, 'bin', `7za`));
|
await fs.rename(path.join(APPDIR, `usr`, 'share', `7za`), path.join(APPDIR, `usr`, 'bin', `7za`));
|
||||||
|
|
||||||
await fs.writeFile(path.join(APPDIR, `${APP_ID}.desktop`), `[Desktop Entry]
|
if (!await fs.exists('./bin/nw/nw'))
|
||||||
Version=${pkg.version}
|
{
|
||||||
X-AppImage-Name=${APP_NAME}
|
await import('./download-nw');
|
||||||
X-AppImage-Version=${pkg.version}
|
}
|
||||||
X-AppImage-Arch=${process.arch}
|
|
||||||
Name=${APP_NAME}
|
|
||||||
Comment=${pkg.description}
|
|
||||||
Exec=${APP_ID}.AppImage
|
|
||||||
Icon=.DirIcon
|
|
||||||
Type=Application
|
|
||||||
Categories=Game;
|
|
||||||
`);
|
|
||||||
|
|
||||||
await Bun.write(path.join(APPDIR, "AppRun"), `#!/bin/bash
|
await ensureDir(path.join(APPDIR, `usr`, 'lib', 'nw'));
|
||||||
APPDIR="$(dirname "$(readlink -f "$0")")"
|
await fs.cp('./bin/nw', path.join(APPDIR, `usr`, 'lib', 'nw'), { recursive: true });
|
||||||
APPIMAGE=true
|
await fs.symlink(path.join(APPDIR, `usr`, 'lib', 'nw', 'nw'), path.join(APPDIR, `usr`, `bin`, 'nw'));
|
||||||
exec "$APPDIR/usr/bin/${BINARY_NAME}" "$@"
|
|
||||||
`);
|
const templateVars = {
|
||||||
|
APP_NAME,
|
||||||
|
VERSION: pkg.version,
|
||||||
|
ARCH: process.arch,
|
||||||
|
DESCRIPTION: pkg.description,
|
||||||
|
APP_ID,
|
||||||
|
BINARY_NAME,
|
||||||
|
LICENSE: pkg.license
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktopFileTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.desktop', 'utf8');
|
||||||
|
|
||||||
|
const raw = await $`git tag --sort=-version:refname`.text().then(d => d.trim());
|
||||||
|
const tags = raw.split('\n').filter(t => t.match(/^\d+\.\d+\.\d+$/));
|
||||||
|
console.log("tags", tags);
|
||||||
|
|
||||||
|
console.log(">>> Updating Release History...");
|
||||||
|
const releases = await Promise.all(tags.map(async tag =>
|
||||||
|
{
|
||||||
|
const date = await $`git log -1 --format=%as ${tag}`.text().then(d => d.trim());
|
||||||
|
const version = tag.replace(/^v/, '');
|
||||||
|
return ` <release version="${version}" date="${date}"/>`;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const appStreamTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml', 'utf8');
|
||||||
|
await ensureDir(path.join(APPDIR, 'usr', 'share', 'metainfo'));
|
||||||
|
await fs.writeFile(path.join(APPDIR, 'usr', 'share', 'metainfo', `${APP_ID}.appdata.xml`), mustache.render(appStreamTemplate, { ...templateVars, RELEASES: releases }));
|
||||||
|
|
||||||
|
const appRunTemplate = await fs.readFile(`./.config/appimage/AppRun`, 'utf8');
|
||||||
|
await Bun.write(path.join(APPDIR, "AppRun"), mustache.render(appRunTemplate, templateVars));
|
||||||
await $`chmod +x ${APPDIR}/AppRun`;
|
await $`chmod +x ${APPDIR}/AppRun`;
|
||||||
|
|
||||||
console.log(">>> Building AppImage...");
|
console.log(">>> Building AppImage...");
|
||||||
|
|
@ -52,7 +73,7 @@ const config = {
|
||||||
productName: pkg.displayName,
|
productName: pkg.displayName,
|
||||||
productFilename: pkg.name,
|
productFilename: pkg.name,
|
||||||
executableName: BINARY_NAME,
|
executableName: BINARY_NAME,
|
||||||
desktopEntry: DESKTOP,
|
desktopEntry: mustache.render(desktopFileTemplate, templateVars),
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
file: ICON,
|
file: ICON,
|
||||||
|
|
@ -67,7 +88,7 @@ const config = {
|
||||||
// Remove the build dir, mainly to help with CIs
|
// Remove the build dir, mainly to help with CIs
|
||||||
await fs.rm(APP_DIR, { recursive: true });
|
await fs.rm(APP_DIR, { recursive: true });
|
||||||
await ensureDir(APP_DIR);
|
await ensureDir(APP_DIR);
|
||||||
const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}.AppImage`);
|
const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}-${process.platform}-${process.arch}.AppImage`);
|
||||||
const STAGE = path.resolve(TMP_FOLDER, `${APP_ID}.stage`);
|
const STAGE = path.resolve(TMP_FOLDER, `${APP_ID}.stage`);
|
||||||
|
|
||||||
await ensureDir(STAGE);
|
await ensureDir(STAGE);
|
||||||
|
|
@ -86,8 +107,9 @@ const proc = Bun.spawn([
|
||||||
});
|
});
|
||||||
|
|
||||||
const code = await proc.exited;
|
const code = await proc.exited;
|
||||||
await fs.rm(APPDIR, { recursive: true, force: true });
|
|
||||||
await fs.rm(STAGE, { recursive: true, force: true });
|
await fs.rm(STAGE, { recursive: true, force: true });
|
||||||
|
await fs.rm(APPDIR, { recursive: true, force: true });
|
||||||
|
|
||||||
if (code !== 0) process.exit(code);
|
if (code !== 0) process.exit(code);
|
||||||
|
|
||||||
console.log(`\n Done!`);
|
console.log(`\n Done!`);
|
||||||
|
|
@ -2,49 +2,44 @@ import EventEmitter from "events";
|
||||||
import browser from '../src/bun/browser';
|
import browser from '../src/bun/browser';
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { createInterface } from "readline";
|
import { watch } from "fs";
|
||||||
import { Readable } from "stream";
|
|
||||||
const events = new EventEmitter();
|
const events = new EventEmitter();
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222";
|
process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222";
|
||||||
process.env.NODE_ENV = "development";
|
process.env.NODE_ENV = "development";
|
||||||
|
|
||||||
let retries = 0;
|
|
||||||
|
|
||||||
function spawnServer ()
|
function spawnServer ()
|
||||||
{
|
{
|
||||||
const s = Bun.spawn(["bun", '--watch', '--install=fallback', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
|
const s = Bun.spawn(["bun", '--install=fallback', "run", "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
HEADLESS: "true",
|
HEADLESS: "true",
|
||||||
},
|
},
|
||||||
stdout: "pipe",
|
stdout: 'inherit',
|
||||||
stderr: "pipe",
|
stderr: 'inherit',
|
||||||
stdin: "pipe",
|
stdin: 'inherit',
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
killSignal: 'SIGUSR1',
|
killSignal: 'SIGUSR1',
|
||||||
|
ipc (message, subprocess, handle)
|
||||||
|
{
|
||||||
|
if (message === 'focus')
|
||||||
|
{
|
||||||
|
events.emit('focus');
|
||||||
|
} else if (message === 'exitapp')
|
||||||
|
{
|
||||||
|
events.emit('exitapp');
|
||||||
|
}
|
||||||
|
},
|
||||||
onExit (subprocess, exitCode, signalCode)
|
onExit (subprocess, exitCode, signalCode)
|
||||||
{
|
{
|
||||||
process.exit();
|
if (exitCode !== 3)
|
||||||
|
{
|
||||||
|
console.log("Existing Dev With", exitCode);
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const rl = createInterface({ input: Readable.fromWeb(s.stdout as any) });
|
|
||||||
rl.on('line', e =>
|
|
||||||
{
|
|
||||||
if (e === 'focus')
|
|
||||||
{
|
|
||||||
events.emit('focus');
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const rle = createInterface({ input: Readable.fromWeb(s.stderr as any) });
|
|
||||||
rle.on('line', e =>
|
|
||||||
{
|
|
||||||
console.error(e);
|
|
||||||
});
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,9 +48,10 @@ function spawnBrowser ()
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
||||||
return browser(events, process.env.FORCE_BROWSER === "true", {
|
return browser(events, {
|
||||||
configPath: path.join(tmpdir(), 'gameflow'),
|
configPath: path.join(tmpdir(), 'gameflow'),
|
||||||
isSteamDeckGameMode: false
|
isSteamDeckGameMode: false,
|
||||||
|
forceBrowser: process.env.FORCE_BROWSER === "true"
|
||||||
});
|
});
|
||||||
} catch (error)
|
} catch (error)
|
||||||
{
|
{
|
||||||
|
|
@ -63,13 +59,33 @@ function spawnBrowser ()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let server = spawnServer();
|
async function restart ()
|
||||||
|
{
|
||||||
|
if (server)
|
||||||
|
{
|
||||||
|
server.kill("SIGUSR1");
|
||||||
|
await server.exited;
|
||||||
|
server = undefined;
|
||||||
|
console.log("Server Restarted");
|
||||||
|
}
|
||||||
|
|
||||||
|
server = spawnServer();
|
||||||
|
console.log("Server Restarted");
|
||||||
|
}
|
||||||
|
|
||||||
|
watch("./src/bun", { recursive: true }, (event, filename) =>
|
||||||
|
{
|
||||||
|
console.log(`[watcher] ${event}: ${filename} — restarting...`);
|
||||||
|
restart();
|
||||||
|
});
|
||||||
|
|
||||||
|
let server: Bun.Subprocess | undefined = spawnServer();
|
||||||
if (!process.env.HEADLESS)
|
if (!process.env.HEADLESS)
|
||||||
{
|
{
|
||||||
spawnBrowser()?.then(async e =>
|
spawnBrowser()?.then(async e =>
|
||||||
{
|
{
|
||||||
console.log("Sending exit Signal to server");
|
if (!server) return;
|
||||||
await server.stdin.write('shutdown\n');
|
server.kill("SIGUSR1");
|
||||||
await server.stdin.flush();
|
await server.exited;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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.`);
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,6 @@ import EventEmitter from "node:events";
|
||||||
import { appPath } from "../utils";
|
import { appPath } from "../utils";
|
||||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import { getStoreFolder } from "./store/services/gamesService";
|
|
||||||
import { PluginManager } from "./plugins/plugin-manager";
|
import { PluginManager } from "./plugins/plugin-manager";
|
||||||
import registerPlugins from "./plugins/register-plugins";
|
import registerPlugins from "./plugins/register-plugins";
|
||||||
import controls from './controls/controls';
|
import controls from './controls/controls';
|
||||||
|
|
@ -43,6 +42,8 @@ export let events: EventEmitter<AppEventMap>;
|
||||||
let controlsHandle: { cleanup: () => void; };
|
let controlsHandle: { cleanup: () => void; };
|
||||||
let api: { cleanup: () => Promise<void>; };
|
let api: { cleanup: () => Promise<void>; };
|
||||||
let bunServer: { cleanup: () => Promise<void>; } | undefined;
|
let bunServer: { cleanup: () => Promise<void>; } | undefined;
|
||||||
|
let cleannedUp = false;
|
||||||
|
let cleaningUp = false;
|
||||||
|
|
||||||
export async function load ()
|
export async function load ()
|
||||||
{
|
{
|
||||||
|
|
@ -56,6 +57,7 @@ export async function load ()
|
||||||
windowSize: { width: 1280, height: 800 }
|
windowSize: { width: 1280, height: 800 }
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
customEmulators = new Conf<Record<string, string>>({
|
customEmulators = new Conf<Record<string, string>>({
|
||||||
projectName: projectPackage.name,
|
projectName: projectPackage.name,
|
||||||
projectSuffix: 'bun',
|
projectSuffix: 'bun',
|
||||||
|
|
@ -96,6 +98,9 @@ export async function load ()
|
||||||
|
|
||||||
export async function cleanup ()
|
export async function cleanup ()
|
||||||
{
|
{
|
||||||
|
if (cleaningUp) throw new Error("Already Cleaning Up");
|
||||||
|
cleaningUp = true;
|
||||||
|
if (cleannedUp) throw new Error("Already Cleaned Up. Skipping");
|
||||||
console.log("Cleaning Up");
|
console.log("Cleaning Up");
|
||||||
await bunServer?.cleanup();
|
await bunServer?.cleanup();
|
||||||
await api.cleanup();
|
await api.cleanup();
|
||||||
|
|
@ -108,6 +113,7 @@ export async function cleanup ()
|
||||||
config._closeWatcher();
|
config._closeWatcher();
|
||||||
customEmulators._closeWatcher();
|
customEmulators._closeWatcher();
|
||||||
console.log("Finished Cleaning Up");
|
console.log("Finished Cleaning Up");
|
||||||
|
cleannedUp = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reloadDatabase ()
|
export async function reloadDatabase ()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { cache } from "./app";
|
||||||
import cacheSchema from "@schema/cache";
|
import cacheSchema from "@schema/cache";
|
||||||
import { GithubReleaseSchema } from "@/shared/constants";
|
import { GithubReleaseSchema } from "@/shared/constants";
|
||||||
import PQueue from "p-queue";
|
import PQueue from "p-queue";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export const CACHE_KEYS = {
|
export const CACHE_KEYS = {
|
||||||
ROM_PLATFORMS: 'rom-platforms',
|
ROM_PLATFORMS: 'rom-platforms',
|
||||||
|
|
@ -12,17 +13,17 @@ export const CACHE_KEYS = {
|
||||||
|
|
||||||
export const githubRequestQueue = new PQueue({ intervalCap: 10, interval: 1000 * 60 * 10, strict: true });
|
export const githubRequestQueue = new PQueue({ intervalCap: 10, interval: 1000 * 60 * 10, strict: true });
|
||||||
|
|
||||||
export async function getOrCached<T> (key: string, getter: () => Promise<T>, options?: { expireMs?: number; }): Promise<T>
|
export async function getOrCached<T> (key: string, getter: (lastValue: T | undefined) => Promise<T>, options?: { expireMs?: number; force?: boolean; }): Promise<T>
|
||||||
{
|
{
|
||||||
const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) });
|
const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) });
|
||||||
const updated_at = new Date();
|
const updated_at = new Date();
|
||||||
|
|
||||||
if (cached && cached.expire_at > updated_at)
|
if (cached && cached.expire_at > updated_at && !options?.force)
|
||||||
{
|
{
|
||||||
return cached.data as T;
|
return cached.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getter();
|
const data = await getter(cached?.data as T);
|
||||||
if (data === undefined) return data;
|
if (data === undefined) return data;
|
||||||
|
|
||||||
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
|
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
|
@ -38,12 +39,15 @@ export async function getOrCached<T> (key: string, getter: () => Promise<T>, opt
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCachedGithubRelease (path: string)
|
export async function getOrCachedGithubRelease (path: string, forceCheck?: boolean)
|
||||||
{
|
{
|
||||||
return getOrCached(`github-release-${path}`, async () => githubRequestQueue.add(async () =>
|
return getOrCached<z.infer<typeof GithubReleaseSchema>>(`github-release-${path}`, () => githubRequestQueue.add(async () =>
|
||||||
{
|
{
|
||||||
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" });
|
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, {
|
||||||
|
method: "GET"
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error(response.statusText);
|
if (!response.ok) throw new Error(response.statusText);
|
||||||
return GithubReleaseSchema.parseAsync(await response.json());
|
const release = await GithubReleaseSchema.parseAsync(await response.json());
|
||||||
}), { expireMs: 1000 * 60 * 60 });
|
return release;
|
||||||
|
}), { expireMs: 1000 * 60 * 60, force: forceCheck });
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { IJob, JobContext } from "../task-queue";
|
import { IJob, JobContext } from "../task-queue";
|
||||||
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema";
|
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema";
|
||||||
import { db, events, plugins } from "../app";
|
import { config, db, events, plugins } from "../app";
|
||||||
import * as appSchema from "@schema/app";
|
import * as appSchema from "@schema/app";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
|
@ -51,6 +51,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
command: this.validCommand,
|
command: this.validCommand,
|
||||||
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
|
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
|
||||||
validChangedSaveFiles: {},
|
validChangedSaveFiles: {},
|
||||||
|
saveFolderSlots: this.saveSlots,
|
||||||
gameInfo
|
gameInfo
|
||||||
}).catch(e =>
|
}).catch(e =>
|
||||||
{
|
{
|
||||||
|
|
@ -129,31 +130,41 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
|
|
||||||
if (Array.isArray(this.validCommand.command))
|
if (Array.isArray(this.validCommand.command))
|
||||||
{
|
{
|
||||||
const bunGame = Bun.spawn(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,
|
cwd: this.validCommand.startDir,
|
||||||
signal: context.abortSignal,
|
signal: context.abortSignal,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
...this.validCommand.env
|
...this.validCommand.env
|
||||||
}
|
},
|
||||||
|
onExit (subprocess, exitCode, signalCode, error)
|
||||||
|
{
|
||||||
|
if (error)
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
|
reject(error);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
context.setProgress(0, "playing");
|
context.setProgress(0, "playing");
|
||||||
|
|
||||||
bunGame.exited.then(e =>
|
|
||||||
{
|
|
||||||
resolve(true);
|
|
||||||
}).catch(e =>
|
|
||||||
{
|
|
||||||
console.error(e);
|
|
||||||
reject(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
game = bunGame;
|
game = bunGame;
|
||||||
} else
|
} 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.
|
// ES-DE commands require shell execution. Some emulators fail otherwise.
|
||||||
const spawnGame = spawn(this.validCommand.command, {
|
const spawnGame = spawn(command, {
|
||||||
shell: this.validCommand.shell ?? true,
|
shell: this.validCommand.shell ?? true,
|
||||||
cwd: this.validCommand.startDir,
|
cwd: this.validCommand.startDir,
|
||||||
signal: context.abortSignal,
|
signal: context.abortSignal,
|
||||||
|
|
@ -178,7 +189,6 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
|
|
||||||
game = spawnGame;
|
game = spawnGame;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
else if (this.validCommand.metadata.emulatorBin)
|
else if (this.validCommand.metadata.emulatorBin)
|
||||||
{
|
{
|
||||||
|
|
@ -186,14 +196,28 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
|
|
||||||
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
|
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
|
// We have full control over launching integrated emulators better to use bun spawn
|
||||||
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], {
|
const bunGame = Bun.spawn(command, {
|
||||||
cwd: this.validCommand.startDir,
|
cwd: this.validCommand.startDir,
|
||||||
signal: context.abortSignal,
|
signal: context.abortSignal,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
...commandArgs.env
|
...commandArgs.env
|
||||||
}
|
},
|
||||||
|
onExit (subprocess, exitCode, signalCode, error)
|
||||||
|
{
|
||||||
|
if (error)
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
|
reject(error);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
context.setProgress(0, "playing");
|
context.setProgress(0, "playing");
|
||||||
|
|
@ -219,15 +243,6 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
});
|
});
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
bunGame.exited.then(e =>
|
|
||||||
{
|
|
||||||
resolve(true);
|
|
||||||
}).catch(e =>
|
|
||||||
{
|
|
||||||
console.error(e);
|
|
||||||
reject(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
game = bunGame;
|
game = bunGame;
|
||||||
|
|
||||||
} else
|
} else
|
||||||
|
|
|
||||||
118
src/bun/api/jobs/self-update-job.ts
Normal file
118
src/bun/api/jobs/self-update-job.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import z from "zod";
|
||||||
|
import { IJob, JobContext } from "../task-queue";
|
||||||
|
import { cleanPromise, cleanup, events, plugins } from "../app";
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
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));
|
||||||
|
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],
|
||||||
|
extractDir: path.dirname(process.execPath),
|
||||||
|
exePath: `${pkg.bin}.exe`
|
||||||
|
}));
|
||||||
|
context.setProgress(0, "Restarting App To Update");
|
||||||
|
await cleanup();
|
||||||
|
events.emit('exitapp');
|
||||||
|
Bun.spawn(["cmd", "/c", batPath], { detached: true });
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
events.emit('notification', { message: latest.statusText, title: 'Failed Update', type: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,17 @@ export default class CEMUIntegration implements PluginType
|
||||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] };
|
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) =>
|
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||||
{
|
{
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
@ -29,7 +40,7 @@ export default class CEMUIntegration implements PluginType
|
||||||
args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
|
args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { args, savesPath: { cemu: { cwd: savesPath } } };
|
return { args, savesPath: { [this.emulator]: { cwd: savesPath } } };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,21 +68,20 @@ export default class DOLPHINIntegration implements PluginType
|
||||||
args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`);
|
args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`);
|
||||||
|
|
||||||
finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder;
|
finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder;
|
||||||
|
return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { args, savesPath: { dolphin: { cwd: finalSavesPath } } };
|
return { args };
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderSlots, command, gameInfo }) =>
|
ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) =>
|
||||||
{
|
{
|
||||||
if (command.emulator === this.emulator && saveFolderSlots && command.metadata.romPath)
|
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||||
{
|
validChangedSaveFiles[this.emulator] = {
|
||||||
validChangedSaveFiles.dolphin = {
|
cwd: saveFolderSlots[this.emulator].cwd,
|
||||||
cwd: saveFolderSlots.dolphin.cwd,
|
subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir),
|
||||||
subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir),
|
shared: false
|
||||||
shared: false
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -31,6 +31,18 @@ export default class PCSX2Integration implements PluginType
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) =>
|
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||||
{
|
{
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
@ -103,7 +115,7 @@ export default class PCSX2Integration implements PluginType
|
||||||
|
|
||||||
await Bun.write(configPath, ini.stringify(configFile));
|
await Bun.write(configPath, ini.stringify(configFile));
|
||||||
|
|
||||||
return { args, savesPath: { pcsx2: { cwd: paths.MEMORY_CARDS_PATH } } };
|
return { args, savesPath: { [this.emulator]: { cwd: paths.MEMORY_CARDS_PATH } } };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { args };
|
return { args };
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import Mustache from "mustache";
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import ini from 'ini';
|
import ini from 'ini';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
|
||||||
export default class PPSSPPIntegration implements PluginType
|
export default class PPSSPPIntegration implements PluginType
|
||||||
{
|
{
|
||||||
|
|
@ -19,10 +20,14 @@ export default class PPSSPPIntegration implements PluginType
|
||||||
{
|
{
|
||||||
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||||
{
|
{
|
||||||
await Bun.write(path.join(ctx.path, "portable.txt"), "");
|
const stat = await fs.stat(ctx.path);
|
||||||
if (process.platform === 'win32')
|
if (stat.isDirectory())
|
||||||
{
|
{
|
||||||
await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -44,6 +49,17 @@ export default class PPSSPPIntegration implements PluginType
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) =>
|
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||||
{
|
{
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
@ -114,7 +130,14 @@ export default class PPSSPPIntegration implements PluginType
|
||||||
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
|
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { args, savesPath: { ppsspp: { cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") } } };
|
return {
|
||||||
|
args,
|
||||||
|
savesPath: {
|
||||||
|
[this.emulator]: {
|
||||||
|
cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { args };
|
return { args };
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export default class XENIAIntegration implements PluginType
|
||||||
if (ctx.autoValidCommand.metadata.romPath)
|
if (ctx.autoValidCommand.metadata.romPath)
|
||||||
{
|
{
|
||||||
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
|
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
|
||||||
return { args, savesPath: { xenia: { cwd: finalSavesPath } } };
|
return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { args };
|
return { args };
|
||||||
|
|
@ -91,13 +91,12 @@ export default class XENIAIntegration implements PluginType
|
||||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch);
|
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch);
|
||||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch);
|
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch);
|
||||||
|
|
||||||
ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) =>
|
ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) =>
|
||||||
{
|
{
|
||||||
if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath)
|
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||||
{
|
const files = await fs.readdir(saveFolderSlots[this.emulator].cwd, { recursive: true });
|
||||||
const files = await fs.readdir(saveFolderPath, { recursive: true });
|
validChangedSaveFiles.xenia = { cwd: saveFolderSlots[this.emulator].cwd, subPath: files, shared: false };
|
||||||
validChangedSaveFiles.gameflow = { cwd: saveFolderPath, subPath: files, shared: false };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { config, events } from "@/bun/api/app";
|
import { config, db, events } from "@/bun/api/app";
|
||||||
import path, { dirname } from 'node:path';
|
import path, { dirname } from 'node:path';
|
||||||
import unzip from 'unzip-stream';
|
import unzip from 'unzip-stream';
|
||||||
import { chmodSync, ensureDir } from "fs-extra";
|
import { chmodSync, ensureDir } from "fs-extra";
|
||||||
|
|
@ -10,6 +10,10 @@ import fs from 'node:fs/promises';
|
||||||
import { randomUUIDv7, sleep } from "bun";
|
import { randomUUIDv7, sleep } from "bun";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
|
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
|
||||||
|
import { getErrorMessage } from "@/bun/utils";
|
||||||
|
|
||||||
|
const DefaultLocalName = "Default_Local";
|
||||||
|
|
||||||
const SettingsSchema = z.object({
|
const SettingsSchema = z.object({
|
||||||
runWebGui: z.boolean()
|
runWebGui: z.boolean()
|
||||||
|
|
@ -18,7 +22,7 @@ const SettingsSchema = z.object({
|
||||||
.meta({ title: "Run Web GUI" }),
|
.meta({ title: "Run Web GUI" }),
|
||||||
globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"),
|
globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"),
|
||||||
webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"),
|
webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"),
|
||||||
remoteName: z.string().default(""),
|
remoteName: z.string().default(DefaultLocalName),
|
||||||
verboseLog: z.boolean()
|
verboseLog: z.boolean()
|
||||||
.default(false)
|
.default(false)
|
||||||
.describe("Show detailed log of operation for debugging")
|
.describe("Show detailed log of operation for debugging")
|
||||||
|
|
@ -116,8 +120,21 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
||||||
|
|
||||||
async refresh ()
|
async refresh ()
|
||||||
{
|
{
|
||||||
const data = await this.request('/config/listremotes', {});
|
try
|
||||||
z.globalRegistry.add(SettingsSchema.shape.remoteName, { examples: data.remotes, description: "The name of the remote to sync with" });
|
{
|
||||||
|
const data = await this.request('/config/listremotes', {});
|
||||||
|
z.globalRegistry.add(SettingsSchema.shape.remoteName, {
|
||||||
|
examples: [''].concat(...data.remotes),
|
||||||
|
description: "The name of the remote to sync with"
|
||||||
|
});
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
|
||||||
|
z.globalRegistry.add(SettingsSchema.shape.remoteName, {
|
||||||
|
examples: [''],
|
||||||
|
description: "The name of the remote to sync with"
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async startServer (ctx: PluginLoadingContextType<SettingsType>)
|
async startServer (ctx: PluginLoadingContextType<SettingsType>)
|
||||||
|
|
@ -146,23 +163,29 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
||||||
const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) });
|
const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) });
|
||||||
rl.on('line', e =>
|
rl.on('line', e =>
|
||||||
{
|
{
|
||||||
const data = JSON.parse(e);
|
try
|
||||||
|
|
||||||
if (data.level === 'error')
|
|
||||||
{
|
{
|
||||||
console.error(data.msg);
|
const data = JSON.parse(e);
|
||||||
} else if (data.level === 'critical')
|
|
||||||
{
|
|
||||||
console.error(data.msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
else
|
if (data.level === 'error')
|
||||||
|
{
|
||||||
|
console.error(data.msg);
|
||||||
|
} else if (data.level === 'critical')
|
||||||
|
{
|
||||||
|
console.error(data.msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
else
|
||||||
|
{
|
||||||
|
console.log(e);
|
||||||
|
if (loginTokenUrlRegex.test(data.msg))
|
||||||
|
{
|
||||||
|
this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error)
|
||||||
{
|
{
|
||||||
console.log(e);
|
console.log(e);
|
||||||
if (loginTokenUrlRegex.test(data.msg))
|
|
||||||
{
|
|
||||||
this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
@ -171,10 +194,16 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
||||||
{
|
{
|
||||||
const handleResolve = (line: string) =>
|
const handleResolve = (line: string) =>
|
||||||
{
|
{
|
||||||
const data = JSON.parse(line);
|
try
|
||||||
if (!loginTokenUrlRegex.test(data.msg)) return;
|
{
|
||||||
rl.off('line', handleResolve);
|
const data = JSON.parse(line);
|
||||||
resolve(data);
|
if (!loginTokenUrlRegex.test(data.msg)) return;
|
||||||
|
rl.off('line', handleResolve);
|
||||||
|
resolve(data);
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
};
|
};
|
||||||
rl.on('line', handleResolve);
|
rl.on('line', handleResolve);
|
||||||
setTimeout(() => { reject("Timeout"); }, 5000);
|
setTimeout(() => { reject("Timeout"); }, 5000);
|
||||||
|
|
@ -206,100 +235,235 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
||||||
|
|
||||||
async cleanup ()
|
async cleanup ()
|
||||||
{
|
{
|
||||||
await this.request('/core/quit', {}).catch(e =>
|
await new Promise((resolve) =>
|
||||||
{
|
{
|
||||||
this.server?.kill("SIGKILL");
|
this.request('/core/quit', {}).catch(e =>
|
||||||
|
{
|
||||||
|
this.server?.kill("SIGKILL");
|
||||||
|
this.server = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() =>
|
||||||
|
{
|
||||||
|
this.request('/core/quit', { exitCode: 9 }).then(e =>
|
||||||
|
{
|
||||||
|
resolve(false);
|
||||||
|
this.server = undefined;
|
||||||
|
}).catch(e =>
|
||||||
|
{
|
||||||
|
resolve(false);
|
||||||
|
this.server?.kill("SIGKILL");
|
||||||
|
this.server = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
this.server?.exited.then(() => resolve(true));
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.server?.exited;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async load (ctx: PluginLoadingContextType<SettingsType>)
|
async load (ctx: PluginLoadingContextType<SettingsType>)
|
||||||
{
|
{
|
||||||
await this.setup(ctx);
|
await this.setup(ctx);
|
||||||
|
|
||||||
ctx.hooks.games.prePlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, setProgress, saveFolderSlots }) =>
|
ctx.hooks.games.prePlay.tapPromise({
|
||||||
|
name: desc.name,
|
||||||
|
stage: 10,
|
||||||
|
}, async ({ source, id, setProgress, saveFolderSlots, command }) =>
|
||||||
{
|
{
|
||||||
if (source !== 'store' || !this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return;
|
if (!this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return;
|
||||||
|
|
||||||
|
const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined;
|
||||||
|
if (!destination) return;
|
||||||
|
|
||||||
|
const remoteName = ctx.config.get('remoteName');
|
||||||
|
|
||||||
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
|
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
|
||||||
{
|
{
|
||||||
|
let supportsMetadata = true;
|
||||||
let src: string;
|
let src: string;
|
||||||
if (ctx.config.get('remoteName'))
|
|
||||||
|
if (remoteName && remoteName !== DefaultLocalName)
|
||||||
{
|
{
|
||||||
src = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`;
|
src = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`;
|
||||||
|
|
||||||
const exists = await this.request('/operations/stat', {
|
const exists = await this.request('/operations/stat', {
|
||||||
fs: `${ctx.config.get('remoteName')}:`,
|
fs: `${remoteName}:`,
|
||||||
remote: `gameflow/saves/${source}/${id}/${slot}`
|
remote: `gameflow/saves/${destination.join('/')}/${slot}`
|
||||||
}).catch(e => undefined);
|
}).catch(e => undefined);
|
||||||
if (!exists || !exists.item) return;
|
if (!exists || !exists.item) return;
|
||||||
|
const remote = await this.request('/operations/fsinfo', {
|
||||||
|
fs: `${remoteName}:`
|
||||||
|
});
|
||||||
|
supportsMetadata = !remote.ReadMetadata;
|
||||||
|
if (supportsMetadata)
|
||||||
|
{
|
||||||
|
console.warn("Remote", remoteName, "does not support metadata");
|
||||||
|
}
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
src = path.join(config.get('downloadPath'), 'saves', source, id, slot);
|
src = path.join(config.get('downloadPath'), 'saves', ...destination, slot);
|
||||||
if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', source, id, slot))) return;
|
if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', ...destination, slot))) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgress(0.5, "RClone: Syncing Saves");
|
const job = await this.request('/sync/copy', {
|
||||||
|
|
||||||
const data = await this.request('/sync/copy', {
|
|
||||||
srcFs: src,
|
srcFs: src,
|
||||||
dstFs: cwd,
|
dstFs: cwd,
|
||||||
createEmptySrcDirs: true,
|
createEmptySrcDirs: true,
|
||||||
|
_async: true,
|
||||||
_config: {
|
_config: {
|
||||||
UseJSONLog: true,
|
CheckFirst: true,
|
||||||
LogLevel: "DEBUG",
|
Metadata: true,
|
||||||
HumanReadable: true,
|
NoCheckDest: supportsMetadata
|
||||||
Progress: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles }) =>
|
|
||||||
{
|
|
||||||
if (source !== 'store' || !this.rclonePath || !ctx.config.get('exportSaves')) return;
|
|
||||||
console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(","));
|
|
||||||
|
|
||||||
await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) =>
|
|
||||||
{
|
|
||||||
let dest: string;
|
|
||||||
if (ctx.config.get('remoteName'))
|
|
||||||
{
|
|
||||||
dest = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`;
|
|
||||||
} else
|
|
||||||
{
|
|
||||||
dest = path.join(config.get('downloadPath'), 'saves', source, id, slot);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await this.request('/sync/sync', {
|
|
||||||
srcFs: change.cwd,
|
|
||||||
dstFs: dest,
|
|
||||||
createEmptySrcDirs: true,
|
|
||||||
_config: {
|
|
||||||
UseJSONLog: true,
|
|
||||||
LogLevel: "DEBUG",
|
|
||||||
HumanReadable: true,
|
|
||||||
Progress: true
|
|
||||||
},
|
|
||||||
_filter: {
|
|
||||||
IncludeRule: Array.isArray(change.subPath) ? change.subPath.map(s =>
|
|
||||||
{
|
|
||||||
if (change.isGlob) return s;
|
|
||||||
else s.replaceAll('\\', '/');
|
|
||||||
}) : change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/')
|
|
||||||
}
|
}
|
||||||
}).catch(e =>
|
}).catch(e =>
|
||||||
{
|
{
|
||||||
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
||||||
return undefined;
|
return undefined;
|
||||||
|
});;
|
||||||
|
|
||||||
|
await new Promise(async (resolve, reject) =>
|
||||||
|
{
|
||||||
|
setProgress(0, "RClone: Syncing Saves");
|
||||||
|
|
||||||
|
const checkInterval = setInterval(async () =>
|
||||||
|
{
|
||||||
|
const stat = await this.request('/job/status', { jobid: job.jobid });
|
||||||
|
if (stat.finished)
|
||||||
|
{
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
console.log(stat.output);
|
||||||
|
resolve(true);
|
||||||
|
|
||||||
|
} else if (stat.error)
|
||||||
|
{
|
||||||
|
reject(stat.error);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
setProgress(stat.progress, "RClone: Syncing Saves");
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) =>
|
||||||
|
{
|
||||||
|
if (!this.rclonePath || !ctx.config.get('exportSaves')) return;
|
||||||
|
const local = await db.query.games.findFirst({ where: getLocalGameMatch(id, source) });
|
||||||
|
console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(","));
|
||||||
|
|
||||||
|
const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined;
|
||||||
|
if (!destination) return;
|
||||||
|
|
||||||
|
const remoteName = ctx.config.get('remoteName');
|
||||||
|
|
||||||
|
await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) =>
|
||||||
|
{
|
||||||
|
let suportsMetadata = false;
|
||||||
|
let dest: string;
|
||||||
|
if (remoteName && remoteName !== DefaultLocalName)
|
||||||
|
{
|
||||||
|
dest = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`;
|
||||||
|
const remote = await this.request('/operations/fsinfo', {
|
||||||
|
fs: `${remoteName}:`
|
||||||
|
});
|
||||||
|
suportsMetadata = !remote.ReadMetadata;
|
||||||
|
if (suportsMetadata)
|
||||||
|
{
|
||||||
|
console.warn("Remote", remoteName, "does not support metadata");
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
dest = path.join(config.get('downloadPath'), 'saves', ...destination, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
IncludeRule: Array.isArray(change.subPath) ?
|
||||||
|
change.subPath.map(s =>
|
||||||
|
{
|
||||||
|
if (change.isGlob) return s;
|
||||||
|
else s.replaceAll('\\', '/');
|
||||||
|
}) :
|
||||||
|
[change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/')]
|
||||||
|
};
|
||||||
|
|
||||||
|
let jobid: number | undefined = undefined;
|
||||||
|
|
||||||
|
if (change.fixedSize)
|
||||||
|
{
|
||||||
|
await this.request('/sync/copy', {
|
||||||
|
srcFs: change.cwd,
|
||||||
|
dstFs: dest,
|
||||||
|
createEmptySrcDirs: true,
|
||||||
|
_async: true,
|
||||||
|
_config: {
|
||||||
|
NoCheckDest: true
|
||||||
|
},
|
||||||
|
_filter: filter
|
||||||
|
})
|
||||||
|
.then(job => jobid = job.jobid)
|
||||||
|
.catch(e =>
|
||||||
|
{
|
||||||
|
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
await this.request('/sync/sync', {
|
||||||
|
srcFs: change.cwd,
|
||||||
|
dstFs: dest,
|
||||||
|
createEmptySrcDirs: true,
|
||||||
|
_async: true,
|
||||||
|
_config: {
|
||||||
|
CheckSum: true,
|
||||||
|
CheckFirst: true,
|
||||||
|
Metadata: true,
|
||||||
|
MetadataSet: {
|
||||||
|
igdb_id: local?.igdb_id ? String(local?.igdb_id) : undefined,
|
||||||
|
ra_id: local?.ra_id ? String(local?.ra_id) : undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_filter: filter
|
||||||
|
})
|
||||||
|
.then(job => jobid = job.jobid)
|
||||||
|
.catch(e =>
|
||||||
|
{
|
||||||
|
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jobid) return;
|
||||||
|
await new Promise(async (resolve, reject) =>
|
||||||
|
{
|
||||||
|
const checkInterval = setInterval(async () =>
|
||||||
|
{
|
||||||
|
const stat = await this.request('/job/status', { jobid });
|
||||||
|
if (stat.finished)
|
||||||
|
{
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
console.log(stat.output);
|
||||||
|
resolve(true);
|
||||||
|
|
||||||
|
} else if (stat.error)
|
||||||
|
{
|
||||||
|
reject(stat.error);
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data)
|
const stats = await this.request('/core/stats', {
|
||||||
|
group: `job/${jobid}`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stats.transfers > 0)
|
||||||
{
|
{
|
||||||
events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' });
|
events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
{
|
{
|
||||||
this.isSteamDeck = isSteamDeckGameMode();
|
this.isSteamDeck = isSteamDeckGameMode();
|
||||||
ctx.setProgress(0, "Logging Into Romm");
|
ctx.setProgress(0, "Logging Into Romm");
|
||||||
|
await this.updateClient();
|
||||||
await checkLoginAndRefreshRomm();
|
await checkLoginAndRefreshRomm();
|
||||||
await this.updateClient();
|
await this.updateClient();
|
||||||
|
|
||||||
|
|
@ -270,7 +271,8 @@ export default class RommIntegration implements PluginType<SettingsType>
|
||||||
metadata: rom.metadatum,
|
metadata: rom.metadatum,
|
||||||
files,
|
files,
|
||||||
auth: await this.getAuthToken(),
|
auth: await this.getAuthToken(),
|
||||||
extract_path
|
extract_path,
|
||||||
|
id: "romm"
|
||||||
};
|
};
|
||||||
|
|
||||||
return [info];
|
return [info];
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,9 @@ export class PluginManager
|
||||||
{
|
{
|
||||||
if (plugin.enabled || plugin.description.canDisable === false)
|
if (plugin.enabled || plugin.description.canDisable === false)
|
||||||
{
|
{
|
||||||
|
console.log("Loading Plugin", plugin.description.name);
|
||||||
await plugin.plugin.load(ctx);
|
await plugin.plugin.load(ctx);
|
||||||
|
console.log("Loaded Plugin", plugin.description.name);
|
||||||
plugin.loaded = true;
|
plugin.loaded = true;
|
||||||
}
|
}
|
||||||
} catch (error)
|
} catch (error)
|
||||||
|
|
@ -119,11 +121,13 @@ export class PluginManager
|
||||||
{
|
{
|
||||||
if (p.loaded)
|
if (p.loaded)
|
||||||
{
|
{
|
||||||
|
console.log("Starting", p.description.name, "plugin cleanup");
|
||||||
await p.plugin.cleanup!();
|
await p.plugin.cleanup!();
|
||||||
|
console.log(p.description.name, "cleanup complete");
|
||||||
}
|
}
|
||||||
} catch (error)
|
} catch (error)
|
||||||
{
|
{
|
||||||
console.log("Error for plugin", p.description.name, "while cleaning up");
|
console.error("Error for plugin", p.description.name, "while cleaning up");
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import Elysia from "elysia";
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { cachePath, config, events, taskQueue } from "./app";
|
import { cache, cachePath, config, events, taskQueue } from "./app";
|
||||||
import { isSteamDeck, openExternal } from "../utils";
|
import { getAppVersion, isSteamDeck, openExternal } from "../utils";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import buildNotificationsStream from "./notifications";
|
import buildNotificationsStream from "./notifications";
|
||||||
import path, { dirname } from "node:path";
|
import path, { dirname } from "node:path";
|
||||||
|
|
@ -14,23 +14,15 @@ import si from 'systeminformation';
|
||||||
import { getStoreFolder } from "./store/services/gamesService";
|
import { getStoreFolder } from "./store/services/gamesService";
|
||||||
import ReloadPluginsJob from "./jobs/reload-plugins-job";
|
import ReloadPluginsJob from "./jobs/reload-plugins-job";
|
||||||
import { semver } from "bun";
|
import { semver } from "bun";
|
||||||
import packageDef from '~/package.json';
|
import { getOrCached, getOrCachedGithubRelease, githubRequestQueue } from "./cache";
|
||||||
import { getOrCached, githubRequestQueue } from "./cache";
|
import SelfUpdateJob from "./jobs/self-update-job";
|
||||||
|
|
||||||
async function checkUpdate ()
|
async function checkUpdate (force?: boolean)
|
||||||
{
|
{
|
||||||
return getOrCached('check-for-update', async () => githubRequestQueue.add(async () =>
|
const latest = await getOrCachedGithubRelease('simeonradivoev/gameflow-deck', force);
|
||||||
{
|
if (!latest || !latest.tag_name) return { hasUpdate: 0, version: getAppVersion() };
|
||||||
const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest');
|
const hasUpdate = semver.order(latest.tag_name, getAppVersion());
|
||||||
if (latest.ok)
|
return { hasUpdate, version: latest.tag_name };
|
||||||
{
|
|
||||||
const data = await latest.json();
|
|
||||||
const hasUpdate = semver.order(data.tag_name, packageDef.version);
|
|
||||||
return hasUpdate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}), { expireMs: 1000 * 60 * 60 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const system = new Elysia({ prefix: '/api/system' })
|
export const system = new Elysia({ prefix: '/api/system' })
|
||||||
|
|
@ -71,7 +63,8 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
machine: os.machine(),
|
machine: os.machine(),
|
||||||
source,
|
source,
|
||||||
cacheSize: (await fs.stat(cachePath)).size,
|
cacheSize: (await fs.stat(cachePath)).size,
|
||||||
storeSize: (await getFolderSize(getStoreFolder())).size
|
storeSize: (await getFolderSize(getStoreFolder())).size,
|
||||||
|
version: getAppVersion()
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.get('/notifications', ({ set }) =>
|
.get('/notifications', ({ set }) =>
|
||||||
|
|
@ -120,17 +113,25 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
|
|
||||||
dispose.push(taskQueue.on('progress', e =>
|
dispose.push(taskQueue.on('progress', e =>
|
||||||
{
|
{
|
||||||
if (e.id !== ReloadPluginsJob.id) return;
|
if (e.id === ReloadPluginsJob.id)
|
||||||
ws.send({ type: "loading", progress: e.progress, state: e.state });
|
{
|
||||||
|
ws.send({ type: "loading", progress: e.progress, state: e.state });
|
||||||
|
}
|
||||||
|
else if (e.id === SelfUpdateJob.id)
|
||||||
|
{
|
||||||
|
ws.send({ type: "loading", progress: e.progress, state: e.state });
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
dispose.push(taskQueue.on('started', e =>
|
dispose.push(taskQueue.on('started', e =>
|
||||||
{
|
{
|
||||||
if (e.id !== ReloadPluginsJob.id) return;
|
if (e.id === ReloadPluginsJob.id)
|
||||||
ws.send({ type: "loading", progress: 0 });
|
ws.send({ type: "loading", progress: e.job.progress, state: e.job.state });
|
||||||
|
else if (e.id === SelfUpdateJob.id)
|
||||||
|
ws.send({ type: "loading", progress: e.job.progress, state: e.job.state });
|
||||||
}));
|
}));
|
||||||
dispose.push(taskQueue.on('ended', e =>
|
dispose.push(taskQueue.on('ended', e =>
|
||||||
{
|
{
|
||||||
if (e.id !== ReloadPluginsJob.id) return;
|
if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return;
|
||||||
ws.send({ type: "loaded" });
|
ws.send({ type: "loaded" });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -268,4 +269,12 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
.get('/update', async () =>
|
.get('/update', async () =>
|
||||||
{
|
{
|
||||||
return checkUpdate();
|
return checkUpdate();
|
||||||
|
})
|
||||||
|
.post('/update', async () =>
|
||||||
|
{
|
||||||
|
return taskQueue.enqueue(SelfUpdateJob.id, new SelfUpdateJob());
|
||||||
|
})
|
||||||
|
.post('/update/check', async () =>
|
||||||
|
{
|
||||||
|
return checkUpdate(true);
|
||||||
});
|
});
|
||||||
|
|
@ -106,7 +106,18 @@ export class TaskQueue
|
||||||
{
|
{
|
||||||
this.queue = [];
|
this.queue = [];
|
||||||
this.activeQueue.forEach(c => c.abort());
|
this.activeQueue.forEach(c => c.abort());
|
||||||
return Promise.all(this.activeQueue.map(c => c.promise.promise.catch(e => console.error("Error During Task Queue Closing"))));
|
return Promise.all(this.activeQueue.map(c =>
|
||||||
|
{
|
||||||
|
return new Promise(resolve =>
|
||||||
|
{
|
||||||
|
c.promise.promise.then(resolve).catch(e =>
|
||||||
|
{
|
||||||
|
console.error("Error During Task Queue Closing");
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,42 @@ import { dlopen, FFIType, Pointer } from "bun:ffi";
|
||||||
import { SERVER_URL } from '@/shared/constants';
|
import { SERVER_URL } from '@/shared/constants';
|
||||||
import { host } from './utils/host';
|
import { host } from './utils/host';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { ensureDir } from 'fs-extra';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
export default async function init (events: EventEmitter, forceBrowser: boolean, params: BrowserParams)
|
export default async function init (events: EventEmitter, params: BrowserParams)
|
||||||
{
|
{
|
||||||
if (forceBrowser)
|
if (params.forceNWJS)
|
||||||
{
|
{
|
||||||
await runBrowser(events, params);
|
await runNW(events, params);
|
||||||
} else
|
return;
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await runWebview(events, params);
|
|
||||||
} catch (error)
|
|
||||||
{
|
|
||||||
await runBrowser(events, params);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await runNW(events, params);
|
if (params.forceBrowser)
|
||||||
|
{
|
||||||
|
await runBrowser(events, params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await runWebview(events, params);
|
||||||
|
return;
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await runNW(events, params);
|
||||||
|
return;
|
||||||
|
} catch (error)
|
||||||
|
{
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await runBrowser(events, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusWindow (id: Pointer)
|
function focusWindow (id: Pointer)
|
||||||
|
|
@ -51,17 +69,50 @@ function focusWindow (id: Pointer)
|
||||||
|
|
||||||
async function runNW (events: EventEmitter, params: BrowserParams)
|
async function runNW (events: EventEmitter, params: BrowserParams)
|
||||||
{
|
{
|
||||||
const path = process.platform === 'win32' ? './bin/nw/nw.exe' : './bin/nw/nw';
|
let nwPath = process.platform === 'win32' ? './bin/nw/nw.exe' : './bin/nw/nw';
|
||||||
if (!await fs.exists(path))
|
if (process.env.FLATPAK_BUILD)
|
||||||
{
|
{
|
||||||
console.error("Could not find NW.js");
|
nwPath = '/app/bin/nw/nw';
|
||||||
return;
|
} else if (process.env.APPIMAGE)
|
||||||
|
{
|
||||||
|
nwPath = path.join(process.env.APPDIR ?? '', 'usr', 'bin', 'nw');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await fs.exists(nwPath))
|
||||||
|
{
|
||||||
|
throw new Error(`Could not find NW.js at ${nwPath}`);
|
||||||
}
|
}
|
||||||
const signalHandler = new AbortController();
|
const signalHandler = new AbortController();
|
||||||
|
const chromeArgs: string[] = ['--in-process-gpu'];
|
||||||
|
if (params.isSteamDeckGameMode)
|
||||||
|
{
|
||||||
|
chromeArgs.push('--kiosk');
|
||||||
|
chromeArgs.push(`--window-size=1280,800`);
|
||||||
|
} else if (params.windowSize)
|
||||||
|
{
|
||||||
|
chromeArgs.push(`--window-size=${params.windowSize.width},${params.windowSize.height}`);
|
||||||
|
}
|
||||||
|
if (params.windowPosition) chromeArgs.push(`--window-position=${params.windowPosition.x},${params.windowPosition.y}`);
|
||||||
events.on('exitapp', () => signalHandler.abort());
|
events.on('exitapp', () => signalHandler.abort());
|
||||||
const args = [path, `--url=${SERVER_URL(host)}`];
|
const configPath = path.join(params.configPath, 'nw-user-data');
|
||||||
if (process.env.NODE_ENV !== 'development') args.push("--disable-devtools");
|
await ensureDir(configPath);
|
||||||
const nwProcess = Bun.spawn(args, { signal: signalHandler.signal });
|
console.log("NW config path at:", configPath);
|
||||||
|
const args = [nwPath, `--url=${SERVER_URL(host)}`, `--user-data-dir=${configPath}`];
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'development')
|
||||||
|
{
|
||||||
|
console.log("Disabling devtools");
|
||||||
|
args.push("--disable-devtools");
|
||||||
|
}
|
||||||
|
console.log("Launching NW.js");
|
||||||
|
const nwProcess = Bun.spawn(args, {
|
||||||
|
signal: signalHandler.signal,
|
||||||
|
killSignal: "SIGKILL",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NW_PRE_ARGS: chromeArgs.join(" ")
|
||||||
|
}
|
||||||
|
});
|
||||||
await nwProcess.exited;
|
await nwProcess.exited;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,8 +182,7 @@ async function runBrowser (events: EventEmitter, params: BrowserParams)
|
||||||
const browserParams = await BuildParams(params);
|
const browserParams = await BuildParams(params);
|
||||||
if (!browserParams)
|
if (!browserParams)
|
||||||
{
|
{
|
||||||
console.error("Could not find valid browser");
|
throw new Error("Could not find valid browser");
|
||||||
return Promise.resolve();
|
|
||||||
}
|
}
|
||||||
else if (!Bun.env.HEADLESS)
|
else if (!Bun.env.HEADLESS)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,28 @@ import { dirname } from 'pathe';
|
||||||
import { createInterface } from 'readline';
|
import { createInterface } from 'readline';
|
||||||
import { isSteamDeckGameMode } from './utils';
|
import { isSteamDeckGameMode } from './utils';
|
||||||
|
|
||||||
async function cleanup ()
|
async function cleanup (code: number)
|
||||||
{
|
{
|
||||||
await app.cleanup();
|
app.cleanup()
|
||||||
process.exit(0);
|
.then(() =>
|
||||||
|
{
|
||||||
|
process.exit(code);
|
||||||
|
})
|
||||||
|
.catch(e => console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await app.load();
|
await app.load();
|
||||||
|
|
||||||
|
async function shutdown (code: number)
|
||||||
|
{
|
||||||
|
console.log("Graceful Shutdown");
|
||||||
|
await cleanup(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => shutdown(0));
|
||||||
|
process.on("SIGTERM", () => shutdown(0));
|
||||||
|
process.on('SIGUSR1', () => shutdown(3));
|
||||||
|
|
||||||
if (process.env.HEADLESS)
|
if (process.env.HEADLESS)
|
||||||
{
|
{
|
||||||
const rl = createInterface({ input: process.stdin });
|
const rl = createInterface({ input: process.stdin });
|
||||||
|
|
@ -22,7 +36,7 @@ if (process.env.HEADLESS)
|
||||||
if (line.trim() === "shutdown")
|
if (line.trim() === "shutdown")
|
||||||
{
|
{
|
||||||
console.log("Graceful Shutdown");
|
console.log("Graceful Shutdown");
|
||||||
await cleanup();
|
await cleanup(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -30,23 +44,23 @@ if (process.env.HEADLESS)
|
||||||
app.events.on('exitapp', () =>
|
app.events.on('exitapp', () =>
|
||||||
{
|
{
|
||||||
process.stdout.write('exitapp\n');
|
process.stdout.write('exitapp\n');
|
||||||
cleanup();
|
process.send?.("exitapp");
|
||||||
|
cleanup(0);
|
||||||
});
|
});
|
||||||
app.events.on('focus', () =>
|
app.events.on('focus', () =>
|
||||||
{
|
{
|
||||||
process.stdout.write("focus\n");
|
process.stdout.write("focus\n");
|
||||||
|
process.send?.("focus");
|
||||||
});
|
});
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
await init(app.events, process.env.FORCE_BROWSER === "true", {
|
await init(app.events, {
|
||||||
configPath: dirname(app.config.path),
|
configPath: dirname(app.config.path),
|
||||||
windowPosition: app.config.get('windowPosition'),
|
windowPosition: app.config.get('windowPosition'),
|
||||||
windowSize: app.config.get('windowSize'),
|
windowSize: app.config.get('windowSize'),
|
||||||
isSteamDeckGameMode: isSteamDeckGameMode()
|
isSteamDeckGameMode: isSteamDeckGameMode(),
|
||||||
|
forceBrowser: process.env.FORCE_BROWSER === "true",
|
||||||
|
forceNWJS: process.env.FORCE_NWJS === "true"
|
||||||
});
|
});
|
||||||
await cleanup();
|
await cleanup(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
10
src/bun/types/types.d.ts
vendored
10
src/bun/types/types.d.ts
vendored
|
|
@ -30,3 +30,13 @@ declare interface AppEventMap
|
||||||
notification: [FrontendNotification];
|
notification: [FrontendNotification];
|
||||||
focus: [];
|
focus: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '*.bat' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.sh' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import path from 'node:path';
|
||||||
import { SettingsType } from '@/shared/constants';
|
import { SettingsType } from '@/shared/constants';
|
||||||
import { config } from './api/app';
|
import { config } from './api/app';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import packageDef from '~/package.json';
|
||||||
|
|
||||||
export function checkRunning (pid: number)
|
export function checkRunning (pid: number)
|
||||||
{
|
{
|
||||||
|
|
@ -173,3 +174,8 @@ export async function moveAllFiles (srcDir: string, destDir: string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAppVersion ()
|
||||||
|
{
|
||||||
|
return process.env.VERSION_OVERRIDE ?? packageDef.version;
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,8 @@ export interface BrowserParams
|
||||||
windowPosition?: { x: number, y: number; };
|
windowPosition?: { x: number, y: number; };
|
||||||
windowSize?: { width?: number, height?: number; };
|
windowSize?: { width?: number, height?: number; };
|
||||||
isSteamDeckGameMode: boolean;
|
isSteamDeckGameMode: boolean;
|
||||||
|
forceBrowser?: boolean;
|
||||||
|
forceNWJS?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function BuildParams (data: BrowserParams)
|
export async function BuildParams (data: BrowserParams)
|
||||||
|
|
@ -54,6 +56,13 @@ export async function BuildParams (data: BrowserParams)
|
||||||
args.push('--allow-insecure-localhost');
|
args.push('--allow-insecure-localhost');
|
||||||
args.push('--auto-accept-camera-and-microphone-capture');
|
args.push('--auto-accept-camera-and-microphone-capture');
|
||||||
|
|
||||||
|
if (process.env.FLATPAK_BUILD)
|
||||||
|
{
|
||||||
|
args.push('--no-sandbox');
|
||||||
|
args.push('--disable-gpu-sandbox');
|
||||||
|
args.push('--test-type');
|
||||||
|
}
|
||||||
|
|
||||||
if (data.isSteamDeckGameMode)
|
if (data.isSteamDeckGameMode)
|
||||||
{
|
{
|
||||||
args.push('--kiosk');
|
args.push('--kiosk');
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,22 @@ export class Downloader
|
||||||
onProgress?: (stats: ProgressStats) => void;
|
onProgress?: (stats: ProgressStats) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
activeFile?: DownloadFileEntry;
|
activeFile?: DownloadFileEntry;
|
||||||
downloadPath: string;
|
downloadPath: string | undefined;
|
||||||
id: string;
|
id: string;
|
||||||
tmpPath: string;
|
tmpPath: string;
|
||||||
tmpPathMeta: string;
|
tmpPathMeta: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param id Id of the download. Should be unique
|
||||||
|
* @param files All the files to download
|
||||||
|
* @param downloadPath The destination path when all downloads are complete they will bemoved here. If undefined they will remain in the tmp path.
|
||||||
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
id: string,
|
id: string,
|
||||||
files: DownloadFileEntry[],
|
files: DownloadFileEntry[],
|
||||||
downloadPath: string, init?: {
|
downloadPath: string | undefined,
|
||||||
|
init?: {
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
onProgress?: (stats: ProgressStats) => void;
|
onProgress?: (stats: ProgressStats) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
|
@ -210,11 +217,19 @@ export class Downloader
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await moveAllFiles(this.tmpPath, this.downloadPath);
|
if (this.downloadPath === undefined)
|
||||||
if (await fs.exists(this.tmpPath))
|
{
|
||||||
await fs.rm(this.tmpPath, { recursive: true });
|
await fs.rm(this.tmpPathMeta);
|
||||||
await fs.rm(this.tmpPathMeta);
|
return this.files.map(f => path.join(this.tmpPath, f.file_path, f.file_name));
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
await moveAllFiles(this.tmpPath, this.downloadPath);
|
||||||
|
if (await fs.exists(this.tmpPath))
|
||||||
|
await fs.rm(this.tmpPath, { recursive: true });
|
||||||
|
await fs.rm(this.tmpPathMeta);
|
||||||
|
|
||||||
|
return this.files.map(f => path.join(this.downloadPath!, f.file_path, f.file_name));
|
||||||
|
}
|
||||||
|
|
||||||
return this.files.map(f => path.join(this.downloadPath, f.file_path, f.file_name));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
6
src/bun/utils/update-gameflow-linux.sh
Normal file
6
src/bun/utils/update-gameflow-linux.sh
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/bin/bash
|
||||||
|
sleep 2
|
||||||
|
mv "{{{tempFile}}}" "{{{appImagePath}}}"
|
||||||
|
chmod +x "{{{appImagePath}}}"
|
||||||
|
"{{{appImagePath}}}" &
|
||||||
|
rm -- "$0"
|
||||||
6
src/bun/utils/update-gameflow-windows.bat
Normal file
6
src/bun/utils/update-gameflow-windows.bat
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
@echo off
|
||||||
|
timeout /t 2 /nobreak
|
||||||
|
powershell -Command "Expand-Archive -Force '{{{tempFile}}}' '{{{installDir}}}'"
|
||||||
|
del "{{{tempFile}}}"
|
||||||
|
start "" /D "{{{installDir}}}" "{{{exePath}}}"
|
||||||
|
del "%~f0"
|
||||||
|
|
@ -25,17 +25,13 @@ export function AnimatedBackground (data: {
|
||||||
)
|
)
|
||||||
: useState<string | undefined>();
|
: useState<string | undefined>();
|
||||||
|
|
||||||
const [lastBackgroundUrl, setLastBackgroundUrl] = useState<string | undefined>(undefined);
|
|
||||||
const backgroundElementRef = useRef<HTMLDivElement>(null);
|
const backgroundElementRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
const lastBg = backgroundUrl;
|
|
||||||
|
|
||||||
if (data.backgroundUrl != backgroundUrl)
|
if (data.backgroundUrl != backgroundUrl)
|
||||||
{
|
{
|
||||||
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
|
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
|
||||||
setLastBackgroundUrl(lastBg);
|
|
||||||
}
|
}
|
||||||
}, [data.backgroundUrl]);
|
}, [data.backgroundUrl]);
|
||||||
|
|
||||||
|
|
@ -44,13 +40,6 @@ export function AnimatedBackground (data: {
|
||||||
{
|
{
|
||||||
finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
|
finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
let finalLastBackgroundUrl: URL | undefined;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
finalLastBackgroundUrl = lastBackgroundUrl ? new URL(lastBackgroundUrl) : undefined;
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
const blur = useLocalSetting('backgroundBlur');
|
const blur = useLocalSetting('backgroundBlur');
|
||||||
if (blur)
|
if (blur)
|
||||||
{
|
{
|
||||||
|
|
@ -59,13 +48,7 @@ export function AnimatedBackground (data: {
|
||||||
finalBackgroundUrl?.searchParams.set('blur', String(24));
|
finalBackgroundUrl?.searchParams.set('blur', String(24));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!finalLastBackgroundUrl?.searchParams.has('blur'))
|
|
||||||
{
|
|
||||||
finalLastBackgroundUrl?.searchParams.set('blur', String(24));
|
|
||||||
}
|
|
||||||
|
|
||||||
finalBackgroundUrl?.searchParams.set('height', String(320));
|
finalBackgroundUrl?.searchParams.set('height', String(320));
|
||||||
finalLastBackgroundUrl?.searchParams.set('height', String(320));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
|
|
@ -90,8 +73,6 @@ export function AnimatedBackground (data: {
|
||||||
|
|
||||||
function handleSetBackground (url: string)
|
function handleSetBackground (url: string)
|
||||||
{
|
{
|
||||||
|
|
||||||
setLastBackgroundUrl(backgroundUrl);
|
|
||||||
setBackgroundUrl(url);
|
setBackgroundUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +101,7 @@ export function AnimatedBackground (data: {
|
||||||
>
|
>
|
||||||
{!data.scrolling && <div className='absolute top-0 left-0 right-0 bottom-0 overflow-hidden'>
|
{!data.scrolling && <div className='absolute top-0 left-0 right-0 bottom-0 overflow-hidden'>
|
||||||
<div className='absolute w-full h-full bg-radial from-base-100 to-base-300 -z-5'></div>
|
<div className='absolute w-full h-full bg-radial from-base-100 to-base-300 -z-5'></div>
|
||||||
{blur && finalLastBackgroundUrl && <img className='absolute w-full h-full object-cover object-center -z-4 mask-radial-at-center mask-radial-from-0 mask-radial-farthest-corner' src={finalLastBackgroundUrl.href}></img>}
|
|
||||||
{finalBackgroundUrl ? <img
|
{finalBackgroundUrl ? <img
|
||||||
decoding="async"
|
decoding="async"
|
||||||
key={finalBackgroundUrl?.href}
|
key={finalBackgroundUrl?.href}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ export default function AppCommunication (data: { children: any; })
|
||||||
});
|
});
|
||||||
|
|
||||||
document.documentElement.dataset.loaded = "true";
|
document.documentElement.dataset.loaded = "true";
|
||||||
|
return () =>
|
||||||
|
{
|
||||||
|
sub.close();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <SystemInfoContext value={systemInfo}>
|
return <SystemInfoContext value={systemInfo}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { useLayoutEffect } from "react";
|
import { useEffect, useLayoutEffect } from "react";
|
||||||
|
|
||||||
export function AutoFocus (data: {
|
export function AutoFocus (data: {
|
||||||
parentKey?: string;
|
parentKey?: string;
|
||||||
|
|
@ -8,11 +8,15 @@ export function AutoFocus (data: {
|
||||||
delay?: number;
|
delay?: number;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
useLayoutEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
let delayTimeout: number | undefined;
|
let delayTimeout: number | undefined;
|
||||||
|
|
||||||
if (data.force || !getCurrentFocusKey() || getCurrentFocusKey() === data.parentKey || !doesFocusableExist(getCurrentFocusKey()))
|
const focusDoesntExist = !doesFocusableExist(getCurrentFocusKey());
|
||||||
|
const parentFocus = getCurrentFocusKey() === data.parentKey;
|
||||||
|
const noFocus = !getCurrentFocusKey();
|
||||||
|
|
||||||
|
if (data.force || noFocus || parentFocus || focusDoesntExist)
|
||||||
{
|
{
|
||||||
if (data.delay)
|
if (data.delay)
|
||||||
{
|
{
|
||||||
|
|
@ -21,8 +25,8 @@ export function AutoFocus (data: {
|
||||||
{
|
{
|
||||||
data.focus({ instant: true });
|
data.focus({ instant: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
{
|
{
|
||||||
if (delayTimeout)
|
if (delayTimeout)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
|
||||||
oneShot('click');
|
oneShot('click');
|
||||||
};
|
};
|
||||||
|
|
||||||
useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
|
useShortcuts(data.game.focusKey, () => [{ label: "Details", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardElement
|
<CardElement
|
||||||
|
|
@ -69,7 +69,7 @@ export function CardList (data: {
|
||||||
{
|
{
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey } = useFocusable({
|
||||||
focusKey: data.id,
|
focusKey: data.id,
|
||||||
focusable: data.games.length > 0,
|
focusable: data.games.length > 0 || (!!data.finalElement && (Array.isArray(data.finalElement) ? data.finalElement.length > 0 : !!data.finalElement)),
|
||||||
preferredChildFocusKey: data.focus
|
preferredChildFocusKey: data.focus
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import { TwitchIcon } from "../scripts/brandIcons";
|
||||||
import { rommLoggedInQuery } from "../scripts/queries/romm";
|
import { rommLoggedInQuery } from "../scripts/queries/romm";
|
||||||
import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
|
import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
|
||||||
import { SystemInfoContext } from "../scripts/contexts";
|
import { SystemInfoContext } from "../scripts/contexts";
|
||||||
import { useRouter } from "@tanstack/react-router";
|
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||||
import { oneShot } from "../scripts/audio/audio";
|
import { oneShot } from "../scripts/audio/audio";
|
||||||
import { hasUpdateQuery } from "../scripts/queries/system";
|
import { hasUpdateQuery } from "../scripts/queries/system";
|
||||||
|
|
||||||
|
|
@ -87,16 +87,24 @@ export interface HeaderAccount
|
||||||
|
|
||||||
function UpdateStatus ()
|
function UpdateStatus ()
|
||||||
{
|
{
|
||||||
|
const handleSelect = () =>
|
||||||
|
{
|
||||||
|
navigate({ to: '/settings/about' });
|
||||||
|
};
|
||||||
const hasUnread = false;
|
const hasUnread = false;
|
||||||
return <div className={classNames("tooltip tooltip-bottom tooltip-warning p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })} data-tip="Update Available">
|
const navigate = useNavigate();
|
||||||
<CircleFadingArrowUp className="sm:size-4 md:size-8 text-warning" />
|
const { ref } = useFocusable({
|
||||||
|
focusKey: 'update-bt', onEnterPress: handleSelect
|
||||||
|
});
|
||||||
|
return <div onClick={handleSelect} ref={ref} className={classNames("tooltip tooltip-bottom tooltip-warning p-2 rounded-full focusable focusable-primary focusable-hover focused:bg-warning cursor-pointer", { "bg-warning text-warning-content ": hasUnread })} data-tip="Update Available">
|
||||||
|
<CircleFadingArrowUp className="sm:size-4 md:size-8 text-warning in-focused:text-warning-content" />
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationStatus ()
|
function NotificationStatus ()
|
||||||
{
|
{
|
||||||
const hasUnread = false;
|
const hasUnread = false;
|
||||||
return <div className={classNames("p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })}>
|
return <div className={classNames("p-2 rounded-full focused:bg-base-300", { "bg-warning text-warning-content": hasUnread })}>
|
||||||
<Bell className="sm:size-4 md:size-8" />
|
<Bell className="sm:size-4 md:size-8" />
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -219,14 +227,17 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
||||||
router.navigate({ to: '/settings/accounts' });
|
router.navigate({ to: '/settings/accounts' });
|
||||||
oneShot('click');
|
oneShot('click');
|
||||||
};
|
};
|
||||||
const { ref } = useFocusable({
|
|
||||||
focusKey: 'accounts', onEnterPress: handleSelect
|
|
||||||
});
|
|
||||||
|
|
||||||
const accounts: HeaderAccount[] = [];
|
const accounts: HeaderAccount[] = [];
|
||||||
if (data.accounts) accounts.push(...data.accounts);
|
if (data.accounts) accounts.push(...data.accounts);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { ref } = useFocusable({
|
||||||
|
focusKey: 'accounts',
|
||||||
|
onEnterPress: handleSelect,
|
||||||
|
focusable: accounts.length > 0
|
||||||
|
});
|
||||||
|
|
||||||
if (rommUser.data?.hasLogin || rommUser.isError)
|
if (rommUser.data?.hasLogin || rommUser.isError)
|
||||||
{
|
{
|
||||||
accounts.push({
|
accounts.push({
|
||||||
|
|
@ -259,7 +270,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
||||||
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
|
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
|
||||||
{
|
{
|
||||||
const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' });
|
const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' });
|
||||||
const { data: hasUpdate } = useQuery(hasUpdateQuery);
|
const { data: update } = useQuery(hasUpdateQuery);
|
||||||
return <div ref={ref} className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
|
return <div ref={ref} className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<div className="flex gap-2 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
|
<div className="flex gap-2 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
|
||||||
|
|
@ -267,7 +278,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
|
||||||
<WiFiStatus />
|
<WiFiStatus />
|
||||||
<BluetoothStatus />
|
<BluetoothStatus />
|
||||||
<NotificationStatus />
|
<NotificationStatus />
|
||||||
{!!hasUpdate && hasUpdate >= 1 && <UpdateStatus />}
|
{!!update && update.hasUpdate >= 1 && <UpdateStatus />}
|
||||||
<BatteryStatus />
|
<BatteryStatus />
|
||||||
</div>
|
</div>
|
||||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,20 @@ export default function ImageWithFallbacks (data: {
|
||||||
{
|
{
|
||||||
img.dataset.index = String(nextIndex);
|
img.dataset.index = String(nextIndex);
|
||||||
img.src = data.src[nextIndex].href;
|
img.src = data.src[nextIndex].href;
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return <img draggable={data.draggable} className={data.className} src={data.src[0].href} data-index={0} onError={handleError}></img>;
|
return <img
|
||||||
|
draggable={data.draggable}
|
||||||
|
className={data.className}
|
||||||
|
src={data.src[0].href}
|
||||||
|
data-index={0}
|
||||||
|
onError={handleError}
|
||||||
|
onLoad={e =>
|
||||||
|
{
|
||||||
|
e.currentTarget.dataset.loaded = "true";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</img>;
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
import { Ref, RefObject } from 'react';
|
||||||
import './dots.css';
|
import './dots.css';
|
||||||
|
|
||||||
export default function DotsLoading ()
|
export default function DotsLoading (data: { ref?: Ref<any>; })
|
||||||
{
|
{
|
||||||
return <div className="flex gap-3 justify-center animation_alternate items-center pt-8">
|
return <div ref={data.ref} className="flex gap-3 justify-center animation_alternate items-center pt-8">
|
||||||
<div className="ball size-6"></div>
|
<div className="ball size-6"></div>
|
||||||
<div className="ball size-6"></div>
|
<div className="ball size-6"></div>
|
||||||
<div className="ball size-6"></div>
|
<div className="ball size-6"></div>
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
||||||
if (!cmd) return;
|
if (!cmd) return;
|
||||||
if (cmd.emulator === 'EMULATORJS')
|
if (cmd.emulator === 'EMULATORJS')
|
||||||
{
|
{
|
||||||
const params = new URLSearchParams(cmd.command);
|
const params = new URLSearchParams(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command);
|
||||||
router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()) });
|
router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()) });
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
|
|
@ -120,14 +120,15 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
||||||
let mainButton: any | undefined = undefined;
|
let mainButton: any | undefined = undefined;
|
||||||
if (status === 'installed')
|
if (status === 'installed')
|
||||||
{
|
{
|
||||||
mainButton = <div className="flex gap-2"><ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
|
mainButton = <div className="flex gap-2">
|
||||||
key="primary"
|
<ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
|
||||||
type='primary'
|
key="primary"
|
||||||
id="mainAction"
|
type='primary'
|
||||||
>
|
id="mainAction"
|
||||||
<Play />
|
>
|
||||||
|
<Play />
|
||||||
|
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
{validCommands.length > 1 &&
|
{validCommands.length > 1 &&
|
||||||
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommands(true, 'allActionsBtn')}>
|
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommands(true, 'allActionsBtn')}>
|
||||||
|
|
|
||||||
|
|
@ -464,7 +464,7 @@ const assets = new Set<string>([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Store basePath resolved from Vite config
|
// Store basePath resolved from Vite config
|
||||||
const BASE_PATH = "./";
|
const BASE_PATH = "/";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--breakpoint-sm: 0px;
|
--breakpoint-sm: 0px;
|
||||||
--breakpoint-md: 1280px;
|
--breakpoint-md: 1024px;
|
||||||
--page-scroll-bg: transparent;
|
--page-scroll-bg: transparent;
|
||||||
--animation-size: 1;
|
--animation-size: 1;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,6 @@ function RouteComponent ()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
|
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
|
||||||
<AutoFocus focus={focusSelf} />
|
|
||||||
<GameDetailsContext value={{
|
<GameDetailsContext value={{
|
||||||
update: () => setUpdate(v => v + 1)
|
update: () => setUpdate(v => v + 1)
|
||||||
}} >
|
}} >
|
||||||
|
|
@ -214,6 +213,7 @@ function RouteComponent ()
|
||||||
</div>
|
</div>
|
||||||
<FloatingShortcuts />
|
<FloatingShortcuts />
|
||||||
</GameDetailsContext>
|
</GameDetailsContext>
|
||||||
|
<AutoFocus focus={focusSelf} />
|
||||||
</AnimatedBackground>
|
</AnimatedBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -13,10 +13,13 @@ import
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Plus,
|
Plus,
|
||||||
|
LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import
|
import
|
||||||
{
|
{
|
||||||
createFileRoute,
|
createFileRoute,
|
||||||
|
PathParamOptions,
|
||||||
|
ToPathOption,
|
||||||
useRouter,
|
useRouter,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -52,6 +55,7 @@ import { FloatingShortcuts } from "../components/Shortcuts";
|
||||||
import SelectMenu from "../components/SelectMenu";
|
import SelectMenu from "../components/SelectMenu";
|
||||||
import HeaderSearchField from "../components/HeaderSearchField";
|
import HeaderSearchField from "../components/HeaderSearchField";
|
||||||
import CardElement from "../components/CardElement";
|
import CardElement from "../components/CardElement";
|
||||||
|
import { Router } from "..";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: ConsoleHomeUI,
|
component: ConsoleHomeUI,
|
||||||
|
|
@ -114,24 +118,30 @@ function Preview (data: { index: number; children?: any; })
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GetStoreGamesCard ()
|
function AdditionalCard (data: {
|
||||||
|
id: string,
|
||||||
|
route: keyof typeof Router.routesByPath,
|
||||||
|
title: string,
|
||||||
|
subTitle: string,
|
||||||
|
index: number,
|
||||||
|
actionLabel: string;
|
||||||
|
icon: LucideIcon | string;
|
||||||
|
badgeIcon?: LucideIcon;
|
||||||
|
})
|
||||||
{
|
{
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const handleNavigate = () =>
|
|
||||||
{
|
|
||||||
router.navigate({ to: '/store/tab/games' });
|
|
||||||
};
|
|
||||||
return <CardElement onFocus={scrollIntoViewHandler({ behavior: "smooth", inline: "center" })} badges={[<Search className="size-8" />]} onAction={handleNavigate} title="Gameflow Store" subtitle="Get Free Games" preview={<Preview index={43} ><Store className="not-mobile:drop-shadow-md in-focus:animate-rotate size-32" /></Preview>} focusKey='store-games-btn' index={0} id="store-games-btn" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShowAllGamesCard ()
|
|
||||||
{
|
|
||||||
const router = useRouter();
|
|
||||||
const handleNavigate = () =>
|
const handleNavigate = () =>
|
||||||
{
|
{
|
||||||
router.navigate({ to: '/games' });
|
router.navigate({ to: data.route as any });
|
||||||
};
|
};
|
||||||
return <CardElement onFocus={scrollIntoViewHandler({ behavior: "smooth", inline: "center" })} onAction={handleNavigate} title="All Games" preview={<Preview index={17} ><LayoutGrid className="not-mobile:drop-shadow-md in-focus:animate-rotate size-32" /></Preview>} focusKey='all-games-btn' index={0} id="all-games-btn" />;
|
useShortcuts(data.id, () => [{ label: data.actionLabel, button: GamePadButtonCode.A, action: handleNavigate }]);
|
||||||
|
return <CardElement onFocus={scrollIntoViewHandler({ behavior: "smooth", inline: "center" })} badges={data.badgeIcon ? [<data.badgeIcon className="size-8" />] : undefined} onAction={handleNavigate} title={data.title} subtitle={data.subTitle} preview={<Preview index={data.index} >
|
||||||
|
{typeof data.icon === 'string' ?
|
||||||
|
<img className="not-mobile:drop-shadow-md" src={data.icon} /> :
|
||||||
|
<data.icon className="not-mobile:drop-shadow-md in-focus:animate-rotate size-32" />
|
||||||
|
}
|
||||||
|
</Preview>} focusKey={data.id} index={0} id={data.id} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeList (data: {
|
function HomeList (data: {
|
||||||
|
|
@ -197,8 +207,13 @@ function HomeList (data: {
|
||||||
id="games-list"
|
id="games-list"
|
||||||
setBackground={bg.setBackground}
|
setBackground={bg.setBackground}
|
||||||
filters={{ limit: 12, orderBy: 'activity' }}
|
filters={{ limit: 12, orderBy: 'activity' }}
|
||||||
finalElement={[<GetStoreGamesCard />, <ShowAllGamesCard />]}
|
finalElement={[
|
||||||
emptyElement={[]}
|
<AdditionalCard key='store-games-btn' icon={Store} badgeIcon={Search} route='/store/tab/games' id='store-games-btn' title="Gameflow Store" subTitle="Get Free Games" index={43} actionLabel="Go To Store" />,
|
||||||
|
<AdditionalCard key='all-games-btn' icon={LayoutGrid} route='/games' id='all-games-btn' title="All Games" subTitle="All Owned Games" index={17} actionLabel="All Games" />
|
||||||
|
]}
|
||||||
|
emptyElement={[
|
||||||
|
<AdditionalCard key='romm-setup-btn' icon={'https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg'} route='/settings/accounts' id='romm-setup-btn' title="Setup Romm" subTitle="To Import Games" index={18} actionLabel="Setup Romm" />
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
||||||
</>;
|
</>;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/
|
||||||
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts';
|
import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts';
|
||||||
import { useJobStatus } from '../scripts/utils';
|
import { useJobStatus } from '../scripts/utils';
|
||||||
import { useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { rommApi } from '../scripts/clientApi';
|
||||||
|
|
||||||
export const Route = createFileRoute('/launcher/$source/$id')({
|
export const Route = createFileRoute('/launcher/$source/$id')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -39,7 +40,7 @@ function RouteComponent ()
|
||||||
|
|
||||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||||
|
|
||||||
const { data, state } = useJobStatus('launch-game', {
|
const { state, data } = useJobStatus('launch-game', {
|
||||||
onProgress (process, data)
|
onProgress (process, data)
|
||||||
{
|
{
|
||||||
if (progressRef.current)
|
if (progressRef.current)
|
||||||
|
|
@ -55,6 +56,7 @@ function RouteComponent ()
|
||||||
},
|
},
|
||||||
}, [progressRef.current, HandleGoBack]);
|
}, [progressRef.current, HandleGoBack]);
|
||||||
|
|
||||||
|
|
||||||
useBlocker({ shouldBlockFn: () => !!data });
|
useBlocker({ shouldBlockFn: () => !!data });
|
||||||
|
|
||||||
return <AnimatedBackground ref={ref} backgroundKey='game-details'>
|
return <AnimatedBackground ref={ref} backgroundKey='game-details'>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
|
||||||
|
|
||||||
import { systemInfoQuery } from '@queries/system';
|
import { Button } from '@/mainview/components/options/Button';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
|
import { checkUpdateMutation, hasUpdateQuery, systemInfoQuery, updateMutation } from '@queries/system';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { ArrowUpCircle, CircleFadingArrowUp, RefreshCcw } from 'lucide-react';
|
||||||
import prettyBytes from 'pretty-bytes';
|
import prettyBytes from 'pretty-bytes';
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings/about')({
|
export const Route = createFileRoute('/settings/about')({
|
||||||
|
|
@ -12,58 +15,87 @@ export const Route = createFileRoute('/settings/about')({
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { data: systemInfo } = useQuery(systemInfoQuery);
|
const { data: systemInfo } = useQuery(systemInfoQuery);
|
||||||
return <table className="table">
|
const { ref, focusKey } = useFocusable({ focusKey: 'about-section' });
|
||||||
<tbody>
|
const { data: hasUpdate, refetch: refetchHasUpdate } = useQuery(hasUpdateQuery);
|
||||||
<tr>
|
const update = useMutation(updateMutation);
|
||||||
<th>Agent</th>
|
const forceCheckUpdate = useMutation({
|
||||||
<td>{navigator.userAgent}</td>
|
...checkUpdateMutation,
|
||||||
</tr>
|
onSuccess (data, variables, onMutateResult, context)
|
||||||
{/* row 2 */}
|
{
|
||||||
<tr>
|
refetchHasUpdate();
|
||||||
<th>Platform</th>
|
},
|
||||||
<td>{navigator.platform}</td>
|
});
|
||||||
</tr>
|
|
||||||
<tr>
|
return <table ref={ref} className="table">
|
||||||
<th>Resolution</th>
|
|
||||||
<td>{screen.width}x{screen.height}</td>
|
<FocusContext value={focusKey}>
|
||||||
</tr>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Window</th>
|
<th>Version</th>
|
||||||
<td>{window.innerWidth}x{window.innerHeight}</td>
|
<td>{systemInfo?.data?.version}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* row 3 */}
|
<tr>
|
||||||
<tr>
|
<th>Update</th>
|
||||||
<th>User</th>
|
<td className='flex flex-flex-wrap gap-2'>
|
||||||
<td>{systemInfo?.data?.user}</td>
|
{
|
||||||
</tr>
|
hasUpdate && hasUpdate.hasUpdate > 0 ?
|
||||||
<tr>
|
<Button className='gap-3' style='warning' id='update-btn' onAction={() => update.mutate()}><CircleFadingArrowUp /> Update to {hasUpdate?.version}</Button> :
|
||||||
<th>Architecture</th>
|
<Button className='gap-3' id='update-btn' onAction={() => forceCheckUpdate.mutate()}>{forceCheckUpdate.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />}Check for Update</Button>
|
||||||
<td>{systemInfo?.data?.arch}</td>
|
}
|
||||||
</tr>
|
{<Button className='gap-3' id='force-update-btn' onAction={() => update.mutate()}><CircleFadingArrowUp /> Force Update</Button>}
|
||||||
<tr>
|
</td>
|
||||||
<th>System</th>
|
</tr>
|
||||||
<td>{systemInfo?.data?.platform}</td>
|
<tr>
|
||||||
</tr>
|
<th>Agent</th>
|
||||||
<tr>
|
<td>{navigator.userAgent}</td>
|
||||||
<th>Hostname</th>
|
</tr>
|
||||||
<td>{systemInfo?.data?.hostname}</td>
|
{/* row 2 */}
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<th>Platform</th>
|
||||||
<th>Machine</th>
|
<td>{navigator.platform}</td>
|
||||||
<td>{systemInfo?.data?.machine}</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<th>Resolution</th>
|
||||||
<th>Sizes</th>
|
<td>{screen.width}x{screen.height}</td>
|
||||||
<td>Cache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<th>Window</th>
|
||||||
<th>Source</th>
|
<td>{window.innerWidth}x{window.innerHeight}</td>
|
||||||
<td>{systemInfo?.data?.source}</td>
|
</tr>
|
||||||
</tr>
|
{/* row 3 */}
|
||||||
<tr>
|
<tr>
|
||||||
<th>Steam Deck</th>
|
<th>User</th>
|
||||||
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
|
<td>{systemInfo?.data?.user}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
<tr>
|
||||||
|
<th>Architecture</th>
|
||||||
|
<td>{systemInfo?.data?.arch}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>System</th>
|
||||||
|
<td>{systemInfo?.data?.platform}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Hostname</th>
|
||||||
|
<td>{systemInfo?.data?.hostname}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Machine</th>
|
||||||
|
<td>{systemInfo?.data?.machine}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Sizes</th>
|
||||||
|
<td>Cache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<td>{systemInfo?.data?.source}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Steam Deck</th>
|
||||||
|
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</FocusContext>
|
||||||
</table>;
|
</table>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { AutoFocus } from '@/mainview/components/AutoFocus';
|
import { AutoFocus } from '@/mainview/components/AutoFocus';
|
||||||
|
import DotsLoading from '@/mainview/components/backgrounds/dots';
|
||||||
import { Button } from '@/mainview/components/options/Button';
|
import { Button } from '@/mainview/components/options/Button';
|
||||||
import { OptionDropdown } from '@/mainview/components/options/OptionDropdown';
|
import { OptionDropdown } from '@/mainview/components/options/OptionDropdown';
|
||||||
import { OptionInput } from '@/mainview/components/options/OptionInput';
|
import { OptionInput } from '@/mainview/components/options/OptionInput';
|
||||||
|
|
@ -7,16 +8,34 @@ import { RoundButton } from '@/mainview/components/RoundButton';
|
||||||
import { getAllPluginsQuery, getPluginDetailsQuery } from '@/mainview/scripts/queries/plugins';
|
import { getAllPluginsQuery, getPluginDetailsQuery } from '@/mainview/scripts/queries/plugins';
|
||||||
import { getPluginActionsQuery, getPluginSettingQuery, getPluginSettingsDefinitionQuery, pluginActionMutation, setPluginSettingMutation } from '@/mainview/scripts/queries/settings';
|
import { getPluginActionsQuery, getPluginSettingQuery, getPluginSettingsDefinitionQuery, pluginActionMutation, setPluginSettingMutation } from '@/mainview/scripts/queries/settings';
|
||||||
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||||
|
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
|
||||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { JSONSchema7 } from 'json-schema';
|
import { JSONSchema7 } from 'json-schema';
|
||||||
import { ArrowLeft, CirclePlay, Play, Settings2, SettingsIcon } from 'lucide-react';
|
import { ArrowLeft, CirclePlay, Play, Settings2, SettingsIcon } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
export const Route = createFileRoute('/settings/plugin/$source')({
|
export const Route = createFileRoute('/settings/plugin/$source')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
pendingComponent: Loading,
|
||||||
|
async loader (ctx)
|
||||||
|
{
|
||||||
|
const definitions = await ctx.context.queryClient.fetchQuery(getPluginSettingsDefinitionQuery(ctx.params.source));
|
||||||
|
const actions = await ctx.context.queryClient.fetchQuery(getPluginActionsQuery(ctx.params.source));
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
return { definitions, actions };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function Loading ()
|
||||||
|
{
|
||||||
|
const { ref, focusSelf } = useFocusable({ focusKey: 'plugins' });
|
||||||
|
return <>
|
||||||
|
<DotsLoading ref={ref} />
|
||||||
|
<AutoFocus focus={focusSelf} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
function PluginAction (data: { id: string, title: string | undefined, description: string | undefined; action: string; reload: () => void; })
|
function PluginAction (data: { id: string, title: string | undefined, description: string | undefined; action: string; reload: () => void; })
|
||||||
{
|
{
|
||||||
const { source } = Route.useParams();
|
const { source } = Route.useParams();
|
||||||
|
|
@ -91,15 +110,19 @@ function PluginOption (data: { name: string, title?: string, prop: JSONSchema7;
|
||||||
|
|
||||||
function Settings ()
|
function Settings ()
|
||||||
{
|
{
|
||||||
|
const { definitions, actions } = Route.useLoaderData();
|
||||||
const { source } = Route.useParams();
|
const { source } = Route.useParams();
|
||||||
const { data: definitions, refetch: refetchDefinitions } = useQuery(getPluginSettingsDefinitionQuery(source));
|
const queryClient = useQueryClient();
|
||||||
const { data: actions, refetch: referchActions } = useQuery(getPluginActionsQuery(source));
|
|
||||||
const handleReload = () =>
|
const handleReload = () =>
|
||||||
{
|
{
|
||||||
referchActions();
|
queryClient.refetchQueries(getPluginSettingsDefinitionQuery(source));
|
||||||
refetchDefinitions();
|
queryClient.refetchQueries(getPluginActionsQuery(source));
|
||||||
};
|
};
|
||||||
const { ref, focusKey } = useFocusable({ focusKey: 'plugin-settings' });
|
const { ref, focusKey } = useFocusable({
|
||||||
|
focusKey: 'plugin-settings',
|
||||||
|
focusable: (definitions?.properties && Object.keys(definitions?.properties).length > 0) || actions.length > 0
|
||||||
|
});
|
||||||
return <div ref={ref}>
|
return <div ref={ref}>
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
{!!definitions?.properties && Object.entries(Object.groupBy(Object.entries(definitions?.properties)
|
{!!definitions?.properties && Object.entries(Object.groupBy(Object.entries(definitions?.properties)
|
||||||
|
|
@ -142,16 +165,19 @@ function RouteComponent ()
|
||||||
|
|
||||||
return <div ref={ref}>
|
return <div ref={ref}>
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<RoundButton className='absolute' id='return-to-plugins' onAction={handleReturn}><ArrowLeft /></RoundButton>
|
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
<div className='flex text-2xl font-bold gap-2 grow items-center justify-center'>
|
<div className='flex text-2xl font-bold gap-2 grow items-center justify-center'>
|
||||||
|
<RoundButton onFocus={scrollIntoViewHandler({ inline: 'end' })} id='return-to-plugins' onAction={handleReturn}><ArrowLeft /></RoundButton>
|
||||||
<img className='h-12' src={data?.icon}></img>
|
<img className='h-12' src={data?.icon}></img>
|
||||||
{data?.displayName}
|
{data?.displayName}
|
||||||
</div>
|
</div>
|
||||||
<ul className='flex gap-2 justify-center'>{data?.keywords?.map((k, i) => <li key={i} className='bg-base-200 rounded-full p-2 px-4'>{k}</li>)}</ul>
|
<ul className='flex gap-2 justify-center'>{data?.keywords?.map((k, i) => <li key={i} className='bg-base-200 rounded-full p-2 px-4'>{k}</li>)}</ul>
|
||||||
<div className='bg-base-200 p-4 rounded-2xl'>{data?.description}</div>
|
<div className='bg-base-200 p-4 rounded-2xl'>{data?.description}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Settings />
|
<Settings />
|
||||||
|
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
<AutoFocus focus={focusSelf} />
|
<AutoFocus focus={focusSelf} />
|
||||||
</div>;
|
</div>;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { AutoFocus } from '@/mainview/components/AutoFocus';
|
||||||
import { pluginCategoryIcons, pluginCategoryPriorities } from '@/mainview/components/Constants';
|
import { pluginCategoryIcons, pluginCategoryPriorities } from '@/mainview/components/Constants';
|
||||||
import { Button } from '@/mainview/components/options/Button';
|
import { Button } from '@/mainview/components/options/Button';
|
||||||
import { OptionInput } from '@/mainview/components/options/OptionInput';
|
import { OptionInput } from '@/mainview/components/options/OptionInput';
|
||||||
|
|
@ -62,7 +63,7 @@ function Plugin (data: {
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery);
|
const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery);
|
||||||
const { ref, focusKey } = useFocusable({ focusKey: 'plugins' });
|
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugins' });
|
||||||
const pluginMutation = useMutation({
|
const pluginMutation = useMutation({
|
||||||
...enablePluginMutation, onSuccess (data, variables, onMutateResult, context)
|
...enablePluginMutation, onSuccess (data, variables, onMutateResult, context)
|
||||||
{
|
{
|
||||||
|
|
@ -84,6 +85,7 @@ function RouteComponent ()
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>;
|
||||||
})}
|
})}
|
||||||
|
<AutoFocus focus={focusSelf} />
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ function RouteComponent ()
|
||||||
const { focus } = Route.useSearch();
|
const { focus } = Route.useSearch();
|
||||||
const [search] = useSessionStorage<string | undefined>(`${Route.to}-search`, undefined);
|
const [search] = useSessionStorage<string | undefined>(`${Route.to}-search`, undefined);
|
||||||
const navigator = useNavigate();
|
const navigator = useNavigate();
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
|
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus ?? 'store-games' });
|
||||||
const [filter, setFilter] = useSessionStorage<GameListFilterType>('store-games-filters', {});
|
const [filter, setFilter] = useSessionStorage<GameListFilterType>('store-games-filters', {});
|
||||||
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter));
|
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter));
|
||||||
const { data: gameFilters } = useQuery(gameFiltersQuery({ source: 'store' }));
|
const { data: gameFilters } = useQuery(gameFiltersQuery({ source: 'store' }));
|
||||||
|
|
@ -80,7 +80,8 @@ function RouteComponent ()
|
||||||
if (isFetchingNextPage || isFetching)
|
if (isFetchingNextPage || isFetching)
|
||||||
return;
|
return;
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}} />} games={data?.pages.flatMap((page) => page.data.map((g) =>
|
}} />}
|
||||||
|
games={data?.pages.flatMap((page) => page.data.map((g) =>
|
||||||
{
|
{
|
||||||
const badges: JSX.Element[] = [];
|
const badges: JSX.Element[] = [];
|
||||||
if (g.id.source === 'local')
|
if (g.id.source === 'local')
|
||||||
|
|
@ -119,7 +120,8 @@ function RouteComponent ()
|
||||||
onFocus: (k, n, d) => handleFocus(k, n, d)
|
onFocus: (k, n, d) => handleFocus(k, n, d)
|
||||||
} satisfies GameMetaExtra as GameMetaExtra;
|
} satisfies GameMetaExtra as GameMetaExtra;
|
||||||
})
|
})
|
||||||
) ?? []} id={'store-games'} />
|
) ?? []}
|
||||||
|
id={'store-games'} />
|
||||||
</div>
|
</div>
|
||||||
<div className='fixed left-2 top-52 bottom-0 sm:w-10 md:w-14 z-10'>
|
<div className='fixed left-2 top-52 bottom-0 sm:w-10 md:w-14 z-10'>
|
||||||
<SideFilters id='filter-btns' localFilter={filter} setLocalFilter={setFilter} filterValues={gameFilters} filters={{ source: 'store' }} />
|
<SideFilters id='filter-btns' localFilter={filter} setLocalFilter={setFilter} filterValues={gameFilters} filters={{ source: 'store' }} />
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { autoEmulatorsQuery } from '@queries/settings';
|
import { autoEmulatorsQuery } from '@queries/settings';
|
||||||
import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store';
|
import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store';
|
||||||
|
import ImageWithFallbacks from '@/mainview/components/ImageWithFallbacks';
|
||||||
|
|
||||||
export const Route = createFileRoute('/store/tab/')({
|
export const Route = createFileRoute('/store/tab/')({
|
||||||
component: RouteComponent
|
component: RouteComponent
|
||||||
|
|
@ -64,16 +65,7 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
|
||||||
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-md w-full ring-6 ring-base-200 border-6 border-base-200">
|
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-md w-full ring-6 ring-base-200 border-6 border-base-200">
|
||||||
<div className='flex relative h-full overflow-hidden'>
|
<div className='flex relative h-full overflow-hidden'>
|
||||||
<div className='absolute w-full h-full z-0 bg-base-200'>
|
<div className='absolute w-full h-full z-0 bg-base-200'>
|
||||||
<picture key={selectedGame}
|
<ImageWithFallbacks src={previewUrls ?? []} className='w-full h-full transition-all duration-500 ease-out scale-110 opacity-0 light:data-loaded:opacity-40 dark:data-loaded:opacity-40 z-0 object-cover' />
|
||||||
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 light:data-loaded:opacity-40 dark:data-loaded:opacity-80 z-0'
|
|
||||||
onLoad={(e) =>
|
|
||||||
{
|
|
||||||
e.currentTarget.dataset.loaded = "true";
|
|
||||||
e.currentTarget.classList.toggle('scale-110', false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{previewUrls?.map((u, i) => <source key={i} src={u.href} />)}
|
|
||||||
</picture>
|
|
||||||
</div>
|
</div>
|
||||||
<div key={selectedGame} className='flex sm:flex-wrap md:flex-nowrap grow z-1 p-8 opacity-0 animate-fade-in h-full items-end gap-4 sm:justify-end md:justify-between'>
|
<div key={selectedGame} className='flex sm:flex-wrap md:flex-nowrap grow z-1 p-8 opacity-0 animate-fade-in h-full items-end gap-4 sm:justify-end md:justify-between'>
|
||||||
<div className='flex gap-4 max-h-full z-1 grow md:h-full'>
|
<div className='flex gap-4 max-h-full z-1 grow md:h-full'>
|
||||||
|
|
@ -89,7 +81,7 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onAction={() => storeContext.showDetails('game', game.id.source, game.id.id, focusKey)} className='px-6 py-3 text-2xl! z-1 gap-2 focusable focusable-primary' id={'play-featured-btn'}> <Search /> Details</Button>
|
<Button onAction={() => storeContext.showDetails('game', game.id.source, game.id.id, focusKey)} className='px-6 py-3 text-2xl! z-1 gap-2 drop-shadow-md focusable focusable-primary' id={'play-featured-btn'}> <Search /> Details</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div> : <div className='skeleton w-full rounded-3xl grow sm:h-64 z-15' />}
|
</div> : <div className='skeleton w-full rounded-3xl grow sm:h-64 z-15' />}
|
||||||
|
|
|
||||||
|
|
@ -57,3 +57,23 @@ export const hasUpdateQuery = queryOptions({
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 30
|
staleTime: 1000 * 60 * 30
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const checkUpdateMutation = mutationOptions({
|
||||||
|
mutationKey: ['update', 'check'],
|
||||||
|
mutationFn: async () =>
|
||||||
|
{
|
||||||
|
const { data, error } = await systemApi.api.system.update.check.post();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateMutation = mutationOptions({
|
||||||
|
mutationKey: ['update'],
|
||||||
|
mutationFn: async () =>
|
||||||
|
{
|
||||||
|
const { data, error } = await systemApi.api.system.update.post();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -272,6 +272,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
||||||
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
|
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
|
||||||
onCompleted?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
|
onCompleted?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
|
onClosed?: () => void;
|
||||||
},
|
},
|
||||||
deps?: DependencyList
|
deps?: DependencyList
|
||||||
)
|
)
|
||||||
|
|
@ -289,6 +290,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
||||||
const sub = jobsApi.api.jobs[id].subscribe({ query: init?.query });
|
const sub = jobsApi.api.jobs[id].subscribe({ query: init?.query });
|
||||||
ref.current = sub as any;
|
ref.current = sub as any;
|
||||||
|
|
||||||
|
sub.on('close', () => init?.onClosed?.());
|
||||||
sub.subscribe(({ data }) =>
|
sub.subscribe(({ data }) =>
|
||||||
{
|
{
|
||||||
switch (data.type)
|
switch (data.type)
|
||||||
|
|
@ -326,7 +328,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
|
||||||
sub.close();
|
sub.close();
|
||||||
ref.current = null;
|
ref.current = null;
|
||||||
};
|
};
|
||||||
}, [id, init?.query, init?.onEnded, init?.onCompleted, init?.onProgress, init?.onError, ...(deps ?? [])]);
|
}, [id, init?.query]);
|
||||||
|
|
||||||
return { data, state, error, wsRef: ref };
|
return { data, state, error, wsRef: ref };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
src/mainview/types.d.ts
vendored
1
src/mainview/types.d.ts
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
declare const __HOST__: string;
|
declare const __HOST__: string;
|
||||||
declare const __PUBLIC__: boolean;
|
declare const __PUBLIC__: boolean;
|
||||||
|
declare const __FLATPAK__: boolean;
|
||||||
declare const __EMULATORS__: Record<string, string>;
|
declare const __EMULATORS__: Record<string, string>;
|
||||||
declare module "@emulators" {
|
declare module "@emulators" {
|
||||||
const data: Record<string, string>;
|
const data: Record<string, string>;
|
||||||
|
|
|
||||||
1
src/shared/types..d.ts
vendored
1
src/shared/types..d.ts
vendored
|
|
@ -341,6 +341,7 @@ declare interface SaveFileChange
|
||||||
isGlob?: true;
|
isGlob?: true;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
shared: boolean;
|
shared: boolean;
|
||||||
|
fixedSize?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare type SaveSlots = Record<string, { cwd: string; }>;
|
declare type SaveSlots = Record<string, { cwd: string; }>;
|
||||||
|
|
@ -102,7 +102,8 @@ export default defineConfig(({ command }) =>
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
__HOST__: JSON.stringify(host),
|
__HOST__: JSON.stringify(host),
|
||||||
__PUBLIC__: process.env.PUBLIC_ACCESS ? true : false
|
__PUBLIC__: process.env.PUBLIC_ACCESS ? true : false,
|
||||||
|
__FLATPAK__: process.env.FLATPAK_BUILD ? true : false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue