diff --git a/.config/appimage/AppRun b/.config/appimage/AppRun new file mode 100644 index 0000000..7320ecc --- /dev/null +++ b/.config/appimage/AppRun @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$APPDIR/usr/bin/{{BINARY_NAME}}" "$@" \ No newline at end of file diff --git a/.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml b/.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml new file mode 100644 index 0000000..1d4cc0f --- /dev/null +++ b/.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml @@ -0,0 +1,53 @@ + + + {{APP_ID}} + CC0-1.0 + {{LICENSE}} + {{APP_NAME}} + Retro gaming frontend designed for handheld and controllers + + Simeon Radivoev + + +

A Cross-Platform Retro gaming frontend designed for handheld and controllers. Focused on building a simple user experience and intuitive UI.

+
+ + Game + + + always + + {{APP_ID}}.desktop + https://github.com/simeonradivoev/gameflow-deck + https://github.com/simeonradivoev/gameflow-deck/issues + https://github.com/sponsors/simeonradivoev + + + https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/yObFD2LySH.jpg + + + Game Details + https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/3nhuKCK6E3.jpg + + + The Settings Panel + https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/GL7SkQbHIY.png + + + Emulator Details + https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/xNj7scPEDQ.png + + + Gameflow Store + https://media.githubusercontent.com/media/simeonradivoev/gameflow-deck/master/.github/screenshots/CpBLzTNM6N.png + + + +{{{RELEASES}}} + + + + {{APP_ID}}.desktop + gameflow + +
\ No newline at end of file diff --git a/.config/appimage/com.simeonradivoev.gameflow-deck.desktop b/.config/appimage/com.simeonradivoev.gameflow-deck.desktop new file mode 100644 index 0000000..dddf26d --- /dev/null +++ b/.config/appimage/com.simeonradivoev.gameflow-deck.desktop @@ -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; \ No newline at end of file diff --git a/.config/flatpak/com.simeonradivoev.gameflow-deck.desktop b/.config/flatpak/com.simeonradivoev.gameflow-deck.desktop index bed0068..b5e2806 100644 --- a/.config/flatpak/com.simeonradivoev.gameflow-deck.desktop +++ b/.config/flatpak/com.simeonradivoev.gameflow-deck.desktop @@ -4,4 +4,4 @@ Comment=GameFlow Deck Exec=gameflow Icon=com.simeonradivoev.gameflow-deck Type=Application -Categories=Game; \ No newline at end of file +Categories=Games; \ No newline at end of file diff --git a/.config/flatpak/com.simeonradivoev.gameflow-deck.json b/.config/flatpak/com.simeonradivoev.gameflow-deck.json index ccdc833..ebe1efa 100644 --- a/.config/flatpak/com.simeonradivoev.gameflow-deck.json +++ b/.config/flatpak/com.simeonradivoev.gameflow-deck.json @@ -1,23 +1,36 @@ { "app-id": "com.simeonradivoev.gameflow-deck", - "runtime": "org.kde.Platform", - "runtime-version": "6.10", - "sdk": "org.kde.Sdk", + "runtime": "org.freedesktop.Platform", + "runtime-version": "25.08", + "sdk": "org.freedesktop.Sdk", "command": "/app/bin/gameflow", - "base": "io.qt.qtwebengine.BaseApp", - "base-version": "6.10", "finish-args": [ "--share=ipc", "--share=network", "--socket=pulseaudio", "--socket=wayland", + "--socket=inherit-wayland-socket", "--socket=x11", + "--socket=fallback-x11", + "--socket=session-bus", + "--socket=system-bus", "--device=all", "--filesystem=host", "--filesystem=home", + "--filesystem=~/.steam/steam:rw", + "--filesystem=~/.steam:rw", + "--filesystem=~/.var/app/com.valvesoftware.Steam:rw", + "--filesystem=/run/udev:ro", + "--filesystem=/run/media", + "--filesystem=xdg-documents", + "--filesystem=xdg-desktop", + "--filesystem=xdg-run/gamescope-0:rw", "--env=PKG_CONFIG_LIBDIR=/app/lib", "--env=FLATPAK_BUILD=true", - "--allow=devel" + "--allow=devel", + "--talk-name=org.freedesktop.portal.OpenURI", + "--talk-name=org.freedesktop.Flatpak", + "--talk-name=org.a11y.Bus" ], "modules": [ { @@ -29,7 +42,6 @@ "mkdir -p /app/lib", "install -Dm644 256x256.png /app/share/icons/hicolor/256x256/apps/com.simeonradivoev.gameflow-deck.png", "install -Dm644 com.simeonradivoev.gameflow-deck.desktop /app/share/applications/com.simeonradivoev.gameflow-deck.desktop", - "mv libvips-cpp.so.* /app/lib", "mv * /app/share/gameflow/", "mv /app/share/gameflow/gameflow /app/bin", "mv /app/share/gameflow/bun /app/bin", @@ -39,15 +51,15 @@ "sources": [ { "type": "dir", - "path": "../build/linux" + "path": "../../build/linux" }, { "type": "file", - "path": "../flatpak/com.simeonradivoev.gameflow-deck.desktop" + "path": "com.simeonradivoev.gameflow-deck.desktop" }, { "type": "file", - "path": "../src/mainview/public/256x256.png" + "path": "../../src/mainview/public/256x256.png" }, { "type": "script", @@ -72,23 +84,22 @@ "only-arches": [ "aarch64" ] - }, - { - "type": "file", - "path": "../node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3", - "only-arches": [ - "x86_64" - ] } ] }, { - "name": "webview", - "buildsystem": "cmake-ninja", + "name": "NW.js", + "buildsystem": "simple", + "build-commands": [ + "mkdir -p /app/bin/nw", + "mv * /app/bin/nw", + "chmod +x /app/bin/nw/nw" + ], "sources": [ { - "type": "dir", - "path": "../flatpak/webview" + "type": "archive", + "url": "https://dl.nwjs.io/v0.110.1/nwjs-v0.110.1-linux-x64.tar.gz", + "sha256": "d9a9ed2255e9ee87c9dd1860d9c7a479cea5279dcd80d3e80e23b083d325554a" } ] } diff --git a/.config/flatpak/webview/CMakeLists.txt b/.config/flatpak/webview/CMakeLists.txt deleted file mode 100644 index 511252a..0000000 --- a/.config/flatpak/webview/CMakeLists.txt +++ /dev/null @@ -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 -) \ No newline at end of file diff --git a/.config/flatpak/webview/main.cpp b/.config/flatpak/webview/main.cpp deleted file mode 100644 index d0982e7..0000000 --- a/.config/flatpak/webview/main.cpp +++ /dev/null @@ -1,14 +0,0 @@ -#include -#include -#include -#include - -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(); -} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 84ec3e5..e6ff5ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,7 +83,7 @@ jobs: with: type: "zip" directory: ${{ github.workspace }} - filename: "Gameflow-Windows.zip" + filename: "Gameflow-win32-x64.zip" path: "canary-build-Windows" - name: Publish Release @@ -96,4 +96,4 @@ jobs: omitBodyDuringUpdate: true replacesArtifacts: true token: ${{ secrets.GITHUB_TOKEN }} - artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-Windows.zip" + artifacts: "${{ github.workspace }}/canary-build-*/*.AppImage,${{ github.workspace }}/Gameflow-*.zip" diff --git a/.gitignore b/.gitignore index a89abd1..f21beae 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ gameflow-deck.code-workspace .env.local src/tests/mock-roms/db.sqlite src/tests/mock-config -bin \ No newline at end of file +bin +.config/flatpak/repo \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c6da05..b63a222 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,17 +6,22 @@ "files.watcherExclude": { "**/*.gen.*": true, "src/mainview/gen/*": true, + "**/build": true, + "**/.config/flatpack/repo/**": true, + "**/.flatpak-builder/**": true, }, "search.exclude": { "**/*.gen.*": true, - ".flatpak-builder/**/*": true, + "**/.flatpak-builder": true, + "**/.config/flatpack/repo/**": true, + "**/build": true, "src/mainview/gen/*": true, }, "npm.scriptRunner": "bun", "npm.exclude": [ "**/.flatpak-builder/**/*", "**/build/flatpack/**", - "**/flatpack/repo/**", + "**/.config/flatpack/repo/**", ], "editor.formatOnSave": true, "[typescriptreact]": { diff --git a/package.json b/package.json index 79ef69a..fc31668 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "icon": "./src/mainview/assets/icon.svg", "main": "./src/bun/index.ts", "bin": "gameflow", + "license": "AGPL-3.0", "repository": { "type": "git", "url": "https://github.com/simeonradivoev/gameflow-deck" @@ -22,7 +23,6 @@ "build:dev:vite": "NODE_ENV=development bun run build:vite", "build": "bun run build:vite && bun run ./scripts/package-bun.ts", "build:prod": "NODE_ENV=production bun run build", - "build:prod:dynamic": "NODE_ENV=production NON_COMPILED=true bun run build", "build:linux": "TARGET=bun-linux-x64 bun run build", "openapi-ts": "bun run ./scripts/romm/openapi-ts.ts", "run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml", @@ -33,7 +33,7 @@ "flatpak:generate-sources": "bun run ./scripts/generate-flatpak-sources.ts", "flatpak:override": "flatpak override org.flatpak.Builder --filesystem=host --device=all", "flatpak:restore": "flatpak override --reset --user org.flatpak.Builder", - "flatpak:build": "flatpak run org.flatpak.Builder build/flatpak flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --force-clean", + "flatpak:build": "FLATPAK_BUILD=true NODE_ENV=production NON_COMPILED=true bun run build && flatpak run org.flatpak.Builder ../gameflow-flatpak/build/flatpak .config/flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --state-dir=../gameflow-flatpak/state --force-clean", "flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck", "build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts", "build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts", @@ -41,6 +41,7 @@ "package:Linux": "bun run build:prod:appimage", "package:Windows": "bun run build:prod", "download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium", + "download:nwjs": "bun scripts/download-nw.ts", "build:audiosprites": "bun ./scripts/generate-audio-sprites.ts", "tsc": "tsc --noEmit" }, diff --git a/scripts/build-appimage.ts b/scripts/build-appimage.ts index 852df2e..c2f07f5 100644 --- a/scripts/build-appimage.ts +++ b/scripts/build-appimage.ts @@ -4,11 +4,11 @@ import fs from 'node:fs/promises'; import { appBuilderPath, } from 'app-builder-bin'; import path from 'node:path'; import { ensureDir } from "fs-extra"; +import mustache from "mustache"; const APP_DIR = process.env.BUILD_DIR ?? `./build/${process.platform}`; const BINARY_NAME = pkg.bin; const ICON = "./src/mainview/public/256x256.png"; -const DESKTOP = "./flatpak/com.simeonradivoev.gameflow-deck.desktop"; const TMP_FOLDER = "."; 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', `7za`), path.join(APPDIR, `usr`, 'bin', `7za`)); -await fs.writeFile(path.join(APPDIR, `${APP_ID}.desktop`), `[Desktop Entry] -Version=${pkg.version} -X-AppImage-Name=${APP_NAME} -X-AppImage-Version=${pkg.version} -X-AppImage-Arch=${process.arch} -Name=${APP_NAME} -Comment=${pkg.description} -Exec=${APP_ID}.AppImage -Icon=.DirIcon -Type=Application -Categories=Game; -`); +if (!await fs.exists('./bin/nw/nw')) +{ + await import('./download-nw'); +} -await Bun.write(path.join(APPDIR, "AppRun"), `#!/bin/bash -APPDIR="$(dirname "$(readlink -f "$0")")" -APPIMAGE=true -exec "$APPDIR/usr/bin/${BINARY_NAME}" "$@" -`); +await ensureDir(path.join(APPDIR, `usr`, 'lib', 'nw')); +await fs.cp('./bin/nw', path.join(APPDIR, `usr`, 'lib', 'nw'), { recursive: true }); +await fs.symlink(path.join(APPDIR, `usr`, 'lib', 'nw', 'nw'), path.join(APPDIR, `usr`, `bin`, 'nw')); + +const templateVars = { + APP_NAME, + VERSION: pkg.version, + ARCH: process.arch, + DESCRIPTION: pkg.description, + APP_ID, + BINARY_NAME, + LICENSE: pkg.license +}; + +const desktopFileTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.desktop', 'utf8'); + +const raw = await $`git tag --sort=-version:refname`.text().then(d => d.trim()); +const tags = raw.split('\n').filter(t => t.match(/^\d+\.\d+\.\d+$/)); +console.log("tags", tags); + +console.log(">>> Updating Release History..."); +const releases = await Promise.all(tags.map(async tag => +{ + const date = await $`git log -1 --format=%as ${tag}`.text().then(d => d.trim()); + const version = tag.replace(/^v/, ''); + return ` `; +})); + +const appStreamTemplate = await fs.readFile('./.config/appimage/com.simeonradivoev.gameflow-deck.appdata.xml', 'utf8'); +await ensureDir(path.join(APPDIR, 'usr', 'share', 'metainfo')); +await fs.writeFile(path.join(APPDIR, 'usr', 'share', 'metainfo', `${APP_ID}.appdata.xml`), mustache.render(appStreamTemplate, { ...templateVars, RELEASES: releases })); + +const appRunTemplate = await fs.readFile(`./.config/appimage/AppRun`, 'utf8'); +await Bun.write(path.join(APPDIR, "AppRun"), mustache.render(appRunTemplate, templateVars)); await $`chmod +x ${APPDIR}/AppRun`; console.log(">>> Building AppImage..."); @@ -52,7 +73,7 @@ const config = { productName: pkg.displayName, productFilename: pkg.name, executableName: BINARY_NAME, - desktopEntry: DESKTOP, + desktopEntry: mustache.render(desktopFileTemplate, templateVars), icons: [ { file: ICON, @@ -67,7 +88,7 @@ const config = { // Remove the build dir, mainly to help with CIs await fs.rm(APP_DIR, { recursive: true }); await ensureDir(APP_DIR); -const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}.AppImage`); +const OUTPUT = path.resolve(APP_DIR, `${APP_NAME}-${process.platform}-${process.arch}.AppImage`); const STAGE = path.resolve(TMP_FOLDER, `${APP_ID}.stage`); await ensureDir(STAGE); @@ -86,8 +107,9 @@ const proc = Bun.spawn([ }); const code = await proc.exited; -await fs.rm(APPDIR, { recursive: true, force: true }); await fs.rm(STAGE, { recursive: true, force: true }); +await fs.rm(APPDIR, { recursive: true, force: true }); + if (code !== 0) process.exit(code); console.log(`\n Done!`); \ No newline at end of file diff --git a/scripts/dev.ts b/scripts/dev.ts index b8d3f6d..14e3f40 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -2,49 +2,44 @@ import EventEmitter from "events"; import browser from '../src/bun/browser'; import { tmpdir } from "os"; import path from "path"; -import { createInterface } from "readline"; -import { Readable } from "stream"; +import { watch } from "fs"; const events = new EventEmitter(); const abortController = new AbortController(); process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222"; process.env.NODE_ENV = "development"; -let retries = 0; - 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: { ...process.env, HEADLESS: "true", }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", + stdout: 'inherit', + stderr: 'inherit', + stdin: 'inherit', signal: abortController.signal, killSignal: 'SIGUSR1', + ipc (message, subprocess, handle) + { + if (message === 'focus') + { + events.emit('focus'); + } else if (message === 'exitapp') + { + events.emit('exitapp'); + } + }, 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; } @@ -53,9 +48,10 @@ function spawnBrowser () try { - return browser(events, process.env.FORCE_BROWSER === "true", { + return browser(events, { configPath: path.join(tmpdir(), 'gameflow'), - isSteamDeckGameMode: false + isSteamDeckGameMode: false, + forceBrowser: process.env.FORCE_BROWSER === "true" }); } 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) { spawnBrowser()?.then(async e => { - console.log("Sending exit Signal to server"); - await server.stdin.write('shutdown\n'); - await server.stdin.flush(); + if (!server) return; + server.kill("SIGUSR1"); + await server.exited; }); } \ No newline at end of file diff --git a/scripts/download-nw.ts b/scripts/download-nw.ts new file mode 100644 index 0000000..7d318b6 --- /dev/null +++ b/scripts/download-nw.ts @@ -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 = { + "win32": "win", + "darwin": "osx" +}; +const extMap: Record = { + "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.`); +} \ No newline at end of file diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index f54671c..57aa61c 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -18,7 +18,6 @@ import EventEmitter from "node:events"; import { appPath } from "../utils"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { ensureDir } from "fs-extra"; -import { getStoreFolder } from "./store/services/gamesService"; import { PluginManager } from "./plugins/plugin-manager"; import registerPlugins from "./plugins/register-plugins"; import controls from './controls/controls'; @@ -43,6 +42,8 @@ export let events: EventEmitter; let controlsHandle: { cleanup: () => void; }; let api: { cleanup: () => Promise; }; let bunServer: { cleanup: () => Promise; } | undefined; +let cleannedUp = false; +let cleaningUp = false; export async function load () { @@ -56,6 +57,7 @@ export async function load () windowSize: { width: 1280, height: 800 } }), }); + customEmulators = new Conf>({ projectName: projectPackage.name, projectSuffix: 'bun', @@ -96,6 +98,9 @@ export async function load () export async function cleanup () { + if (cleaningUp) throw new Error("Already Cleaning Up"); + cleaningUp = true; + if (cleannedUp) throw new Error("Already Cleaned Up. Skipping"); console.log("Cleaning Up"); await bunServer?.cleanup(); await api.cleanup(); @@ -108,6 +113,7 @@ export async function cleanup () config._closeWatcher(); customEmulators._closeWatcher(); console.log("Finished Cleaning Up"); + cleannedUp = true; } export async function reloadDatabase () diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts index ff84b4d..cdad6b1 100644 --- a/src/bun/api/cache.ts +++ b/src/bun/api/cache.ts @@ -3,6 +3,7 @@ import { cache } from "./app"; import cacheSchema from "@schema/cache"; import { GithubReleaseSchema } from "@/shared/constants"; import PQueue from "p-queue"; +import z from "zod"; export const CACHE_KEYS = { 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 async function getOrCached (key: string, getter: () => Promise, options?: { expireMs?: number; }): Promise +export async function getOrCached (key: string, getter: (lastValue: T | undefined) => Promise, options?: { expireMs?: number; force?: boolean; }): Promise { const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) }); const updated_at = new Date(); - if (cached && cached.expire_at > updated_at) + if (cached && cached.expire_at > updated_at && !options?.force) { return cached.data as T; } - const data = await getter(); + const data = await getter(cached?.data as T); if (data === undefined) return data; const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000); @@ -38,12 +39,15 @@ export async function getOrCached (key: string, getter: () => Promise, opt 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>(`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); - return GithubReleaseSchema.parseAsync(await response.json()); - }), { expireMs: 1000 * 60 * 60 }); + const release = await GithubReleaseSchema.parseAsync(await response.json()); + return release; + }), { expireMs: 1000 * 60 * 60, force: forceCheck }); } \ No newline at end of file diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 139d8af..54085a1 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,7 +1,7 @@ import z from "zod"; import { IJob, JobContext } from "../task-queue"; 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 { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; @@ -51,6 +51,7 @@ export class LaunchGameJob implements IJob { @@ -129,31 +130,41 @@ export class LaunchGameJob implements IJob - { - resolve(true); - }).catch(e => - { - console.error(e); - reject(e); - }); - game = bunGame; } else { + + let command = this.validCommand.command; + + if (process.env.FLATPAK_BUILD) command = `flatpak-spawn --host --directory=${config.get('downloadPath')} ${command}`; + // ES-DE commands require shell execution. Some emulators fail otherwise. - const spawnGame = spawn(this.validCommand.command, { + const spawnGame = spawn(command, { shell: this.validCommand.shell ?? true, cwd: this.validCommand.startDir, signal: context.abortSignal, @@ -178,7 +189,6 @@ export class LaunchGameJob implements IJob - { - resolve(true); - }).catch(e => - { - console.error(e); - reject(e); - }); - game = bunGame; } else diff --git a/src/bun/api/jobs/self-update-job.ts b/src/bun/api/jobs/self-update-job.ts new file mode 100644 index 0000000..38d57c0 --- /dev/null +++ b/src/bun/api/jobs/self-update-job.ts @@ -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 +{ + 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, 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, 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" }); + } + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index 7a0eadc..080af5f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -14,6 +14,17 @@ export default class CEMUIntegration implements PluginType return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] }; }); + ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) => + { + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + validChangedSaveFiles[this.emulator] = { + cwd: saveFolderSlots[this.emulator].cwd, + shared: true, + subPath: '*.{tga,xml,dat}', + isGlob: true + }; + }); + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { const args: string[] = []; @@ -29,7 +40,7 @@ export default class CEMUIntegration implements PluginType args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); } - return { args, savesPath: { cemu: { cwd: savesPath } } }; + return { args, savesPath: { [this.emulator]: { cwd: savesPath } } }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index de853f7..79a615b 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -68,21 +68,20 @@ export default class DOLPHINIntegration implements PluginType args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`); finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder; + return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } }; } - return { args, 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) - { - validChangedSaveFiles.dolphin = { - cwd: saveFolderSlots.dolphin.cwd, - subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir), - shared: false - }; - } + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + validChangedSaveFiles[this.emulator] = { + cwd: saveFolderSlots[this.emulator].cwd, + subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir), + shared: false + }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 605c1b6..db39248 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -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) => { const args: string[] = []; @@ -103,7 +115,7 @@ export default class PCSX2Integration implements PluginType 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 }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 5f5dbbb..a0c4a2d 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -10,6 +10,7 @@ import Mustache from "mustache"; import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; import ini from 'ini'; +import fs from 'node:fs/promises'; 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) => { - await Bun.write(path.join(ctx.path, "portable.txt"), ""); - if (process.platform === 'win32') + const stat = await fs.stat(ctx.path); + 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) => { const args: string[] = []; @@ -114,7 +130,14 @@ export default class PPSSPPIntegration implements PluginType 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 }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts index 774b918..9d8670f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -68,7 +68,7 @@ export default class XENIAIntegration implements PluginType if (ctx.autoValidCommand.metadata.romPath) { finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath); - return { args, savesPath: { xenia: { cwd: finalSavesPath } } }; + return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } }; } 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.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) - { - const files = await fs.readdir(saveFolderPath, { recursive: true }); - validChangedSaveFiles.gameflow = { cwd: saveFolderPath, subPath: files, shared: false }; - } + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + const files = await fs.readdir(saveFolderSlots[this.emulator].cwd, { recursive: true }); + validChangedSaveFiles.xenia = { cwd: saveFolderSlots[this.emulator].cwd, subPath: files, shared: false }; + }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts index 45602c2..f5fdf9f 100644 --- a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts @@ -1,6 +1,6 @@ import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; 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 unzip from 'unzip-stream'; import { chmodSync, ensureDir } from "fs-extra"; @@ -10,6 +10,10 @@ import fs from 'node:fs/promises'; import { randomUUIDv7, sleep } from "bun"; import z from "zod"; import { createInterface } from "node:readline"; +import { getLocalGameMatch } from "@/bun/api/games/services/utils"; +import { getErrorMessage } from "@/bun/utils"; + +const DefaultLocalName = "Default_Local"; const SettingsSchema = z.object({ runWebGui: z.boolean() @@ -18,7 +22,7 @@ const SettingsSchema = z.object({ .meta({ title: "Run Web GUI" }), globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"), webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"), - remoteName: z.string().default(""), + remoteName: z.string().default(DefaultLocalName), verboseLog: z.boolean() .default(false) .describe("Show detailed log of operation for debugging") @@ -116,8 +120,21 @@ export default class RcloneIntegration implements PluginType async refresh () { - const data = await this.request('/config/listremotes', {}); - z.globalRegistry.add(SettingsSchema.shape.remoteName, { examples: data.remotes, description: "The name of the remote to sync with" }); + try + { + const data = await this.request('/config/listremotes', {}); + z.globalRegistry.add(SettingsSchema.shape.remoteName, { + examples: [''].concat(...data.remotes), + description: "The name of the remote to sync with" + }); + } catch (error) + { + events.emit('notification', { message: getErrorMessage(error), type: 'error' }); + z.globalRegistry.add(SettingsSchema.shape.remoteName, { + examples: [''], + description: "The name of the remote to sync with" + }); + } } async startServer (ctx: PluginLoadingContextType) @@ -146,23 +163,29 @@ export default class RcloneIntegration implements PluginType const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) }); rl.on('line', e => { - const data = JSON.parse(e); - - if (data.level === 'error') + try { - console.error(data.msg); - } else if (data.level === 'critical') - { - console.error(data.msg); - } + const data = JSON.parse(e); - 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); - 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 { const handleResolve = (line: string) => { - const data = JSON.parse(line); - if (!loginTokenUrlRegex.test(data.msg)) return; - rl.off('line', handleResolve); - resolve(data); + try + { + const data = JSON.parse(line); + if (!loginTokenUrlRegex.test(data.msg)) return; + rl.off('line', handleResolve); + resolve(data); + } catch (error) + { + + } }; rl.on('line', handleResolve); setTimeout(() => { reject("Timeout"); }, 5000); @@ -206,100 +235,235 @@ export default class RcloneIntegration implements PluginType 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) { 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)) { - + let supportsMetadata = true; 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', { - fs: `${ctx.config.get('remoteName')}:`, - remote: `gameflow/saves/${source}/${id}/${slot}` + fs: `${remoteName}:`, + remote: `gameflow/saves/${destination.join('/')}/${slot}` }).catch(e => undefined); if (!exists || !exists.item) return; - + const remote = await this.request('/operations/fsinfo', { + fs: `${remoteName}:` + }); + supportsMetadata = !remote.ReadMetadata; + if (supportsMetadata) + { + console.warn("Remote", remoteName, "does not support metadata"); + } } else { - src = path.join(config.get('downloadPath'), 'saves', source, id, slot); - if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', source, id, slot))) return; + src = path.join(config.get('downloadPath'), 'saves', ...destination, slot); + if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', ...destination, slot))) return; } - setProgress(0.5, "RClone: Syncing Saves"); - - const data = await this.request('/sync/copy', { + const job = await this.request('/sync/copy', { srcFs: src, dstFs: cwd, createEmptySrcDirs: true, + _async: true, _config: { - UseJSONLog: true, - LogLevel: "DEBUG", - HumanReadable: true, - 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('\\', '/') + CheckFirst: true, + Metadata: true, + NoCheckDest: supportsMetadata } }).catch(e => { events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); return undefined; + });; + + await new Promise(async (resolve, reject) => + { + setProgress(0, "RClone: Syncing Saves"); + + const checkInterval = setInterval(async () => + { + const stat = await this.request('/job/status', { jobid: job.jobid }); + if (stat.finished) + { + clearInterval(checkInterval); + console.log(stat.output); + resolve(true); + + } else if (stat.error) + { + reject(stat.error); + } else + { + setProgress(stat.progress, "RClone: Syncing Saves"); + } + }, 500); + }); + } + + }); + + ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) => + { + if (!this.rclonePath || !ctx.config.get('exportSaves')) return; + const local = await db.query.games.findFirst({ where: getLocalGameMatch(id, source) }); + console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(",")); + + const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined; + if (!destination) return; + + const remoteName = ctx.config.get('remoteName'); + + await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) => + { + let suportsMetadata = false; + let dest: string; + if (remoteName && remoteName !== DefaultLocalName) + { + dest = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`; + const remote = await this.request('/operations/fsinfo', { + fs: `${remoteName}:` + }); + suportsMetadata = !remote.ReadMetadata; + if (suportsMetadata) + { + console.warn("Remote", remoteName, "does not support metadata"); + } + } else + { + dest = path.join(config.get('downloadPath'), 'saves', ...destination, slot); + } + + const filter = { + IncludeRule: Array.isArray(change.subPath) ? + change.subPath.map(s => + { + if (change.isGlob) return s; + else s.replaceAll('\\', '/'); + }) : + [change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/')] + }; + + let jobid: number | undefined = undefined; + + if (change.fixedSize) + { + await this.request('/sync/copy', { + srcFs: change.cwd, + dstFs: dest, + createEmptySrcDirs: true, + _async: true, + _config: { + NoCheckDest: true + }, + _filter: filter + }) + .then(job => jobid = job.jobid) + .catch(e => + { + events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); + return undefined; + }); + } else + { + await this.request('/sync/sync', { + srcFs: change.cwd, + dstFs: dest, + createEmptySrcDirs: true, + _async: true, + _config: { + CheckSum: true, + CheckFirst: true, + Metadata: true, + MetadataSet: { + igdb_id: local?.igdb_id ? String(local?.igdb_id) : undefined, + ra_id: local?.ra_id ? String(local?.ra_id) : undefined + } + }, + _filter: filter + }) + .then(job => jobid = job.jobid) + .catch(e => + { + events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); + return undefined; + }); + } + + if (!jobid) return; + await new Promise(async (resolve, reject) => + { + const checkInterval = setInterval(async () => + { + const stat = await this.request('/job/status', { jobid }); + if (stat.finished) + { + clearInterval(checkInterval); + console.log(stat.output); + resolve(true); + + } else if (stat.error) + { + reject(stat.error); + } else + { + + } + }, 500); }); - 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' }); } diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index ccc0c29..e049285 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -145,6 +145,7 @@ export default class RommIntegration implements PluginType { this.isSteamDeck = isSteamDeckGameMode(); ctx.setProgress(0, "Logging Into Romm"); + await this.updateClient(); await checkLoginAndRefreshRomm(); await this.updateClient(); @@ -270,7 +271,8 @@ export default class RommIntegration implements PluginType metadata: rom.metadatum, files, auth: await this.getAuthToken(), - extract_path + extract_path, + id: "romm" }; return [info]; diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts index e22b2c2..86944f3 100644 --- a/src/bun/api/plugins/plugin-manager.ts +++ b/src/bun/api/plugins/plugin-manager.ts @@ -90,7 +90,9 @@ export class PluginManager { if (plugin.enabled || plugin.description.canDisable === false) { + console.log("Loading Plugin", plugin.description.name); await plugin.plugin.load(ctx); + console.log("Loaded Plugin", plugin.description.name); plugin.loaded = true; } } catch (error) @@ -119,11 +121,13 @@ export class PluginManager { if (p.loaded) { + console.log("Starting", p.description.name, "plugin cleanup"); await p.plugin.cleanup!(); + console.log(p.description.name, "cleanup complete"); } } 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); } })); diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 927c00d..ccc3fb9 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -2,8 +2,8 @@ import Elysia from "elysia"; import open from 'open'; import z from "zod"; import os from 'node:os'; -import { cachePath, config, events, taskQueue } from "./app"; -import { isSteamDeck, openExternal } from "../utils"; +import { cache, cachePath, config, events, taskQueue } from "./app"; +import { getAppVersion, isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; import path, { dirname } from "node:path"; @@ -14,23 +14,15 @@ import si from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; import ReloadPluginsJob from "./jobs/reload-plugins-job"; import { semver } from "bun"; -import packageDef from '~/package.json'; -import { getOrCached, githubRequestQueue } from "./cache"; +import { getOrCached, getOrCachedGithubRelease, 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 fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest'); - if (latest.ok) - { - const data = await latest.json(); - const hasUpdate = semver.order(data.tag_name, packageDef.version); - return hasUpdate; - } - - return 0; - }), { expireMs: 1000 * 60 * 60 }); + const latest = await getOrCachedGithubRelease('simeonradivoev/gameflow-deck', force); + if (!latest || !latest.tag_name) return { hasUpdate: 0, version: getAppVersion() }; + const hasUpdate = semver.order(latest.tag_name, getAppVersion()); + return { hasUpdate, version: latest.tag_name }; } export const system = new Elysia({ prefix: '/api/system' }) @@ -71,7 +63,8 @@ export const system = new Elysia({ prefix: '/api/system' }) machine: os.machine(), source, cacheSize: (await fs.stat(cachePath)).size, - storeSize: (await getFolderSize(getStoreFolder())).size + storeSize: (await getFolderSize(getStoreFolder())).size, + version: getAppVersion() }; }) .get('/notifications', ({ set }) => @@ -120,17 +113,25 @@ export const system = new Elysia({ prefix: '/api/system' }) dispose.push(taskQueue.on('progress', e => { - if (e.id !== ReloadPluginsJob.id) return; - ws.send({ type: "loading", progress: e.progress, state: e.state }); + if (e.id === ReloadPluginsJob.id) + { + 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 => { - if (e.id !== ReloadPluginsJob.id) return; - ws.send({ type: "loading", progress: 0 }); + if (e.id === ReloadPluginsJob.id) + 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 => { - if (e.id !== ReloadPluginsJob.id) return; + if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return; ws.send({ type: "loaded" }); })); @@ -268,4 +269,12 @@ export const system = new Elysia({ prefix: '/api/system' }) .get('/update', async () => { return checkUpdate(); + }) + .post('/update', async () => + { + return taskQueue.enqueue(SelfUpdateJob.id, new SelfUpdateJob()); + }) + .post('/update/check', async () => + { + return checkUpdate(true); }); \ No newline at end of file diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 34459ed..bb890df 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -106,7 +106,18 @@ export class TaskQueue { this.queue = []; 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); + }); + })); } } diff --git a/src/bun/browser.ts b/src/bun/browser.ts index c86dfef..8d71427 100644 --- a/src/bun/browser.ts +++ b/src/bun/browser.ts @@ -6,24 +6,42 @@ import { dlopen, FFIType, Pointer } from "bun:ffi"; import { SERVER_URL } from '@/shared/constants'; import { host } from './utils/host'; 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); - } else - { - try - { - await runWebview(events, params); - } catch (error) - { - await runBrowser(events, params); - } + await runNW(events, params); + return; } - 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) @@ -51,17 +69,50 @@ function focusWindow (id: Pointer) async function runNW (events: EventEmitter, params: BrowserParams) { - const path = process.platform === 'win32' ? './bin/nw/nw.exe' : './bin/nw/nw'; - if (!await fs.exists(path)) + let nwPath = process.platform === 'win32' ? './bin/nw/nw.exe' : './bin/nw/nw'; + if (process.env.FLATPAK_BUILD) { - console.error("Could not find NW.js"); - return; + nwPath = '/app/bin/nw/nw'; + } 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 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()); - const args = [path, `--url=${SERVER_URL(host)}`]; - if (process.env.NODE_ENV !== 'development') args.push("--disable-devtools"); - const nwProcess = Bun.spawn(args, { signal: signalHandler.signal }); + const configPath = path.join(params.configPath, 'nw-user-data'); + await ensureDir(configPath); + 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; } @@ -131,8 +182,7 @@ async function runBrowser (events: EventEmitter, params: BrowserParams) const browserParams = await BuildParams(params); if (!browserParams) { - console.error("Could not find valid browser"); - return Promise.resolve(); + throw new Error("Could not find valid browser"); } else if (!Bun.env.HEADLESS) { diff --git a/src/bun/index.ts b/src/bun/index.ts index 146c195..a2ba31d 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -5,14 +5,28 @@ import { dirname } from 'pathe'; import { createInterface } from 'readline'; import { isSteamDeckGameMode } from './utils'; -async function cleanup () +async function cleanup (code: number) { - await app.cleanup(); - process.exit(0); + app.cleanup() + .then(() => + { + process.exit(code); + }) + .catch(e => console.error); } 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) { const rl = createInterface({ input: process.stdin }); @@ -22,7 +36,7 @@ if (process.env.HEADLESS) if (line.trim() === "shutdown") { console.log("Graceful Shutdown"); - await cleanup(); + await cleanup(0); } }); @@ -30,23 +44,23 @@ if (process.env.HEADLESS) app.events.on('exitapp', () => { process.stdout.write('exitapp\n'); - cleanup(); + process.send?.("exitapp"); + cleanup(0); }); app.events.on('focus', () => { process.stdout.write("focus\n"); + process.send?.("focus"); }); } else { - await init(app.events, process.env.FORCE_BROWSER === "true", { + await init(app.events, { configPath: dirname(app.config.path), windowPosition: app.config.get('windowPosition'), 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); +} \ No newline at end of file diff --git a/src/bun/types/types.d.ts b/src/bun/types/types.d.ts index 1bc7a22..ee43a63 100644 --- a/src/bun/types/types.d.ts +++ b/src/bun/types/types.d.ts @@ -29,4 +29,14 @@ declare interface AppEventMap exitapp: []; notification: [FrontendNotification]; focus: []; +} + +declare module '*.bat' { + const content: string; + export default content; +} + +declare module '*.sh' { + const content: string; + export default content; } \ No newline at end of file diff --git a/src/bun/utils.ts b/src/bun/utils.ts index c21a78b..23d17c0 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { SettingsType } from '@/shared/constants'; import { config } from './api/app'; import fs from 'node:fs/promises'; +import packageDef from '~/package.json'; export function checkRunning (pid: number) { @@ -172,4 +173,9 @@ export async function moveAllFiles (srcDir: string, destDir: string) }); } } +} + +export function getAppVersion () +{ + return process.env.VERSION_OVERRIDE ?? packageDef.version; } \ No newline at end of file diff --git a/src/bun/utils/browser-params.ts b/src/bun/utils/browser-params.ts index 0023219..5059137 100644 --- a/src/bun/utils/browser-params.ts +++ b/src/bun/utils/browser-params.ts @@ -11,6 +11,8 @@ export interface BrowserParams windowPosition?: { x: number, y: number; }; windowSize?: { width?: number, height?: number; }; isSteamDeckGameMode: boolean; + forceBrowser?: boolean; + forceNWJS?: boolean; } export async function BuildParams (data: BrowserParams) @@ -54,6 +56,13 @@ export async function BuildParams (data: BrowserParams) args.push('--allow-insecure-localhost'); 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) { args.push('--kiosk'); diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index 8d443c2..f4f7d95 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -27,15 +27,22 @@ export class Downloader onProgress?: (stats: ProgressStats) => void; signal?: AbortSignal; activeFile?: DownloadFileEntry; - downloadPath: string; + downloadPath: string | undefined; id: string; tmpPath: 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( id: string, files: DownloadFileEntry[], - downloadPath: string, init?: { + downloadPath: string | undefined, + init?: { headers?: Record, onProgress?: (stats: ProgressStats) => void; signal?: AbortSignal; @@ -210,11 +217,19 @@ export class Downloader }); } - await moveAllFiles(this.tmpPath, this.downloadPath); - if (await fs.exists(this.tmpPath)) - await fs.rm(this.tmpPath, { recursive: true }); - await fs.rm(this.tmpPathMeta); + if (this.downloadPath === undefined) + { + 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)); } } \ No newline at end of file diff --git a/src/bun/utils/update-gameflow-linux.sh b/src/bun/utils/update-gameflow-linux.sh new file mode 100644 index 0000000..1bd1c81 --- /dev/null +++ b/src/bun/utils/update-gameflow-linux.sh @@ -0,0 +1,6 @@ +#!/bin/bash +sleep 2 +mv "{{{tempFile}}}" "{{{appImagePath}}}" +chmod +x "{{{appImagePath}}}" +"{{{appImagePath}}}" & +rm -- "$0" \ No newline at end of file diff --git a/src/bun/utils/update-gameflow-windows.bat b/src/bun/utils/update-gameflow-windows.bat new file mode 100644 index 0000000..11dc3c8 --- /dev/null +++ b/src/bun/utils/update-gameflow-windows.bat @@ -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" \ No newline at end of file diff --git a/src/mainview/components/AnimatedBackground.tsx b/src/mainview/components/AnimatedBackground.tsx index 31ea52a..36caedf 100644 --- a/src/mainview/components/AnimatedBackground.tsx +++ b/src/mainview/components/AnimatedBackground.tsx @@ -25,17 +25,13 @@ export function AnimatedBackground (data: { ) : useState(); - const [lastBackgroundUrl, setLastBackgroundUrl] = useState(undefined); const backgroundElementRef = useRef(null); useEffect(() => { - const lastBg = backgroundUrl; - if (data.backgroundUrl != backgroundUrl) { setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined); - setLastBackgroundUrl(lastBg); } }, [data.backgroundUrl]); @@ -44,13 +40,6 @@ export function AnimatedBackground (data: { { finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined; } catch { } - - let finalLastBackgroundUrl: URL | undefined; - try - { - finalLastBackgroundUrl = lastBackgroundUrl ? new URL(lastBackgroundUrl) : undefined; - } catch { } - const blur = useLocalSetting('backgroundBlur'); if (blur) { @@ -59,13 +48,7 @@ export function AnimatedBackground (data: { finalBackgroundUrl?.searchParams.set('blur', String(24)); } - if (!finalLastBackgroundUrl?.searchParams.has('blur')) - { - finalLastBackgroundUrl?.searchParams.set('blur', String(24)); - } - finalBackgroundUrl?.searchParams.set('height', String(320)); - finalLastBackgroundUrl?.searchParams.set('height', String(320)); } useEffect(() => @@ -90,8 +73,6 @@ export function AnimatedBackground (data: { function handleSetBackground (url: string) { - - setLastBackgroundUrl(backgroundUrl); setBackgroundUrl(url); } @@ -120,7 +101,7 @@ export function AnimatedBackground (data: { > {!data.scrolling &&
- {blur && finalLastBackgroundUrl && } + {finalBackgroundUrl ? + { + sub.close(); + }; }, []); return diff --git a/src/mainview/components/AutoFocus.tsx b/src/mainview/components/AutoFocus.tsx index 74c6da4..ae0bb33 100644 --- a/src/mainview/components/AutoFocus.tsx +++ b/src/mainview/components/AutoFocus.tsx @@ -1,5 +1,5 @@ import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { useLayoutEffect } from "react"; +import { useEffect, useLayoutEffect } from "react"; export function AutoFocus (data: { parentKey?: string; @@ -8,11 +8,15 @@ export function AutoFocus (data: { delay?: number; }) { - useLayoutEffect(() => + useEffect(() => { 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) { @@ -21,8 +25,8 @@ export function AutoFocus (data: { { data.focus({ instant: true }); } - } + return () => { if (delayTimeout) diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 5de871d..d311167 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -32,7 +32,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara 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 ( 0, + focusable: data.games.length > 0 || (!!data.finalElement && (Array.isArray(data.finalElement) ? data.finalElement.length > 0 : !!data.finalElement)), preferredChildFocusKey: data.focus }); diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index b9bb9e3..cf36fe0 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -30,7 +30,7 @@ import { TwitchIcon } from "../scripts/brandIcons"; import { rommLoggedInQuery } from "../scripts/queries/romm"; import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; 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 { hasUpdateQuery } from "../scripts/queries/system"; @@ -87,16 +87,24 @@ export interface HeaderAccount function UpdateStatus () { + const handleSelect = () => + { + navigate({ to: '/settings/about' }); + }; const hasUnread = false; - return
- + const navigate = useNavigate(); + const { ref } = useFocusable({ + focusKey: 'update-bt', onEnterPress: handleSelect + }); + return
+
; } function NotificationStatus () { const hasUnread = false; - return
+ return
; } @@ -219,14 +227,17 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) router.navigate({ to: '/settings/accounts' }); oneShot('click'); }; - const { ref } = useFocusable({ - focusKey: 'accounts', onEnterPress: handleSelect - }); const accounts: HeaderAccount[] = []; if (data.accounts) accounts.push(...data.accounts); const router = useRouter(); + const { ref } = useFocusable({ + focusKey: 'accounts', + onEnterPress: handleSelect, + focusable: accounts.length > 0 + }); + if (rommUser.data?.hasLogin || rommUser.isError) { accounts.push({ @@ -259,7 +270,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; }) { const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' }); - const { data: hasUpdate } = useQuery(hasUpdateQuery); + const { data: update } = useQuery(hasUpdateQuery); return
@@ -267,7 +278,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement - {!!hasUpdate && hasUpdate >= 1 && } + {!!update && update.hasUpdate >= 1 && }
{!!data.buttons &&
} diff --git a/src/mainview/components/ImageWithFallbacks.tsx b/src/mainview/components/ImageWithFallbacks.tsx index 7954da3..6779b26 100644 --- a/src/mainview/components/ImageWithFallbacks.tsx +++ b/src/mainview/components/ImageWithFallbacks.tsx @@ -13,7 +13,20 @@ export default function ImageWithFallbacks (data: { { img.dataset.index = String(nextIndex); img.src = data.src[nextIndex].href; + } }; - return ; + return + { + e.currentTarget.dataset.loaded = "true"; + }} + > + + ; } \ No newline at end of file diff --git a/src/mainview/components/backgrounds/dots.tsx b/src/mainview/components/backgrounds/dots.tsx index ce6e0af..5971082 100644 --- a/src/mainview/components/backgrounds/dots.tsx +++ b/src/mainview/components/backgrounds/dots.tsx @@ -1,8 +1,9 @@ +import { Ref, RefObject } from 'react'; import './dots.css'; -export default function DotsLoading () +export default function DotsLoading (data: { ref?: Ref; }) { - return
+ return
diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index c70369a..96a120f 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -109,7 +109,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so if (!cmd) return; 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()) }); } else { @@ -120,14 +120,15 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so let mainButton: any | undefined = undefined; if (status === 'installed') { - mainButton =
handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} - key="primary" - type='primary' - id="mainAction" - > - + mainButton =
+ handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} + key="primary" + type='primary' + id="mainAction" + > + - + {validCommands.length > 1 && showAllCommands(true, 'allActionsBtn')}> diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index cb3fe1b..1d1a4aa 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "./"; +const BASE_PATH = "/"; /** diff --git a/src/mainview/index.css b/src/mainview/index.css index eb09eb3..532b330 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -7,7 +7,7 @@ @theme { --breakpoint-sm: 0px; - --breakpoint-md: 1280px; + --breakpoint-md: 1024px; --page-scroll-bg: transparent; --animation-size: 1; diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 103d90f..d0a346e 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -166,7 +166,6 @@ function RouteComponent () return ( - setUpdate(v => v + 1) }} > @@ -214,6 +213,7 @@ function RouteComponent ()
+ ); } \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index e2e6844..527fea9 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -13,10 +13,13 @@ import LayoutGrid, PlusCircle, Plus, + LucideIcon, } from "lucide-react"; import { createFileRoute, + PathParamOptions, + ToPathOption, useRouter, } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -52,6 +55,7 @@ import { FloatingShortcuts } from "../components/Shortcuts"; import SelectMenu from "../components/SelectMenu"; import HeaderSearchField from "../components/HeaderSearchField"; import CardElement from "../components/CardElement"; +import { Router } from ".."; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -114,24 +118,30 @@ function Preview (data: { index: number; children?: any; })
; } -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 handleNavigate = () => - { - router.navigate({ to: '/store/tab/games' }); - }; - return ]} onAction={handleNavigate} title="Gameflow Store" subtitle="Get Free Games" preview={} focusKey='store-games-btn' index={0} id="store-games-btn" />; -} -function ShowAllGamesCard () -{ - const router = useRouter(); const handleNavigate = () => { - router.navigate({ to: '/games' }); + router.navigate({ to: data.route as any }); }; - return } focusKey='all-games-btn' index={0} id="all-games-btn" />; + useShortcuts(data.id, () => [{ label: data.actionLabel, button: GamePadButtonCode.A, action: handleNavigate }]); + return ] : undefined} onAction={handleNavigate} title={data.title} subtitle={data.subTitle} preview={ + {typeof data.icon === 'string' ? + : + + } + } focusKey={data.id} index={0} id={data.id} />; } function HomeList (data: { @@ -197,8 +207,13 @@ function HomeList (data: { id="games-list" setBackground={bg.setBackground} filters={{ limit: 12, orderBy: 'activity' }} - finalElement={[, ]} - emptyElement={[]} + finalElement={[ + , + + ]} + emptyElement={[ + + ]} /> ; diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 4254395..e66de07 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -5,7 +5,8 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/ import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts'; 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')({ component: RouteComponent, @@ -39,7 +40,7 @@ function RouteComponent () useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { data, state } = useJobStatus('launch-game', { + const { state, data } = useJobStatus('launch-game', { onProgress (process, data) { if (progressRef.current) @@ -55,6 +56,7 @@ function RouteComponent () }, }, [progressRef.current, HandleGoBack]); + useBlocker({ shouldBlockFn: () => !!data }); return diff --git a/src/mainview/routes/settings/about.tsx b/src/mainview/routes/settings/about.tsx index d30b291..3351ff5 100644 --- a/src/mainview/routes/settings/about.tsx +++ b/src/mainview/routes/settings/about.tsx @@ -1,8 +1,11 @@ -import { systemInfoQuery } from '@queries/system'; -import { useQuery } from '@tanstack/react-query'; +import { Button } from '@/mainview/components/options/Button'; +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 { ArrowUpCircle, CircleFadingArrowUp, RefreshCcw } from 'lucide-react'; import prettyBytes from 'pretty-bytes'; export const Route = createFileRoute('/settings/about')({ @@ -12,58 +15,87 @@ export const Route = createFileRoute('/settings/about')({ function RouteComponent () { const { data: systemInfo } = useQuery(systemInfoQuery); - return - - - - - - {/* row 2 */} - - - - - - - - - - - - - {/* row 3 */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + const { ref, focusKey } = useFocusable({ focusKey: 'about-section' }); + const { data: hasUpdate, refetch: refetchHasUpdate } = useQuery(hasUpdateQuery); + const update = useMutation(updateMutation); + const forceCheckUpdate = useMutation({ + ...checkUpdateMutation, + onSuccess (data, variables, onMutateResult, context) + { + refetchHasUpdate(); + }, + }); + + return
Agent{navigator.userAgent}
Platform{navigator.platform}
Resolution{screen.width}x{screen.height}
Window{window.innerWidth}x{window.innerHeight}
User{systemInfo?.data?.user}
Architecture{systemInfo?.data?.arch}
System{systemInfo?.data?.platform}
Hostname{systemInfo?.data?.hostname}
Machine{systemInfo?.data?.machine}
SizesCache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}
Source{systemInfo?.data?.source}
Steam Deck{systemInfo?.data?.steamDeck ?? 'false'}
+ + + + + + + + + + + + + + + + {/* row 2 */} + + + + + + + + + + + + + {/* row 3 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Version{systemInfo?.data?.version}
Update + { + hasUpdate && hasUpdate.hasUpdate > 0 ? + : + + } + {} +
Agent{navigator.userAgent}
Platform{navigator.platform}
Resolution{screen.width}x{screen.height}
Window{window.innerWidth}x{window.innerHeight}
User{systemInfo?.data?.user}
Architecture{systemInfo?.data?.arch}
System{systemInfo?.data?.platform}
Hostname{systemInfo?.data?.hostname}
Machine{systemInfo?.data?.machine}
SizesCache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}
Source{systemInfo?.data?.source}
Steam Deck{systemInfo?.data?.steamDeck ?? 'false'}
; } diff --git a/src/mainview/routes/settings/plugin.$source.tsx b/src/mainview/routes/settings/plugin.$source.tsx index 78e0623..eedeada 100644 --- a/src/mainview/routes/settings/plugin.$source.tsx +++ b/src/mainview/routes/settings/plugin.$source.tsx @@ -1,4 +1,5 @@ import { AutoFocus } from '@/mainview/components/AutoFocus'; +import DotsLoading from '@/mainview/components/backgrounds/dots'; import { Button } from '@/mainview/components/options/Button'; import { OptionDropdown } from '@/mainview/components/options/OptionDropdown'; 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 { getPluginActionsQuery, getPluginSettingQuery, getPluginSettingsDefinitionQuery, pluginActionMutation, setPluginSettingMutation } from '@/mainview/scripts/queries/settings'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { scrollIntoViewHandler } from '@/mainview/scripts/utils'; 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 { JSONSchema7 } from 'json-schema'; import { ArrowLeft, CirclePlay, Play, Settings2, SettingsIcon } from 'lucide-react'; import toast from 'react-hot-toast'; export const Route = createFileRoute('/settings/plugin/$source')({ 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 <> + + + ; +} + function PluginAction (data: { id: string, title: string | undefined, description: string | undefined; action: string; reload: () => void; }) { const { source } = Route.useParams(); @@ -91,15 +110,19 @@ function PluginOption (data: { name: string, title?: string, prop: JSONSchema7; function Settings () { + const { definitions, actions } = Route.useLoaderData(); const { source } = Route.useParams(); - const { data: definitions, refetch: refetchDefinitions } = useQuery(getPluginSettingsDefinitionQuery(source)); - const { data: actions, refetch: referchActions } = useQuery(getPluginActionsQuery(source)); + const queryClient = useQueryClient(); + const handleReload = () => { - referchActions(); - refetchDefinitions(); + queryClient.refetchQueries(getPluginSettingsDefinitionQuery(source)); + 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
{!!definitions?.properties && Object.entries(Object.groupBy(Object.entries(definitions?.properties) @@ -142,16 +165,19 @@ function RouteComponent () return
- +
+ {data?.displayName}
    {data?.keywords?.map((k, i) =>
  • {k}
  • )}
{data?.description}
+ +
; diff --git a/src/mainview/routes/settings/plugins.tsx b/src/mainview/routes/settings/plugins.tsx index 2e12c78..37240e9 100644 --- a/src/mainview/routes/settings/plugins.tsx +++ b/src/mainview/routes/settings/plugins.tsx @@ -1,3 +1,4 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; import { pluginCategoryIcons, pluginCategoryPriorities } from '@/mainview/components/Constants'; import { Button } from '@/mainview/components/options/Button'; import { OptionInput } from '@/mainview/components/options/OptionInput'; @@ -62,7 +63,7 @@ function Plugin (data: { function RouteComponent () { const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery); - const { ref, focusKey } = useFocusable({ focusKey: 'plugins' }); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugins' }); const pluginMutation = useMutation({ ...enablePluginMutation, onSuccess (data, variables, onMutateResult, context) { @@ -84,6 +85,7 @@ function RouteComponent ()
; })} +
; } diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index febe997..edf0864 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -30,7 +30,7 @@ function RouteComponent () const { focus } = Route.useSearch(); const [search] = useSessionStorage(`${Route.to}-search`, undefined); 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('store-games-filters', {}); const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter)); const { data: gameFilters } = useQuery(gameFiltersQuery({ source: 'store' })); @@ -80,7 +80,8 @@ function RouteComponent () if (isFetchingNextPage || isFetching) return; fetchNextPage(); - }} />} games={data?.pages.flatMap((page) => page.data.map((g) => + }} />} + games={data?.pages.flatMap((page) => page.data.map((g) => { const badges: JSX.Element[] = []; if (g.id.source === 'local') @@ -119,7 +120,8 @@ function RouteComponent () onFocus: (k, n, d) => handleFocus(k, n, d) } satisfies GameMetaExtra as GameMetaExtra; }) - ) ?? []} id={'store-games'} /> + ) ?? []} + id={'store-games'} />
diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx index 2992f88..178eea0 100644 --- a/src/mainview/routes/store/tab/index.tsx +++ b/src/mainview/routes/store/tab/index.tsx @@ -15,6 +15,7 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import { useQuery } from '@tanstack/react-query'; import { autoEmulatorsQuery } from '@queries/settings'; import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store'; +import ImageWithFallbacks from '@/mainview/components/ImageWithFallbacks'; export const Route = createFileRoute('/store/tab/')({ component: RouteComponent @@ -64,16 +65,7 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; }) {game ?
- - { - e.currentTarget.dataset.loaded = "true"; - e.currentTarget.classList.toggle('scale-110', false); - }} - > - {previewUrls?.map((u, i) => )} - +
@@ -89,7 +81,7 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
- +
:
} diff --git a/src/mainview/scripts/queries/system.ts b/src/mainview/scripts/queries/system.ts index b46e4ea..ffc503f 100644 --- a/src/mainview/scripts/queries/system.ts +++ b/src/mainview/scripts/queries/system.ts @@ -56,4 +56,24 @@ export const hasUpdateQuery = queryOptions({ return data; }, 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; + }, }); \ No newline at end of file diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 9164617..37b2da1 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -272,6 +272,7 @@ export function useJobStatus, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onError?: (error: string) => void; + onClosed?: () => void; }, deps?: DependencyList ) @@ -289,6 +290,7 @@ export function useJobStatus init?.onClosed?.()); sub.subscribe(({ data }) => { switch (data.type) @@ -326,7 +328,7 @@ export function useJobStatus; declare module "@emulators" { const data: Record; diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index 2a03104..b715875 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -341,6 +341,7 @@ declare interface SaveFileChange isGlob?: true; cwd: string; shared: boolean; + fixedSize?: boolean; } declare type SaveSlots = Record; \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 7029442..b396eae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -102,7 +102,8 @@ export default defineConfig(({ command }) => }, define: { __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 } }; }); \ No newline at end of file