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.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/.gitattributes b/.gitattributes index 0a0c0bd..01c3dfe 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,6 @@ *.gif filter=lfs diff=lfs merge=lfs -text *.webp filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text +*.wav filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.ogg filter=lfs diff=lfs merge=lfs -text \ No newline at end of file diff --git a/.github/screenshots/3d screenshot.png b/.github/screenshots/3d screenshot.png new file mode 100644 index 0000000..6510ea1 --- /dev/null +++ b/.github/screenshots/3d screenshot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b246781b29fd3ef53ace2639229cb2149b791bf82977cb481f3b4f343f8776d +size 246650 diff --git a/.github/screenshots/3nhuKCK6E3.jpg b/.github/screenshots/3nhuKCK6E3.jpg deleted file mode 100644 index 7f70d9c..0000000 --- a/.github/screenshots/3nhuKCK6E3.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a33280e455034d34c0b2dfa111cff8fe179f97c2a39f7cd0c99b71b1957eda4f -size 1070602 diff --git a/.github/screenshots/3nhuKCK6E3.png b/.github/screenshots/3nhuKCK6E3.png new file mode 100644 index 0000000..5feae4b --- /dev/null +++ b/.github/screenshots/3nhuKCK6E3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5be6551346cd1067ab4bbb172828a801c4e26f1c9cac9ce35e1e712356df05bb +size 1970162 diff --git a/.github/screenshots/6wz3gW8c2h.png b/.github/screenshots/6wz3gW8c2h.png new file mode 100644 index 0000000..6ebc8de --- /dev/null +++ b/.github/screenshots/6wz3gW8c2h.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06131801cba199fa267b532a2f26b841d2b856cdd1bb9428e08879981e7b36c8 +size 1932256 diff --git a/.github/screenshots/EWPHmIBEE5.png b/.github/screenshots/EWPHmIBEE5.png index 6fd9f40..90e6416 100644 --- a/.github/screenshots/EWPHmIBEE5.png +++ b/.github/screenshots/EWPHmIBEE5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a03b0cd56b78f51f8bc4c304b8b14fa611748b9efa192ef26283e732beff90c1 -size 643600 +oid sha256:0eeee2b3d31fbb4ea49bb38a2634088fa0a54d6ce5d41f7bf39f419d518b802e +size 850435 diff --git a/.github/screenshots/GL7SkQbHIY.png b/.github/screenshots/GL7SkQbHIY.png index 2cdbe12..28544a3 100644 --- a/.github/screenshots/GL7SkQbHIY.png +++ b/.github/screenshots/GL7SkQbHIY.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf2d692f8ccf3a1c5f9addf26052a34a74c332474fbd8c5bbc7923208407a748 -size 86214 +oid sha256:a22580330264a0ad2d4a6f758ad26c18ed9a0a17cbe1254dbbad01e959b205f8 +size 110988 diff --git a/.github/screenshots/MMeJxl4IXr.png b/.github/screenshots/MMeJxl4IXr.png new file mode 100644 index 0000000..f8cd130 --- /dev/null +++ b/.github/screenshots/MMeJxl4IXr.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ba59fccc691e8f43d4eff532d88edfab411d9abb68d0566fad1c167b7b17bdd +size 183846 diff --git a/.github/screenshots/Pkazk0RufB.png b/.github/screenshots/Pkazk0RufB.png index ceced8a..3025b0d 100644 --- a/.github/screenshots/Pkazk0RufB.png +++ b/.github/screenshots/Pkazk0RufB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2dd9859c9495af93872534a913a78597d235f8fb723fe685aa1aeab9283e028b -size 1986843 +oid sha256:9db331ad2d2cf2fb2525560baf5970f8faa6c8ff6ae885d9eedd65560af8fffd +size 2035855 diff --git a/.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif b/.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif new file mode 100644 index 0000000..bd842a9 --- /dev/null +++ b/.github/screenshots/iunZbvYEGp-ezgif.com-optimize.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22e018fb97f05c24294fd3b8fe088ca755760b81d3b46dd9f04b5f52f98f34da +size 2693240 diff --git a/.github/screenshots/mockup-1777308293568.png b/.github/screenshots/mockup-1777308293568.png new file mode 100644 index 0000000..93558db --- /dev/null +++ b/.github/screenshots/mockup-1777308293568.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e478586834f41ec41e85ab76bec7ca244c2521446a40442ba3de0ea97888fa17 +size 2007401 diff --git a/.github/screenshots/rBY2mgTLy0.png b/.github/screenshots/rBY2mgTLy0.png new file mode 100644 index 0000000..653ddab --- /dev/null +++ b/.github/screenshots/rBY2mgTLy0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eca786ab7f525bcfe663f82995fc9e91234ddd5df726528b0926eaf32ed8ad8e +size 110966 diff --git a/.github/screenshots/xNj7scPEDQ.png b/.github/screenshots/xNj7scPEDQ.png index d50d6aa..4813a47 100644 --- a/.github/screenshots/xNj7scPEDQ.png +++ b/.github/screenshots/xNj7scPEDQ.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a234b8d4624ccfd677c698c1e33eb7c0b757dc13f1403fd8bc6d37ed9e6ff02 -size 1673960 +oid sha256:c26e4b9c7f690c49f9625ea2b8c2a82f03a24a0236c53dc02c158f7222c2519d +size 1805877 diff --git a/.github/screenshots/yObFD2LySH.jpg b/.github/screenshots/yObFD2LySH.jpg index 00d761f..f540a83 100644 --- a/.github/screenshots/yObFD2LySH.jpg +++ b/.github/screenshots/yObFD2LySH.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:46e473f90661400fec49d87a972a3324cd4fb18f5b8c670aa5b606462f98fbfe -size 1194459 +oid sha256:2f813f98ae41c6d383dcc5e9d6ea693b6701dddc5a03f73cbd1ed990b8532710 +size 1274712 diff --git a/.github/screenshots/zEQxtzhPGx.png b/.github/screenshots/zEQxtzhPGx.png new file mode 100644 index 0000000..1b18477 --- /dev/null +++ b/.github/screenshots/zEQxtzhPGx.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e3bf4df252866dd15f3c1c6efcfc648fbd9d320e47cf2d64551137497d229d6 +size 1324186 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..880e27d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,8 @@ downloads gameflow-deck.code-workspace .env.local src/tests/mock-roms/db.sqlite +src/tests/mock-roms/store src/tests/mock-config -bin \ No newline at end of file +bin +.config/flatpak/repo +xenia.log \ No newline at end of file diff --git a/.versionrc b/.versionrc new file mode 100644 index 0000000..fa5c461 --- /dev/null +++ b/.versionrc @@ -0,0 +1,18 @@ +{ + "packageFiles": [ + { + "filename": "package.json", + "type": "json" + } + ], + "bumpFiles": [ + { + "filename": "package.json", + "type": "json" + }, + { + "filename": "src/packages/gameflow-sdk/package.json", + "type": "json" + } + ] +} \ 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/CHANGELOG.md b/CHANGELOG.md index 8e45981..0a70168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,52 @@ # Changelog -All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. + +## [1.6.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.5.0...v1.6.0) (2026-05-09) + + +### Features + +* Implemented public plugin system accessible from the store. ([38cb752](https://github.com/simeonradivoev/gameflow-deck/commit/38cb7525527b5ad4f6eb284cdad0001fd87eaf7e)) + +## [1.5.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.4.0...v1.5.0) (2026-05-05) + + +### Features + +* Implemented local game import (with a wizard) ([06b7e40](https://github.com/simeonradivoev/gameflow-deck/commit/06b7e4074da23afdec3b2ff97f84a9e1486944d2)) + + +### Bug Fixes + +* Navigation blocking now working with focuesed input fields ([4da717c](https://github.com/simeonradivoev/gameflow-deck/commit/4da717c26d9840febd48ee87a6a493a3e1acc6b9)) + +## [1.4.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.3.0...v1.4.0) (2026-04-26) + + +### Features + +* Added more ways to detect duplicates ([05fafce](https://github.com/simeonradivoev/gameflow-deck/commit/05fafced07c853deb656d7c17d05184c42ee507c)) +* added update notes and moved update to own tab ([cf84f40](https://github.com/simeonradivoev/gameflow-deck/commit/cf84f40a174b8f242ca58fb6fe02eefab46ff442)) +* Added way to update the local games from romm when IDs change based on IGDB or Retro Achievement ID ([4806f34](https://github.com/simeonradivoev/gameflow-deck/commit/4806f3487a577ab8e7c66907e5b640d95ab8a46c)), closes [#2](https://github.com/simeonradivoev/gameflow-deck/issues/2) +* Bundled NW.js with appimages ([813785f](https://github.com/simeonradivoev/gameflow-deck/commit/813785f4f3d292a87cc4a6b86dc152c43572d2c8)) +* Implemented audio effects ([edbc390](https://github.com/simeonradivoev/gameflow-deck/commit/edbc390d144bf44da35d0f5383ec36eb25c34d1b)) +* Implemented dolphin integration ([a69147a](https://github.com/simeonradivoev/gameflow-deck/commit/a69147a4f73cf626b92622a8ee22b54f538d41a9)) +* Implemented emulator launching ([09b8b9c](https://github.com/simeonradivoev/gameflow-deck/commit/09b8b9c6f850cea3b897308925faf9be02cefa1a)), closes [#1](https://github.com/simeonradivoev/gameflow-deck/issues/1) +* Implemented emulator versions and updating ([34db717](https://github.com/simeonradivoev/gameflow-deck/commit/34db717ec5cbcf8b1ae54fbda33bf9a78f01bd17)) +* Implemented filtering and searching ([444d8c4](https://github.com/simeonradivoev/gameflow-deck/commit/444d8c4c278c6032b37f44a884cb6d7bf0b54c85)) +* implemented haptics ([54dd925](https://github.com/simeonradivoev/gameflow-deck/commit/54dd9256e361877d0950a84061d9402616706352)) +* Implemented romm saves for dolphin and xenia ([7948bd2](https://github.com/simeonradivoev/gameflow-deck/commit/7948bd24fabfc01b7be358f06fcd58c8795826c7)) + + +### Bug Fixes + +* Fixed a bunch of issues on linux ([6aacec2](https://github.com/simeonradivoev/gameflow-deck/commit/6aacec2c0de253a71599e261e07aff53055cdb1e)) +* Fixed emulator details buttons not showing ([04d5856](https://github.com/simeonradivoev/gameflow-deck/commit/04d5856f7d71c944c82877d2a1457facea4b6d31)) +* Fixed tests ([c09fbd3](https://github.com/simeonradivoev/gameflow-deck/commit/c09fbd3dc88891227eda2b9f3bd9ac45621c00ea)) +* logins now refresh on plugins load ([7bd0ebd](https://github.com/simeonradivoev/gameflow-deck/commit/7bd0ebdcca1843076911547ec1098cbaae9e2414)) +* Made self update work on windows ([ae196e1](https://github.com/simeonradivoev/gameflow-deck/commit/ae196e11d616b9813dba11f64e7c844077686db8)) +* Made store downloads extract in their own folder ([764691f](https://github.com/simeonradivoev/gameflow-deck/commit/764691fc8610fafebc93a69ca24f74bcac42a898)) ## [1.3.0](https://github.com/simeonradivoev/gameflow-deck/compare/v1.2.1...v1.3.0) (2026-03-31) diff --git a/README.md b/README.md index b3578f1..0e5864e 100644 --- a/README.md +++ b/README.md @@ -4,50 +4,78 @@ A Cross-Platform open source Retro gaming frontend designed for handheld and con Focused on building a simple user experience and intuitive UI as a curated community driven experience. > [!WARNING] -> This app is actively in development, it doesn't have most of its major features implemented yet. +> This app is actively in development, it is constantly changing and improving. > It will have an opinionated design and will be used as an experiment in discovering a good UX. +## Community + +Join us on Discord, where you can ask questions, submit ideas and get help. + +[![](https://invidget.switchblade.xyz/R9KakhY67d)](https://discord.gg/R9KakhY67d) + ## Features ### Integrations - **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms. + - Show Achievements and sync playtime. + - Experimental save syncing - **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores. +- **[RClone](https://github.com/rclone/rclone)** - sync saves between devices or cloud. Some Emulators and store games support it. +- **[UMU](https://github.com/Open-Wine-Components/umu-launcher)** - UMU Launcher for playing windows games on linux without needing steam. (Only used for store games for now) ### Store -- **Emulators** - (WIP) Download and install emulators and automatically configure them -- **Free Curated Games** - Download free curreted games and homebrew roms without ever leaving the app +- **Emulators** - (WIP) Download and install emulators and automatically configure them from a list of supported in the store. Some even come with advanced features like cloud saves. +- **Free Curated Games** - Download free curated games and homebrew roms without ever leaving the app ### Others - **Cross Platform** - Can run on multiple platforms. Built with web technologies and bun backend. - **Steam Deck Support** - Extensively tested with the steam deck. It can use flatpak installed browsers. -- **Lightweight** - It uses the existing system browser to launch the front end, so no need to include a whole web browser. +- **Lightweight** - It uses the window's webview as a frontend, reducing build size and ram usage. - On Windows it first uses webview2 then your browser - - On linux it uses WebKitGTK or a browser even from flatpak + - On linux it does ship with NW.js to work on most distros. A big one is the steam deck missing WebKitGTK. - Not tested on Mac yet - **Great for Controllers** - The UI is inspired by the switch and works great with joysticks and dpads. - **Automatic Downloads** - Downloads roms from ROMM automatically -- **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch games. +- **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch roms. You can bring your existing configurations. - Easy fallback configuration with built in file browser. - **Responsive Layout** - Optimized mainly for the steam deck with responsive layout support and dynamic switching of inputs. +- **Cloud/Device Save Sync** - For supported games and emulators. +- **Dark and Light** - Dark and light themes for your preference. ## Screenshots - - - - - - + + + + + + + + + + ## Goals - I want to build an open and free platform where you can play and discover new hidden gems from the past. - I plan to add a free store where you can download all your needed emulators, the goal is to not have to leave the UI for anything. - I really want to add matrix chat support in the app for engaging with your favorite community. Having access to so many nodejs libraries would make it quite straight forward. -- I'm sick of closed source and private store fronts, and want a way to share community currated free experiences. I'm also sick of the profit driven nature of games and promotions. +- I'm sick of closed source and private store fronts, and want a way to share community curated free experiences. I'm also sick of the profit driven nature of games and promotions. +- Being self contained, I want to avoid writing as little as possible to system and contain and manage settings in a custom changeable directory. This was mainly a side-effect of having the low storage steam deck and always running out of space on my internal hard drive. + +## Usage + +There are currently 2 ways of getting games. One is logging in through romm and importing your games from there. The other is the store (it's a bit limited right now). I might add local import of roms since IGDB login is already implemented. + +The app created a default folder in your home folder. You can move it. It stores everything there. From downloaded roms, emulators and configs. + +## Existing Setups + +The game should work pretty well with existing emulators one has installed. It uses the ES-DE config to find installed emulators. Only downside is more advanced integrations won't work, as they are mainly used for store emulators where the app has more control over, plus I don't want to mess up existing setups. +But given it's an existing setup, say from emudeck it won't matter much as it's already configured say for the steam deck. ## Development @@ -78,6 +106,17 @@ Focused on building a simple user experience and intuitive UI as a curated commu - `bun run openapi-ts` generated the openapi client calls from romm's API - `bun run package:windows` builds an package to be distributed on windows - `bun run package:linux` builds an AppImage to be distributed on linux + - `bun run test` run tests + - `bun run download:chromium` downloads degoogled chromium to use as the frontend + - `bun run download:nwjs` downloads NW.js to use as a frontend. + +## Plugins + +To create a plugin create a new npm project and install: +`bun i --peer @simeonradivoev/gameflow-sdk` + +Then publish the package to npmjs with a tag `gameflow-plugin` to appear in the UI. +For more info check the [SDK README](./scripts/sdk/README.md) ### Tech Stack @@ -90,3 +129,10 @@ Focused on building a simple user experience and intuitive UI as a curated commu - [elysia](https://elysiajs.com/) for the APIs - [webview](https://github.com/webview/webview) for launching existing system webviews instead of full browser if possible. - [emulatorjs](https://emulatorjs.org/) for playing lots of roms inside the app without having to deal with external emulators + +### Credits + +- UI Sounds + - [CC BY 4.0 - Credit: JC Sounds](https://opengameart.org/content/jc-sounds-ui-utility-pack-vol-1) + - [Sounds by: Chhoff](https://chhoffmusic.itch.io/classic-ui-sfx) + - [UI Sound Effects by lolurio](https://lolurio.itch.io/lolurios-free-cozy-ui-sfx) diff --git a/bun.lock b/bun.lock index 6188919..5fbc781 100644 --- a/bun.lock +++ b/bun.lock @@ -7,106 +7,154 @@ "dependencies": { "7zip-bin": "^5.2.0", "@auth/core": "^0.34.3", - "@elysiajs/cors": "^1.4.1", - "@elysiajs/eden": "^1.4.6", - "@jimp/wasm-webp": "^1.6.0", + "@elysiajs/cors": "^1.4.2", + "@elysiajs/eden": "^1.4.9", + "@jimp/wasm-webp": "^1.6.1", + "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", - "conf": "^15.0.2", - "drizzle-orm": "^0.45.1", - "elysia": "^1.4.22", - "fs-extra": "^11.3.3", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", "get-folder-size": "^5.0.0", "ini": "^6.0.0", - "jimp": "^1.6.0", + "jimp": "^1.6.1", "mustache": "^4.2.0", "node-7z": "^3.0.0", "node-disk-info": "^1.3.0", - "node-downloader-helper": "^2.1.10", + "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", + "npm-check-updates": "^22.2.0", "open": "^11.0.0", + "p-queue": "^9.2.0", "pathe": "^2.0.3", - "systeminformation": "^5.31.5", - "tapable": "^2.3.0", - "tough-cookie": "^6.0.0", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", + "systeminformation": "^5.31.6", + "tapable": "^2.3.3", + "tough-cookie": "^6.0.1", "tough-cookie-file-store": "^3.3.0", - "ts-igdb-client": "^0.4.2", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", - "zod": "^4.3.6", + "zod": "^4.4.3", }, "devDependencies": { - "@ap0nia/eden": "^1.0.0-next.22", + "@ap0nia/eden": "^1.6.1", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@emulatorjs/emulatorjs": "^4.2.3", - "@hey-api/openapi-ts": "^0.91.0", - "@noriginmedia/norigin-spatial-navigation": "^2.3.0", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-form": "^1.28.0", - "@tanstack/react-query": "^5.90.20", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router": "^1.157.16", - "@tanstack/react-router-devtools": "^1.154.12", - "@tanstack/react-router-ssr-query": "^1.157.17", - "@tanstack/router-plugin": "^1.157.16", - "@tanstack/zod-adapter": "^1.162.4", + "@hey-api/openapi-ts": "^0.91.1", + "@noriginmedia/norigin-spatial-navigation": "^3.1.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-form": "^1.32.0", + "@tanstack/react-query": "^5.100.10", + "@tanstack/react-query-devtools": "^5.100.10", + "@tanstack/react-query-persist-client": "^5.100.10", + "@tanstack/react-router": "^1.169.2", + "@tanstack/react-router-devtools": "^1.166.13", + "@tanstack/react-router-ssr-query": "^1.166.12", + "@tanstack/router-plugin": "^1.167.35", + "@tanstack/zod-adapter": "^1.166.9", "@types/adm-zip": "^0.5.8", + "@types/audiosprite": "^0.7.3", "@types/bun": "latest", "@types/fs-extra": "^11.0.4", + "@types/howler": "^2.2.12", "@types/ini": "^4.1.1", + "@types/json-schema": "^7.0.15", "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", - "@types/react": "^19.2.9", + "@types/rclone.js": "^0.6.3", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", - "@vitejs/plugin-react": "^5.1.2", - "adm-zip": "^0.5.16", + "@vitejs/plugin-react": "^5.2.0", + "adm-zip": "^0.5.17", "animate.css": "^4.1.1", "app-builder-bin": "^5.0.0-alpha.13", + "audiosprite": "^0.7.2", "babel-plugin-react-compiler": "^1.0.0", "classnames": "^2.5.1", + "commit-and-tag-version": "^12.7.3", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "daisyui": "^5.5.14", - "drizzle-kit": "^0.31.9", - "dts-bundle-generator": "^9.5.1", + "daisyui": "^5.5.19", + "drizzle-kit": "^0.31.10", "eden-tanstack-query": "^0.0.9", + "howler": "^2.2.4", + "idb-keyval": "^6.2.2", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-error-boundary": "^6.1.0", + "pretty-ms": "^9.3.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-error-boundary": "^6.1.1", "react-hot-toast": "^2.6.0", - "react-qr-code": "^2.0.18", - "sass-embedded": "^1.97.3", - "standard-version": "^9.5.0", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18", + "react-markdown": "^10.1.0", + "react-qr-code": "^2.0.21", + "sass-embedded": "^1.99.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", "usehooks-ts": "^3.1.1", - "vite": "^7.3.1", - "vite-plugin-svg-icons-ng": "^1.5.2", + "vite": "^7.3.3", + "vite-plugin-svg-icons-ng": "^1.9.1", "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1", }, }, + "src/packages/gameflow-sdk": { + "name": "@simeonradivoev/gameflow-sdk", + "version": "1.6.0", + "bin": { + "gameflow-build": "build.ts", + }, + "peerDependencies": { + "7zip-bin": "^5.2.0", + "@auth/core": "^0.34.3", + "cheerio": "^1.2.0", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", + "get-folder-size": "^5.0.0", + "ini": "^6.0.0", + "jimp": "^1.6.1", + "mustache": "^4.2.0", + "node-7z": "^3.0.0", + "node-disk-info": "^1.3.0", + "node-downloader-helper": "^2.1.11", + "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", + "open": "^11.0.0", + "p-queue": "^9.2.0", + "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", + "tapable": "^2.3.3", + "unzip-stream": "^0.3.4", + "zod": "^4.4.3", + }, + }, }, "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], - "@ap0nia/eden": ["@ap0nia/eden@1.0.0-next.22", "", { "peerDependencies": { "elysia": "^1.3.1" } }, "sha512-9iH09koK29Yuem80fz8nCt9iHVcJqxUo2QHAr4psI02PhvL70n6aWVo/hlHyYXwOSsSgRQlLl1vPmiulFOUFoA=="], + "@ap0nia/eden": ["@ap0nia/eden@1.6.1", "", { "dependencies": { "elysia": "1.2.15" } }, "sha512-jlsUyh4PsYNnMcPuQ3IJq0hhDNnyRNGYx+MSAJlcgKs4En9qrokLorSbTRvVjA1Mdx4VdzEADcPn99Kbph0SOw=="], "@ap0nia/eden-tanstack-query": ["@ap0nia/eden-tanstack-query@1.0.0-next.22", "", {}, "sha512-eSQ98G4TYzrAdsfRekrvqIrTqrAUFy+YpibZ5fj5KL6/R6FcrS2U2F51iML98baXT4MTpOJARY9p+7x0hiA8Qw=="], "@auth/core": ["@auth/core@0.34.3", "", { "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw=="], - "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], - "@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], @@ -126,7 +174,7 @@ "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], - "@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], @@ -148,9 +196,9 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], + "@elysiajs/cors": ["@elysiajs/cors@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ=="], - "@elysiajs/eden": ["@elysiajs/eden@1.4.6", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-Tsa4NwXEWg/u73vWiYZQ3L5/ecgZSxqiEjYwpS+4qBKXeTZqZKl2hcgHJSVBL+InEDMi35Xugct7qyAXE5oM4Q=="], + "@elysiajs/eden": ["@elysiajs/eden@1.4.9", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-3CKVD4ycVjB8nCNssfmhnUuq3SzSHkUES3v5PNCFr9LxIrx39/HVRAZ8z2sLxrFqzUs48dCBZaxoZzJ5UUVHDA=="], "@emulatorjs/core-81": ["@emulatorjs/core-81@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-oPQEqjpR3z7Yedte4u3sOXDZ4NXAykNcbENjYcB+x3QshF8I+3MQCo8kINOT2lsqqgx91WR4kmEaYQqU39YsDA=="], @@ -310,13 +358,13 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@hey-api/codegen-core": ["@hey-api/codegen-core@0.6.0", "", { "dependencies": { "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-O6TRP3g1188gdpZC9yio64KkvWD97QpLjaCFnwTgykTZtzQXZbkyw+lYPVfZ7Ee+m7eLau1/jEJmQcUY9G0sLw=="], + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.6.1", "", { "dependencies": { "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-khTIpxhKEAqmRmeLUnAFJQs4Sbg9RPokovJk9rRcC8B5MWH1j3/BRSqfpAIiJUBDU1+nbVg2RVCV+eQ174cdvw=="], "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-gRbyyTjzpFVNmbD+Upn3w4dWV+bCXGJbff3A7leDO/tfNxSz1xIb6Ad/5UKtvEW9kDt/2Uyc3XkFZ6rpafvbfQ=="], - "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.91.0", "", { "dependencies": { "@hey-api/codegen-core": "0.6.0", "@hey-api/json-schema-ref-parser": "1.2.3", "@hey-api/shared": "0.1.0", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "color-support": "1.1.3", "commander": "14.0.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-AHkd982HsPz1XpqRm59URwJyJqTzyzzC30EAp07b/0M9KojjneCPxm8FnvFnXLRTMyKgcOymMsYXuLzJ9mpMHA=="], + "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.91.1", "", { "dependencies": { "@hey-api/codegen-core": "0.6.1", "@hey-api/json-schema-ref-parser": "1.2.3", "@hey-api/shared": "0.1.1", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "color-support": "1.1.3", "commander": "14.0.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-d16WR35UtthK/ihAIwJaKxrj/zvb5LbYwtVJCyZFFMin2qzDU8Y3Lpk78ensAykrLoaDLzpd0iIyt9JCP5Qmww=="], - "@hey-api/shared": ["@hey-api/shared@0.1.0", "", { "dependencies": { "@hey-api/codegen-core": "0.6.0", "@hey-api/json-schema-ref-parser": "1.2.3", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "cross-spawn": "7.0.6", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-qEDMSBWEEWxcBU5XHacjCCnFOVq1YWPPR3owURVep60I7ejfSG5OINxM4eF+p3KJGMcZduzzfq9pd1grStHZBg=="], + "@hey-api/shared": ["@hey-api/shared@0.1.1", "", { "dependencies": { "@hey-api/codegen-core": "0.6.1", "@hey-api/json-schema-ref-parser": "1.2.3", "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "cross-spawn": "7.0.6", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-/irgNGXw9TL5aKB3S7jCLgh07vgDFkYjSjz7vEWO9xEe6MUhx76zSFzkPspk2UrLghYayvmaKPf1ky4XjNI9ZQ=="], "@hey-api/types": ["@hey-api/types@0.1.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-mZaiPOWH761yD4GjDQvtjS2ZYLu5o5pI1TVSvV/u7cmbybv51/FVtinFBeaE1kFQCKZ8OQpn2ezjLBJrKsGATw=="], @@ -328,63 +376,63 @@ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + "@jimp/core": ["@jimp/core@1.6.1", "", { "dependencies": { "@jimp/file-ops": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^21.3.3", "mime": "3" } }, "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A=="], - "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + "@jimp/diff": ["@jimp/diff@1.6.1", "", { "dependencies": { "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "pixelmatch": "^5.3.0" } }, "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ=="], - "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + "@jimp/file-ops": ["@jimp/file-ops@1.6.1", "", {}, "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w=="], - "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "bmp-ts": "^1.0.9" } }, "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ=="], - "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + "@jimp/js-gif": ["@jimp/js-gif@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w=="], - "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "jpeg-js": "^0.4.4" } }, "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg=="], - "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + "@jimp/js-png": ["@jimp/js-png@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "pngjs": "^7.0.0" } }, "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA=="], - "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "utif2": "^4.1.0" } }, "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw=="], - "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ=="], - "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/utils": "1.6.1" } }, "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ=="], - "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "zod": "^3.23.8" } }, "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ=="], - "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A=="], - "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg=="], - "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "zod": "^3.23.8" } }, "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg=="], - "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg=="], - "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g=="], - "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1" } }, "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew=="], - "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA=="], - "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "zod": "^3.23.8" } }, "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA=="], - "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/js-bmp": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/js-tiff": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "any-base": "^1.1.0" } }, "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A=="], - "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "zod": "^3.23.8" } }, "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A=="], - "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/types": "1.6.1", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w=="], - "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.1", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww=="], - "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/types": "1.6.1", "zod": "^3.23.8" } }, "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg=="], - "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg=="], - "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-hash": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "zod": "^3.23.8" } }, "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q=="], - "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + "@jimp/types": ["@jimp/types@1.6.1", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w=="], - "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + "@jimp/utils": ["@jimp/utils@1.6.1", "", { "dependencies": { "@jimp/types": "1.6.1", "tinycolor2": "^1.6.0" } }, "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw=="], - "@jimp/wasm-webp": ["@jimp/wasm-webp@1.6.0", "", { "dependencies": { "@jsquash/webp": "^1.4.0", "zod": "^3.23.8" } }, "sha512-P0zUpK6n2XIAn8bt0F6rhSn1+FgteBTrL+TBb6Oqw8v5qEDJoNYkd6LlfZYN8YwtRBTBdZ8GFnWsg2Sar+qOkA=="], + "@jimp/wasm-webp": ["@jimp/wasm-webp@1.6.1", "", { "dependencies": { "@jsquash/webp": "^1.4.0", "zod": "^3.23.8" } }, "sha512-t+Wqkde4xQHP/UZ4bDiDo3pbhFz32E7FvQCUkuFdJDmEDl6gPCs6LQiQVBmumUQYTeVLiLtLzlM9j8s7yF0sXQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -402,6 +450,8 @@ "@jsquash/webp": ["@jsquash/webp@1.5.0", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw=="], + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@node-minify/clean-css": ["@node-minify/clean-css@9.0.1", "", { "dependencies": { "@node-minify/utils": "9.0.1", "clean-css": "5.3.3" } }, "sha512-GHTMmjGloRvNzqdG7foI0iZeS2QmuYCQvdASJP9sCKjkpH45bygODpXPYKnlzUEpQgYvPK9Q3GxqYnVY9SdoqA=="], "@node-minify/core": ["@node-minify/core@9.0.2", "", { "dependencies": { "@node-minify/utils": "9.0.1", "glob": "10.3.3", "mkdirp": "3.0.1" } }, "sha512-FNhv29Wom6wKrrFKaeAfmZqz7TX5A1E6P+bpd0VIc+DYWMLUIhAViS8riaZg3A1oD0s06s+5BG2Fg7RqMKiKHw=="], @@ -410,13 +460,11 @@ "@node-minify/utils": ["@node-minify/utils@9.0.1", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-aC1+mhKTP3IMa2VcuGl3ui92LO/7CPQWldNGzu3BVGKiMNJ70AKJW/R6huuYCSuQyHDGM9oFwiVClsZnFxn67g=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + "@noriginmedia/norigin-spatial-navigation": ["@noriginmedia/norigin-spatial-navigation@3.1.0", "", { "dependencies": { "@noriginmedia/norigin-spatial-navigation-core": "^3.1.0", "@noriginmedia/norigin-spatial-navigation-react": "^3.1.0" } }, "sha512-KPge4ocpDFde7cpZ2aqrPrKmxOxkue983NsfpmE/vX4k2l+Ik8UkucCWGqkcy81TXkEyRhdsYwFTRePNB5qUCg=="], - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + "@noriginmedia/norigin-spatial-navigation-core": ["@noriginmedia/norigin-spatial-navigation-core@3.1.0", "", { "dependencies": { "lodash-es": "^4.17.21" } }, "sha512-AFxJHurTqy+I3NLnaXsLUBa9FZjUryMNFEdLpPrITSqDjk525aINeLMOK1PN7WTiK5xpHL0pbpw0+uVOfWgp4w=="], - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - - "@noriginmedia/norigin-spatial-navigation": ["@noriginmedia/norigin-spatial-navigation@2.3.0", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-gR//N45NnKz1h0/AVknkfg7QnNATETdgXUUD3EKPxuQPyhk7NhsphODzRamyvjYaxsU6VbY/szcUlzBWWBkNMw=="], + "@noriginmedia/norigin-spatial-navigation-react": ["@noriginmedia/norigin-spatial-navigation-react@3.1.0", "", { "dependencies": { "@noriginmedia/norigin-spatial-navigation-core": "^3.1.0", "lodash-es": "^4.17.21" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-F2PIqzTnlYbbc+oRdIQfBf7e1VcA1uhyjze4uOal8FHI8tZs1U8nomH84+2KcM6G3EM/XGexgQsPy5f5dtrmUA=="], "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], @@ -448,9 +496,13 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@phalcode/ts-apicalypse": ["@phalcode/ts-apicalypse@1.0.26", "", { "dependencies": { "axios": "^1.11.0" } }, "sha512-RdqkuunEYu63hRs4tYZ6FLTC17ynC6AJ/YUppRGSIyr6pm5pI/vB1qlEaeUr/f4JJsNmbFwGjnMJdXvoP1LmWA=="], + + "@phalcode/ts-igdb-client": ["@phalcode/ts-igdb-client@1.0.26", "", { "dependencies": { "@phalcode/ts-apicalypse": "^1.0.26", "axios": "^1.11.0" } }, "sha512-ITBazxhafHDBVJFI6THrLOT8OuO4zhD9pOeKQUFJ80soKhBevvbJz3tzkt24fF783Hoqaja8rWmGSwcN04d5gA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="], @@ -502,92 +554,100 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="], + "@simeonradivoev/gameflow-sdk": ["@simeonradivoev/gameflow-sdk@workspace:src/packages/gameflow-sdk"], + "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], - "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], - "@tanstack/form-core": ["@tanstack/form-core@1.28.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.7.7" } }, "sha512-MX3YveB6SKHAJ2yUwp+Ca/PCguub8bVEnLcLUbFLwdkSRMkP0lMGdaZl+F0JuEgZw56c6iFoRyfILhS7OQpydA=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], - "@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="], + "@tanstack/form-core": ["@tanstack/form-core@1.32.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-Tn5VRDSjyqjmaet2tJMuEWDRFyrCaon03vxXPlSSaiSs6C/N7lCIwGCXJbZXEUq1kTj8jYN9qyXHbsz4LQHcow=="], + + "@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], - "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="], - "@tanstack/query-devtools": ["@tanstack/query-devtools@5.93.0", "", {}, "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg=="], + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.100.10", "", {}, "sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw=="], - "@tanstack/react-form": ["@tanstack/react-form@1.28.0", "", { "dependencies": { "@tanstack/form-core": "1.28.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ibLcf5QkTogV0Ly944CuqGxWTpHyreNA4Cy8Wtky7zE9wtE3HVapQt4/hUuXo51zihfTkv5URiXpoTSKF5Xosg=="], + "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" } }, "sha512-O9Pey40DhTTDBABS0bHr+KNL5/VMf6PrqjexS8WoDDtnkaoWM+y0MSe0V9E5W+BwvkjM33mB3aYcCxa175gZTQ=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="], + "@tanstack/react-form": ["@tanstack/react-form@1.32.0", "", { "dependencies": { "@tanstack/form-core": "1.32.0", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6WP5SQTA6/H9crCpvpq3ZppYWqtrdE5NjOy6ebABi6uAQPqhfTzrdjS9t40mCZCFtGI5585OhJV6zBP/KN2zcw=="], - "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.3", "", { "dependencies": { "@tanstack/query-devtools": "5.93.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA=="], + "@tanstack/react-query": ["@tanstack/react-query@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="], - "@tanstack/react-router": ["@tanstack/react-router@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.16", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-xwFQa7S7dhBhm3aJYwU79cITEYgAKSrcL6wokaROIvl2JyIeazn8jueWqUPJzFjv+QF6Q8euKRlKUEyb5q2ymg=="], + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.100.10", "", { "dependencies": { "@tanstack/query-devtools": "5.100.10" }, "peerDependencies": { "@tanstack/react-query": "^5.100.10", "react": "^18 || ^19" } }, "sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg=="], - "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.154.12", "", { "dependencies": { "@tanstack/router-devtools-core": "1.154.12" }, "peerDependencies": { "@tanstack/react-router": "^1.154.12", "@tanstack/router-core": "^1.154.12", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-TcGe7pmeVjk1zD58eMR87GG9OXMx6LDGz5QopmJS4LafvK2hvuaht+eKBnZlCvKLPlXu5juwHT4u+2bYdn6sqQ=="], + "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.100.10", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.100.10" }, "peerDependencies": { "@tanstack/react-query": "^5.100.10", "react": "^18 || ^19" } }, "sha512-EImacngLXYEtzlrIPf8IAqKN3foS7cmSj4GWqsHJvc7K+8fy2c3s7mdV8oTJeii/TvrzO4X9fcnXi6tUHMIOHA=="], - "@tanstack/react-router-ssr-query": ["@tanstack/react-router-ssr-query@1.157.17", "", { "dependencies": { "@tanstack/router-ssr-query-core": "1.157.16" }, "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/react-query": ">=5.90.0", "@tanstack/react-router": ">=1.127.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-IrLi+cNLOAiTShuwGUi9WCNWXMG4993fnyXnWMC53M64rhUngPRFCAQiA05BavE/d7bifC6WKDTt1ZhC1Pewaw=="], + "@tanstack/react-router": ["@tanstack/react-router@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.169.2", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ=="], - "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], + "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.166.13", "", { "dependencies": { "@tanstack/router-devtools-core": "1.167.3" }, "peerDependencies": { "@tanstack/react-router": "^1.168.15", "@tanstack/router-core": "^1.168.11", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA=="], - "@tanstack/router-core": ["@tanstack/router-core@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA=="], + "@tanstack/react-router-ssr-query": ["@tanstack/react-router-ssr-query@1.166.12", "", { "dependencies": { "@tanstack/router-ssr-query-core": "1.168.0" }, "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/react-query": ">=5.90.0", "@tanstack/react-router": ">=1.127.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-yDUIoEh+PimAcWmk/2BE0EkI8TwLVeToNzoIuwahmTtBUR+ptZPWbtiPjudO8JZ0BhT3odHtuOn1eBOK0/4NAQ=="], - "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.154.12", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.154.12", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-lvnP9cqknvSSkUjqQRVn61TcBhq72hCFFOzMwdFdFPTO8nMEXvYE6ZZJiXtivwcvsKmO6XVFLMXuJr/928gNkw=="], + "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Ae2M00VTFjjED7glSCi/mMLENRzhEym6NgjoOx7UVNbCC/rLU/5ASDe5VIlDa8QLEqP5Pj088Gi51gjmRuICvQ=="], + "@tanstack/router-core": ["@tanstack/router-core@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw=="], - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.157.16", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.157.16", "@tanstack/router-generator": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.157.16", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-YQg7L06xyCJAYyrEJNZGAnDL8oChILU+G/eSDIwEfcWn5iLk+47x1Gcdxr82++47PWmOPhzuTo8edDQXWs7kAA=="], + "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.3", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.168.11", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg=="], - "@tanstack/router-ssr-query-core": ["@tanstack/router-ssr-query-core@1.157.16", "", { "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/router-core": ">=1.127.0" } }, "sha512-YuwNG4jdtn+r90yyti8yP27IKaVoflWmRezqnj0gyJxpRauBkK7MVLvWSNbJadnk88b9H+rdtNOF2k3owGaong=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.166.42", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.169.2", "@tanstack/router-utils": "1.161.8", "@tanstack/virtual-file-routes": "1.161.7", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^3.24.2" } }, "sha512-2qBWC0t78r6b3vI+AbnvCZcFAvbYBDlLuWZrTjQbcjUmwG3qyeQp983tJyDuj9wb5//adG1tgAGXZkJ3aDwdBg=="], - "@tanstack/router-utils": ["@tanstack/router-utils@1.154.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA=="], + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.35", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.169.2", "@tanstack/router-generator": "1.166.42", "@tanstack/router-utils": "1.161.8", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^3.0.0", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.169.2", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-UAScU5VAzLYVY4FML/Cbc5S5TucT4I8Ata05yozGOe4ZfepTKRffA5xWLtD2N+ov5svdv0KTX/kqlZnYPe28mA=="], - "@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="], + "@tanstack/router-ssr-query-core": ["@tanstack/router-ssr-query-core@1.168.0", "", { "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/router-core": ">=1.127.0" } }, "sha512-5yBUAF1d9z2kOFKoz1spvpvkMSTmRnRXEwi+bGKfrXYmt7CfHu3Pk8KUFMln67uQoKQ9VTkcd5tLkjJVrZ2/AQ=="], - "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="], + "@tanstack/router-utils": ["@tanstack/router-utils@1.161.8", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-xyiLWEKjfBAVhauDSSjXxyf7s8elU6SM+V050sbkofvGmIIvkwPFtDsX7Gvwh14kBd6iCwAT+RiPvXTxAptY0Q=="], - "@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.162.4", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-sO4n2o9F7gZKHZb/nW/fMcDaeVcbFZ2a7zCA+GkaHJwRmhKKlQQ0dae9pc8wOMMG+QkfH1Wysq+tg2RNvm/kpg=="], + "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], + + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], + + "@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.166.9", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-HHllQ/CKGi8YBbftv6OmzojtHM6Rk4UszAFICAgUMbwiqtKqjlIZQ/7mv2IPNxBb8YlOQgzyQ4jz2UTEXIi6YA=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="], - "@types/adm-zip": ["@types/adm-zip@0.5.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q=="], + "@types/audiosprite": ["@types/audiosprite@0.7.3", "", {}, "sha512-P4rUuHPt2kWPMqyObfh1SfqS2H/ZuTxByh00ecuI2tOdvP5b8NznuBeQgemDXV9v8b4pewFPB9G3BuYRONqD7A=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -596,24 +656,36 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/howler": ["@types/howler@2.2.12", "", {}, "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g=="], + "@types/ini": ["@types/ini@4.1.1", "", {}, "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/jsonfile": ["@types/jsonfile@6.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/mustache": ["@types/mustache@4.2.6", "", {}, "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw=="], "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], @@ -622,25 +694,29 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], - "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], + "@types/rclone.js": ["@types/rclone.js@0.6.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-BssKAAVRY//fxGKso8SatyOwiD7X0toDofNnVxZlIXmN7UHrn2UBTxldNAjgUvWA91qJyeEPfKmeJpZVhLugXg=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/unzip-stream": ["@types/unzip-stream@0.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-ud0vtsNRF+joUCyvNMyo0j5DKX2Lh/im+xVgRzBEsfHhQYZ+i4fKTveova9XxLzt6Jl6G0e/0mM4aC0gqZYSnA=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], "JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="], - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], - "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "add-stream": ["add-stream@1.0.0", "", {}, "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ=="], - "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], + "adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -652,7 +728,7 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], @@ -668,25 +744,25 @@ "arrify": ["arrify@1.0.1", "", {}, "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA=="], - "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - - "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "async": ["async@0.9.2", "", {}, "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "atomically": ["atomically@2.1.0", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q=="], + "audiosprite": ["audiosprite@0.7.2", "", { "dependencies": { "async": "~0.9.0", "glob": "^6.0.4", "mkdirp": "^0.5.0", "optimist": "~0.6.1", "underscore": "~1.8.3", "winston": "~1.0.0" }, "bin": { "audiosprite": "./cli.js" } }, "sha512-9Z6UwUuv4To5nUQNRIw5/Q3qA7HYm0ANzoW5EDGPEsU2oIRVgmIlLlm9YZfpPKoeUxt54vMStl2/762189VmJw=="], + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], - "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], @@ -708,13 +784,11 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], - "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -730,9 +804,19 @@ "caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], @@ -750,18 +834,24 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], "colorjs.io": ["colorjs.io@0.5.2", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="], + "colors": ["colors@1.0.3", "", {}, "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "commit-and-tag-version": ["commit-and-tag-version@12.7.3", "", { "dependencies": { "chalk": "^2.4.2", "conventional-changelog": "4.0.0", "conventional-changelog-config-spec": "2.1.0", "conventional-changelog-conventionalcommits": "6.1.0", "conventional-recommended-bump": "7.0.1", "detect-indent": "^6.1.0", "detect-newline": "^3.1.0", "dotgitignore": "^2.1.0", "fast-xml-parser": "^5.5.6", "figures": "^3.2.0", "find-up": "^5.0.0", "git-semver-tags": "^5.0.1", "semver": "^7.7.2", "yaml": "^2.6.0", "yargs": "^17.7.2" }, "bin": { "commit-and-tag-version": "bin/cli.js" } }, "sha512-rbauuCDU98yEHMy/LrNNu8HLTuGv7C2kN/3GXC59L18aJGii0eiryCESb1SEHXNFem2/2ngWG/Pq6qaCqw3aCw=="], + "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -770,51 +860,51 @@ "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], - "conf": ["conf@15.0.2", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw=="], + "conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="], "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "conventional-changelog": ["conventional-changelog@3.1.25", "", { "dependencies": { "conventional-changelog-angular": "^5.0.12", "conventional-changelog-atom": "^2.0.8", "conventional-changelog-codemirror": "^2.0.8", "conventional-changelog-conventionalcommits": "^4.5.0", "conventional-changelog-core": "^4.2.1", "conventional-changelog-ember": "^2.0.9", "conventional-changelog-eslint": "^3.0.9", "conventional-changelog-express": "^2.0.6", "conventional-changelog-jquery": "^3.0.11", "conventional-changelog-jshint": "^2.0.9", "conventional-changelog-preset-loader": "^2.3.4" } }, "sha512-ryhi3fd1mKf3fSjbLXOfK2D06YwKNic1nC9mWqybBHdObPd8KJ2vjaXZfYj1U23t+V8T8n0d7gwnc9XbIdFbyQ=="], + "conventional-changelog": ["conventional-changelog@4.0.0", "", { "dependencies": { "conventional-changelog-angular": "^6.0.0", "conventional-changelog-atom": "^3.0.0", "conventional-changelog-codemirror": "^3.0.0", "conventional-changelog-conventionalcommits": "^6.0.0", "conventional-changelog-core": "^5.0.0", "conventional-changelog-ember": "^3.0.0", "conventional-changelog-eslint": "^4.0.0", "conventional-changelog-express": "^3.0.0", "conventional-changelog-jquery": "^4.0.0", "conventional-changelog-jshint": "^3.0.0", "conventional-changelog-preset-loader": "^3.0.0" } }, "sha512-JbZjwE1PzxQCvm+HUTIr+pbSekS8qdOZzMakdFyPtdkEWwFvwEJYONzjgMm0txCb2yBcIcfKDmg8xtCKTdecNQ=="], - "conventional-changelog-angular": ["conventional-changelog-angular@5.0.13", "", { "dependencies": { "compare-func": "^2.0.0", "q": "^1.5.1" } }, "sha512-i/gipMxs7s8L/QeuavPF2hLnJgH6pEZAttySB6aiQLWcX3puWDL3ACVmvBhJGxnAy52Qc15ua26BufY6KpmrVA=="], + "conventional-changelog-angular": ["conventional-changelog-angular@6.0.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-6qLgrBF4gueoC7AFVHu51nHL9pF9FRjXrH+ceVf7WmAfH3gs+gEYOkvxhjMPjZu57I4AGUGoNTY8V7Hrgf1uqg=="], - "conventional-changelog-atom": ["conventional-changelog-atom@2.0.8", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-xo6v46icsFTK3bb7dY/8m2qvc8sZemRgdqLb/bjpBsH2UyOS8rKNTgcb5025Hri6IpANPApbXMg15QLb1LJpBw=="], + "conventional-changelog-atom": ["conventional-changelog-atom@3.0.0", "", {}, "sha512-pnN5bWpH+iTUWU3FaYdw5lJmfWeqSyrUkG+wyHBI9tC1dLNnHkbAOg1SzTQ7zBqiFrfo55h40VsGXWMdopwc5g=="], - "conventional-changelog-codemirror": ["conventional-changelog-codemirror@2.0.8", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-z5DAsn3uj1Vfp7po3gpt2Boc+Bdwmw2++ZHa5Ak9k0UKsYAO5mH1UBTN0qSCuJZREIhX6WU4E1p3IW2oRCNzQw=="], + "conventional-changelog-codemirror": ["conventional-changelog-codemirror@3.0.0", "", {}, "sha512-wzchZt9HEaAZrenZAUUHMCFcuYzGoZ1wG/kTRMICxsnW5AXohYMRxnyecP9ob42Gvn5TilhC0q66AtTPRSNMfw=="], "conventional-changelog-config-spec": ["conventional-changelog-config-spec@2.1.0", "", {}, "sha512-IpVePh16EbbB02V+UA+HQnnPIohgXvJRxHcS5+Uwk4AT5LjzCZJm5sp/yqs5C6KZJ1jMsV4paEV13BN1pvDuxQ=="], - "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@4.6.3", "", { "dependencies": { "compare-func": "^2.0.0", "lodash": "^4.17.15", "q": "^1.5.1" } }, "sha512-LTTQV4fwOM4oLPad317V/QNQ1FY4Hju5qeBIM1uTHbrnCE+Eg4CdRZ3gO2pUeR+tzWdp80M2j3qFFEDWVqOV4g=="], + "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@6.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-3cS3GEtR78zTfMzk0AizXKKIdN4OvSh7ibNz6/DPbhWWQu7LqE/8+/GqSodV+sywUR2gpJAdP/1JFf4XtN7Zpw=="], - "conventional-changelog-core": ["conventional-changelog-core@4.2.4", "", { "dependencies": { "add-stream": "^1.0.0", "conventional-changelog-writer": "^5.0.0", "conventional-commits-parser": "^3.2.0", "dateformat": "^3.0.0", "get-pkg-repo": "^4.0.0", "git-raw-commits": "^2.0.8", "git-remote-origin-url": "^2.0.0", "git-semver-tags": "^4.1.1", "lodash": "^4.17.15", "normalize-package-data": "^3.0.0", "q": "^1.5.1", "read-pkg": "^3.0.0", "read-pkg-up": "^3.0.0", "through2": "^4.0.0" } }, "sha512-gDVS+zVJHE2v4SLc6B0sLsPiloR0ygU7HaDW14aNJE1v4SlqJPILPl/aJC7YdtRE4CybBf8gDwObBvKha8Xlyg=="], + "conventional-changelog-core": ["conventional-changelog-core@5.0.2", "", { "dependencies": { "add-stream": "^1.0.0", "conventional-changelog-writer": "^6.0.0", "conventional-commits-parser": "^4.0.0", "dateformat": "^3.0.3", "get-pkg-repo": "^4.2.1", "git-raw-commits": "^3.0.0", "git-remote-origin-url": "^2.0.0", "git-semver-tags": "^5.0.0", "normalize-package-data": "^3.0.3", "read-pkg": "^3.0.0", "read-pkg-up": "^3.0.0" } }, "sha512-RhQOcDweXNWvlRwUDCpaqXzbZemKPKncCWZG50Alth72WITVd6nhVk9MJ6w1k9PFNBcZ3YwkdkChE+8+ZwtUug=="], - "conventional-changelog-ember": ["conventional-changelog-ember@2.0.9", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-ulzIReoZEvZCBDhcNYfDIsLTHzYHc7awh+eI44ZtV5cx6LVxLlVtEmcO+2/kGIHGtw+qVabJYjdI5cJOQgXh1A=="], + "conventional-changelog-ember": ["conventional-changelog-ember@3.0.0", "", {}, "sha512-7PYthCoSxIS98vWhVcSphMYM322OxptpKAuHYdVspryI0ooLDehRXWeRWgN+zWSBXKl/pwdgAg8IpLNSM1/61A=="], - "conventional-changelog-eslint": ["conventional-changelog-eslint@3.0.9", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-6NpUCMgU8qmWmyAMSZO5NrRd7rTgErjrm4VASam2u5jrZS0n38V7Y9CzTtLT2qwz5xEChDR4BduoWIr8TfwvXA=="], + "conventional-changelog-eslint": ["conventional-changelog-eslint@4.0.0", "", {}, "sha512-nEZ9byP89hIU0dMx37JXQkE1IpMmqKtsaR24X7aM3L6Yy/uAtbb+ogqthuNYJkeO1HyvK7JsX84z8649hvp43Q=="], - "conventional-changelog-express": ["conventional-changelog-express@2.0.6", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-SDez2f3iVJw6V563O3pRtNwXtQaSmEfTCaTBPCqn0oG0mfkq0rX4hHBq5P7De2MncoRixrALj3u3oQsNK+Q0pQ=="], + "conventional-changelog-express": ["conventional-changelog-express@3.0.0", "", {}, "sha512-HqxihpUMfIuxvlPvC6HltA4ZktQEUan/v3XQ77+/zbu8No/fqK3rxSZaYeHYant7zRxQNIIli7S+qLS9tX9zQA=="], - "conventional-changelog-jquery": ["conventional-changelog-jquery@3.0.11", "", { "dependencies": { "q": "^1.5.1" } }, "sha512-x8AWz5/Td55F7+o/9LQ6cQIPwrCjfJQ5Zmfqi8thwUEKHstEn4kTIofXub7plf1xvFA2TqhZlq7fy5OmV6BOMw=="], + "conventional-changelog-jquery": ["conventional-changelog-jquery@4.0.0", "", {}, "sha512-TTIN5CyzRMf8PUwyy4IOLmLV2DFmPtasKN+x7EQKzwSX8086XYwo+NeaeA3VUT8bvKaIy5z/JoWUvi7huUOgaw=="], - "conventional-changelog-jshint": ["conventional-changelog-jshint@2.0.9", "", { "dependencies": { "compare-func": "^2.0.0", "q": "^1.5.1" } }, "sha512-wMLdaIzq6TNnMHMy31hql02OEQ8nCQfExw1SE0hYL5KvU+JCTuPaDO+7JiogGT2gJAxiUGATdtYYfh+nT+6riA=="], + "conventional-changelog-jshint": ["conventional-changelog-jshint@3.0.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-bQof4byF4q+n+dwFRkJ/jGf9dCNUv4/kCDcjeCizBvfF81TeimPZBB6fT4HYbXgxxfxWXNl/i+J6T0nI4by6DA=="], - "conventional-changelog-preset-loader": ["conventional-changelog-preset-loader@2.3.4", "", {}, "sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g=="], + "conventional-changelog-preset-loader": ["conventional-changelog-preset-loader@3.0.0", "", {}, "sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA=="], - "conventional-changelog-writer": ["conventional-changelog-writer@5.0.1", "", { "dependencies": { "conventional-commits-filter": "^2.0.7", "dateformat": "^3.0.0", "handlebars": "^4.7.7", "json-stringify-safe": "^5.0.1", "lodash": "^4.17.15", "meow": "^8.0.0", "semver": "^6.0.0", "split": "^1.0.0", "through2": "^4.0.0" }, "bin": { "conventional-changelog-writer": "cli.js" } }, "sha512-5WsuKUfxW7suLblAbFnxAcrvf6r+0b7GvNaWUwUIk0bXMnENP/PEieGKVUQrjPqwPT4o3EPAASBXiY6iHooLOQ=="], + "conventional-changelog-writer": ["conventional-changelog-writer@6.0.1", "", { "dependencies": { "conventional-commits-filter": "^3.0.0", "dateformat": "^3.0.3", "handlebars": "^4.7.7", "json-stringify-safe": "^5.0.1", "meow": "^8.1.2", "semver": "^7.0.0", "split": "^1.0.1" }, "bin": { "conventional-changelog-writer": "cli.js" } }, "sha512-359t9aHorPw+U+nHzUXHS5ZnPBOizRxfQsWT5ZDHBfvfxQOAik+yfuhKXG66CN5LEWPpMNnIMHUTCKeYNprvHQ=="], - "conventional-commits-filter": ["conventional-commits-filter@2.0.7", "", { "dependencies": { "lodash.ismatch": "^4.4.0", "modify-values": "^1.0.0" } }, "sha512-ASS9SamOP4TbCClsRHxIHXRfcGCnIoQqkvAzCSbZzTFLfcTqJVugB0agRgsEELsqaeWgsXv513eS116wnlSSPA=="], + "conventional-commits-filter": ["conventional-commits-filter@3.0.0", "", { "dependencies": { "lodash.ismatch": "^4.4.0", "modify-values": "^1.0.1" } }, "sha512-1ymej8b5LouPx9Ox0Dw/qAO2dVdfpRFq28e5Y0jJEU8ZrLdy0vOSkkIInwmxErFGhg6SALro60ZrwYFVTUDo4Q=="], - "conventional-commits-parser": ["conventional-commits-parser@3.2.4", "", { "dependencies": { "JSONStream": "^1.0.4", "is-text-path": "^1.0.1", "lodash": "^4.17.15", "meow": "^8.0.0", "split2": "^3.0.0", "through2": "^4.0.0" }, "bin": { "conventional-commits-parser": "cli.js" } }, "sha512-nK7sAtfi+QXbxHCYfhpZsfRtaitZLIA6889kFIouLvz6repszQDgxBu7wf2WbU+Dco7sAnNCJYERCwt54WPC2Q=="], + "conventional-commits-parser": ["conventional-commits-parser@4.0.0", "", { "dependencies": { "JSONStream": "^1.3.5", "is-text-path": "^1.0.1", "meow": "^8.1.2", "split2": "^3.2.2" }, "bin": { "conventional-commits-parser": "cli.js" } }, "sha512-WRv5j1FsVM5FISJkoYMR6tPk07fkKT0UodruX4je86V4owk451yjXAKzKAPOs9l7y59E2viHUS9eQ+dfUA9NSg=="], - "conventional-recommended-bump": ["conventional-recommended-bump@6.1.0", "", { "dependencies": { "concat-stream": "^2.0.0", "conventional-changelog-preset-loader": "^2.3.4", "conventional-commits-filter": "^2.0.7", "conventional-commits-parser": "^3.2.0", "git-raw-commits": "^2.0.8", "git-semver-tags": "^4.1.1", "meow": "^8.0.0", "q": "^1.5.1" }, "bin": { "conventional-recommended-bump": "cli.js" } }, "sha512-uiApbSiNGM/kkdL9GTOLAqC4hbptObFo4wW2QRyHsKciGAfQuLU1ShZ1BIVI/+K2BE/W1AWYQMCXAsv4dyKPaw=="], + "conventional-recommended-bump": ["conventional-recommended-bump@7.0.1", "", { "dependencies": { "concat-stream": "^2.0.0", "conventional-changelog-preset-loader": "^3.0.0", "conventional-commits-filter": "^3.0.0", "conventional-commits-parser": "^4.0.0", "git-raw-commits": "^3.0.0", "git-semver-tags": "^5.0.0", "meow": "^8.1.2" }, "bin": { "conventional-recommended-bump": "cli.js" } }, "sha512-Ft79FF4SlOFvX4PkwFDRnaNiIVX7YbmqGU0RwccUaiGvgp3S0a8ipR2/Qxk31vclDNM+GSdJOVs2KrsUCjblVA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], - "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -828,15 +918,19 @@ "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], - "css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "daisyui": ["daisyui@5.5.14", "", {}, "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg=="], + "cycle": ["cycle@1.0.3", "", {}, "sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA=="], + + "daisyui": ["daisyui@5.5.19", "", {}, "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA=="], "dargs": ["dargs@7.0.0", "", {}, "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg=="], @@ -852,6 +946,8 @@ "decamelize-keys": ["decamelize-keys@1.1.1", "", { "dependencies": { "decamelize": "^1.1.0", "map-obj": "^1.0.0" } }, "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -862,6 +958,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], @@ -870,6 +968,8 @@ "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], @@ -886,11 +986,9 @@ "dotgitignore": ["dotgitignore@2.1.0", "", { "dependencies": { "find-up": "^3.0.0", "minimatch": "^3.0.4" } }, "sha512-sCm11ak2oY6DglEPpCB8TixLjWAxd3kJTs6UIcSasNYxXdFPV+YKlye92c8H4kKFqV5qYMIh7d+cYecEg0dIkA=="], - "drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], - "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], - - "dts-bundle-generator": ["dts-bundle-generator@9.5.1", "", { "dependencies": { "typescript": ">=5.0.2", "yargs": "^17.6.0" }, "bin": { "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" } }, "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA=="], + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -902,7 +1000,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.277", "", {}, "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw=="], - "elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="], + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -912,7 +1010,7 @@ "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], - "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -930,35 +1028,33 @@ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - - "exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="], + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "eyes": ["eyes@0.1.8", "", {}, "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fast-xml-builder": ["fast-xml-builder@1.1.9", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-jcyKVSEX13iseJqg7n/KWw+xnu/7fdrZ333Fac54KjHDIELVCfDDJXYIm6DTJ0Su4gSzrhqiK0DzY/wZbF40mw=="], + + "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -966,7 +1062,7 @@ "figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="], - "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], + "file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -980,7 +1076,7 @@ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1004,15 +1100,15 @@ "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], - "git-raw-commits": ["git-raw-commits@2.0.11", "", { "dependencies": { "dargs": "^7.0.0", "lodash": "^4.17.15", "meow": "^8.0.0", "split2": "^3.0.0", "through2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.js" } }, "sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A=="], + "git-raw-commits": ["git-raw-commits@3.0.0", "", { "dependencies": { "dargs": "^7.0.0", "meow": "^8.1.2", "split2": "^3.2.2" }, "bin": { "git-raw-commits": "cli.js" } }, "sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw=="], "git-remote-origin-url": ["git-remote-origin-url@2.0.0", "", { "dependencies": { "gitconfiglocal": "^1.0.0", "pify": "^2.3.0" } }, "sha512-eU+GGrZgccNJcsDH5LkXR3PB9M958hxc7sbA8DFJjrv9j4L2P/eZfKhM+QD6wyzpiv+b1BpK0XrYCxkovtjSLw=="], - "git-semver-tags": ["git-semver-tags@4.1.1", "", { "dependencies": { "meow": "^8.0.0", "semver": "^6.0.0" }, "bin": { "git-semver-tags": "cli.js" } }, "sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA=="], + "git-semver-tags": ["git-semver-tags@5.0.1", "", { "dependencies": { "meow": "^8.1.2", "semver": "^7.0.0" }, "bin": { "git-semver-tags": "cli.js" } }, "sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA=="], "gitconfiglocal": ["gitconfiglocal@1.0.0", "", { "dependencies": { "ini": "^1.3.2" } }, "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ=="], - "glob": ["glob@10.3.3", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.0.3", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": { "glob": "dist/cjs/src/bin.js" } }, "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw=="], + "glob": ["glob@6.0.4", "", { "dependencies": { "inflight": "^1.0.4", "inherits": "2", "minimatch": "2 || 3", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1026,7 +1122,7 @@ "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], - "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], "hard-rejection": ["hard-rejection@2.1.0", "", {}, "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA=="], @@ -1038,12 +1134,20 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="], + "html-encoding-sniffer": ["html-encoding-sniffer@3.0.0", "", { "dependencies": { "whatwg-encoding": "^2.0.0" } }, "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="], @@ -1052,23 +1156,35 @@ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], - "immutable": ["immutable@5.1.4", "", {}, "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA=="], + "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], @@ -1078,6 +1194,8 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], @@ -1086,7 +1204,7 @@ "is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], - "is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-text-path": ["is-text-path@1.0.1", "", { "dependencies": { "text-extensions": "^1.0.0" } }, "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w=="], @@ -1098,9 +1216,11 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isstream": ["isstream@0.1.2", "", {}, "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="], + "jackspeak": ["jackspeak@2.3.6", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ=="], - "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + "jimp": ["jimp@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/diff": "1.6.1", "@jimp/js-bmp": "1.6.1", "@jimp/js-gif": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/js-tiff": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/plugin-blur": "1.6.1", "@jimp/plugin-circle": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-contain": "1.6.1", "@jimp/plugin-cover": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-displace": "1.6.1", "@jimp/plugin-dither": "1.6.1", "@jimp/plugin-fisheye": "1.6.1", "@jimp/plugin-flip": "1.6.1", "@jimp/plugin-hash": "1.6.1", "@jimp/plugin-mask": "1.6.1", "@jimp/plugin-print": "1.6.1", "@jimp/plugin-quantize": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/plugin-rotate": "1.6.1", "@jimp/plugin-threshold": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1" } }, "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1132,29 +1252,29 @@ "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], - "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -1164,6 +1284,8 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], "lodash.defaultsdeep": ["lodash.defaultsdeep@4.6.1", "", {}, "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA=="], @@ -1178,6 +1300,8 @@ "lodash.negate": ["lodash.negate@3.0.2", "", {}, "sha512-JGJYYVslKYC0tRMm/7igfdHulCjoXjoganRNWM8AgS+RXfOvFnPkOveDhPI65F9aAypCX9QEEQoBqWf7Q6uAeA=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -1190,15 +1314,69 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], "meow": ["meow@8.1.2", "", { "dependencies": { "@types/minimist": "^1.2.0", "camelcase-keys": "^6.2.2", "decamelize-keys": "^1.1.0", "hard-rejection": "^2.1.0", "minimist-options": "4.1.0", "normalize-package-data": "^3.0.0", "read-pkg-up": "^7.0.1", "redent": "^3.0.0", "trim-newlines": "^3.0.0", "type-fest": "^0.18.0", "yargs-parser": "^20.2.3" } }, "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q=="], - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], @@ -1242,22 +1420,24 @@ "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - "node-downloader-helper": ["node-downloader-helper@2.1.10", "", { "bin": { "ndh": "bin/ndh" } }, "sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg=="], + "node-downloader-helper": ["node-downloader-helper@2.1.11", "", { "bin": { "ndh": "bin/ndh" } }, "sha512-882fH2C9AWdiPCwz/2beq5t8FGMZK9Dx8TJUOIxzMCbvG7XUKM5BuJwN5f0NKo4SCQK6jR4p2TPm54mYGdGchQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], - "node-html-parser": ["node-html-parser@7.0.2", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ=="], - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-stream-zip": ["node-stream-zip@1.15.0", "", {}, "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="], + "node-unrar-js": ["node-unrar-js@2.0.2", "", {}, "sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w=="], + "normalize-package-data": ["normalize-package-data@3.0.3", "", { "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", "semver": "^7.3.4", "validate-npm-package-license": "^3.0.1" } }, "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "npm-check-updates": ["npm-check-updates@22.2.0", "", { "bin": { "npm-check-updates": "build/cli.js", "ncu": "build/cli.js" } }, "sha512-kaxgbkGkCOtoSrsUXShgcEiEfrRPqmOGk6Yeya+5hoNptblu9vuE8/PLABUSJz+IeNgKJBFxcC3UrBYmKsB8iA=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], "nypm": ["nypm@0.6.4", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw=="], @@ -1272,16 +1452,24 @@ "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "opener": ["opener@1.5.2", "", { "bin": { "opener": "bin/opener-bin.js" } }, "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A=="], + "optimist": ["optimist@0.6.1", "", { "dependencies": { "minimist": "~0.0.1", "wordwrap": "~0.0.2" } }, "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-queue": ["p-queue@9.2.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g=="], + + "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], @@ -1292,8 +1480,12 @@ "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -1302,6 +1494,10 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -1312,8 +1508,6 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], - "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1326,12 +1520,16 @@ "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + "pkginfo": ["pkginfo@0.3.1", "", {}, "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "portfinder": ["portfinder@1.0.38", "", { "dependencies": { "async": "^3.2.6", "debug": "^4.3.6" } }, "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], "preact": ["preact@10.11.3", "", {}, "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="], @@ -1344,37 +1542,37 @@ "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], - "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "qr.js": ["qr.js@0.0.0", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="], "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="], "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], - "react-error-boundary": ["react-error-boundary@6.1.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-02k9WQ/mUhdbXir0tC1NiMesGzRPaCsJEWU/4bcFrbY1YMZOtHShtZP6zw0SJrBWA/31H0KT9/FgdL8+sPKgHA=="], + "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "react-qr-code": ["react-qr-code@2.0.18", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "react-qr-code": ["react-qr-code@2.0.21", "", { "dependencies": { "prop-types": "^15.8.1", "qr.js": "0.0.0" }, "peerDependencies": { "react": "*" } }, "sha512-xaywjo0eaF4S3LOz6ns5eoPbM2E+q9HYl4VATYpxK4bBniOhQ9noY2RJ9G4SnZFhUwzx63FUT6KdHzfKgUwyuQ=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -1384,79 +1582,75 @@ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], - "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], - "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], - "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="], "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sass": ["sass@1.97.3", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg=="], + "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="], - "sass-embedded": ["sass-embedded@1.97.3", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", "immutable": "^5.0.2", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-all-unknown": "1.97.3", "sass-embedded-android-arm": "1.97.3", "sass-embedded-android-arm64": "1.97.3", "sass-embedded-android-riscv64": "1.97.3", "sass-embedded-android-x64": "1.97.3", "sass-embedded-darwin-arm64": "1.97.3", "sass-embedded-darwin-x64": "1.97.3", "sass-embedded-linux-arm": "1.97.3", "sass-embedded-linux-arm64": "1.97.3", "sass-embedded-linux-musl-arm": "1.97.3", "sass-embedded-linux-musl-arm64": "1.97.3", "sass-embedded-linux-musl-riscv64": "1.97.3", "sass-embedded-linux-musl-x64": "1.97.3", "sass-embedded-linux-riscv64": "1.97.3", "sass-embedded-linux-x64": "1.97.3", "sass-embedded-unknown-all": "1.97.3", "sass-embedded-win32-arm64": "1.97.3", "sass-embedded-win32-x64": "1.97.3" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-eKzFy13Nk+IRHhlAwP3sfuv+PzOrvzUkwJK2hdoCKYcWGSdmwFpeGpWmyewdw8EgBnsKaSBtgf/0b2K635ecSA=="], + "sass-embedded": ["sass-embedded@1.99.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", "immutable": "^5.1.5", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-all-unknown": "1.99.0", "sass-embedded-android-arm": "1.99.0", "sass-embedded-android-arm64": "1.99.0", "sass-embedded-android-riscv64": "1.99.0", "sass-embedded-android-x64": "1.99.0", "sass-embedded-darwin-arm64": "1.99.0", "sass-embedded-darwin-x64": "1.99.0", "sass-embedded-linux-arm": "1.99.0", "sass-embedded-linux-arm64": "1.99.0", "sass-embedded-linux-musl-arm": "1.99.0", "sass-embedded-linux-musl-arm64": "1.99.0", "sass-embedded-linux-musl-riscv64": "1.99.0", "sass-embedded-linux-musl-x64": "1.99.0", "sass-embedded-linux-riscv64": "1.99.0", "sass-embedded-linux-x64": "1.99.0", "sass-embedded-unknown-all": "1.99.0", "sass-embedded-win32-arm64": "1.99.0", "sass-embedded-win32-x64": "1.99.0" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg=="], - "sass-embedded-all-unknown": ["sass-embedded-all-unknown@1.97.3", "", { "dependencies": { "sass": "1.97.3" }, "cpu": [ "!arm", "!x64", "!arm64", ] }, "sha512-t6N46NlPuXiY3rlmG6/+1nwebOBOaLFOOVqNQOC2cJhghOD4hh2kHNQQTorCsbY9S1Kir2la1/XLBwOJfui0xg=="], + "sass-embedded-all-unknown": ["sass-embedded-all-unknown@1.99.0", "", { "dependencies": { "sass": "1.99.0" }, "cpu": [ "!arm", "!x64", "!arm64", ] }, "sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw=="], - "sass-embedded-android-arm": ["sass-embedded-android-arm@1.97.3", "", { "os": "android", "cpu": "arm" }, "sha512-cRTtf/KV/q0nzGZoUzVkeIVVFv3L/tS1w4WnlHapphsjTXF/duTxI8JOU1c/9GhRPiMdfeXH7vYNcMmtjwX7jg=="], + "sass-embedded-android-arm": ["sass-embedded-android-arm@1.99.0", "", { "os": "android", "cpu": "arm" }, "sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ=="], - "sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.97.3", "", { "os": "android", "cpu": "arm64" }, "sha512-aiZ6iqiHsUsaDx0EFbbmmA0QgxicSxVVN3lnJJ0f1RStY0DthUkquGT5RJ4TPdaZ6ebeJWkboV4bra+CP766eA=="], + "sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.99.0", "", { "os": "android", "cpu": "arm64" }, "sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg=="], - "sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.97.3", "", { "os": "android", "cpu": "none" }, "sha512-zVEDgl9JJodofGHobaM/q6pNETG69uuBIGQHRo789jloESxxZe82lI3AWJQuPmYCOG5ElfRthqgv89h3gTeLYA=="], + "sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.99.0", "", { "os": "android", "cpu": "none" }, "sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw=="], - "sass-embedded-android-x64": ["sass-embedded-android-x64@1.97.3", "", { "os": "android", "cpu": "x64" }, "sha512-3ke0le7ZKepyXn/dKKspYkpBC0zUk/BMciyP5ajQUDy4qJwobd8zXdAq6kOkdiMB+d9UFJOmEkvgFJHl3lqwcw=="], + "sass-embedded-android-x64": ["sass-embedded-android-x64@1.99.0", "", { "os": "android", "cpu": "x64" }, "sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ=="], - "sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.97.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fuqMTqO4gbOmA/kC5b9y9xxNYw6zDEyfOtMgabS7Mz93wimSk2M1quQaTJnL98Mkcsl2j+7shNHxIS/qpcIDDA=="], + "sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.99.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg=="], - "sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.97.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/2RBs/2bZpP8lMkyZ0Px0vkVkT8uBd0YXpOwK7iOwYkAT8SsO4+WdVwErsqC65vI5e1e5p1bb20tuwsoQBMVA=="], + "sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.99.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A=="], - "sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.97.3", "", { "os": "linux", "cpu": "arm" }, "sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA=="], + "sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.99.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw=="], - "sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.97.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-IP1+2otCT3DuV46ooxPaOKV1oL5rLjteRzf8ldZtfIEcwhSgSsHgA71CbjYgLEwMY9h4jeal8Jfv3QnedPvSjg=="], + "sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.99.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew=="], - "sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.97.3", "", { "os": "linux", "cpu": "arm" }, "sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg=="], + "sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.99.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ=="], - "sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.97.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw=="], + "sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.99.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw=="], - "sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.97.3", "", { "os": "linux", "cpu": "none" }, "sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA=="], + "sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.99.0", "", { "os": "linux", "cpu": "none" }, "sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg=="], - "sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.97.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw=="], + "sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.99.0", "", { "os": "linux", "cpu": "x64" }, "sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew=="], - "sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.97.3", "", { "os": "linux", "cpu": "none" }, "sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA=="], + "sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.99.0", "", { "os": "linux", "cpu": "none" }, "sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA=="], - "sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.97.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg=="], + "sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.99.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA=="], - "sass-embedded-unknown-all": ["sass-embedded-unknown-all@1.97.3", "", { "dependencies": { "sass": "1.97.3" }, "os": [ "!linux", "!win32", "!darwin", "!android", ] }, "sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q=="], + "sass-embedded-unknown-all": ["sass-embedded-unknown-all@1.99.0", "", { "dependencies": { "sass": "1.99.0" }, "os": [ "!linux", "!win32", "!darwin", "!android", ] }, "sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg=="], - "sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.97.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-RDGtRS1GVvQfMGAmVXNxYiUOvPzn9oO1zYB/XUM9fudDRnieYTcUytpNTQZLs6Y1KfJxgt5Y+giRceC92fT8Uw=="], + "sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.99.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw=="], - "sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.97.3", "", { "os": "win32", "cpu": "x64" }, "sha512-SFRa2lED9UEwV6vIGeBXeBOLKF+rowF3WmNfb/BzhxmdAsKofCXrJ8ePW7OcDVrvNEbTOGwhsReIsF5sH8fVaw=="], + "sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.99.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg=="], - "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1464,9 +1658,9 @@ "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "seroval": ["seroval@1.4.2", "", {}, "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ=="], + "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], - "seroval-plugins": ["seroval-plugins@1.4.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA=="], + "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1486,18 +1680,24 @@ "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], + + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="], "socket.io-adapter": ["socket.io-adapter@2.5.6", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" } }, "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ=="], "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="], "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], @@ -1510,7 +1710,7 @@ "split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="], - "standard-version": ["standard-version@9.5.0", "", { "dependencies": { "chalk": "^2.4.2", "conventional-changelog": "3.1.25", "conventional-changelog-config-spec": "2.1.0", "conventional-changelog-conventionalcommits": "4.6.3", "conventional-recommended-bump": "6.1.0", "detect-indent": "^6.0.0", "detect-newline": "^3.1.0", "dotgitignore": "^2.1.0", "figures": "^3.1.0", "find-up": "^5.0.0", "git-semver-tags": "^4.0.0", "semver": "^7.1.1", "stringify-package": "^1.0.1", "yargs": "^16.0.0" }, "bin": { "standard-version": "bin/cli.js" } }, "sha512-3zWJ/mmZQsOaO+fOlsa0+QK90pwhNd042qEcw6hKFNoLFs7peGyvPffpEBbK/DSGPbyOvli0mUIFv5A4qTjh2Q=="], + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1518,7 +1718,7 @@ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "stringify-package": ["stringify-package@1.0.1", "", {}, "sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1528,45 +1728,49 @@ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="], + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "svgo": ["svgo@3.3.2", "", { "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0" }, "bin": "./bin/svgo" }, "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw=="], + "svg-icon-baker": ["svg-icon-baker@2.0.1", "", { "dependencies": { "css-tree": "^3.2.1", "fast-xml-builder": "^1.1.5", "fast-xml-parser": "^5.7.2", "svgo": "^4.0.1" } }, "sha512-1QWVlle2fSUra129CEKpo5sn4hLGa0KCd7v8kX+PkD8e1x8fAQXB5rkc8J8mXTHe6iSzcOS7j4Y2K+q9RaUoNQ=="], + + "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], "sync-child-process": ["sync-child-process@1.0.2", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="], "sync-message-port": ["sync-message-port@1.2.0", "", {}, "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg=="], - "systeminformation": ["systeminformation@5.31.5", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ=="], + "systeminformation": ["systeminformation@5.31.6", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], - "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], + "terser": ["terser@5.36.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w=="], "text-extensions": ["text-extensions@1.9.0", "", {}, "sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ=="], "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], - "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], - - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - - "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + "through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="], "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], @@ -1582,7 +1786,7 @@ "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], - "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], "tough-cookie-file-store": ["tough-cookie-file-store@3.3.0", "", { "dependencies": { "tough-cookie": "^6.0.0" } }, "sha512-FbO/cOi/jp4wweo8soVNG/ZjDsgpBZWqaxWwu7gRKvsjg/Qt44kStp87VLfJnin749DlTbZDYvV1wuSr5jly2g=="], @@ -1590,11 +1794,11 @@ "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "trim-newlines": ["trim-newlines@3.0.1", "", {}, "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw=="], - "ts-apicalypse": ["ts-apicalypse@0.4.2", "", { "dependencies": { "axios": "^1.4.0" } }, "sha512-A02KFDFZHYTft0fTkxr5AERLb//XK/qjvGxrX8uNCwZAvFpLI/4TrOVxbq6kV2caZXWAn8eZZfRzVn1xd/cSMw=="], - - "ts-igdb-client": ["ts-igdb-client@0.4.2", "", { "dependencies": { "axios": "^1.4.0", "ts-apicalypse": "^0.4.2" } }, "sha512-VGQbIyy75GbHW0WGGHBXsdJzuj8wOW/jiWURmQWltpkFjCTMSqqx9gTPSUUcJ0W2kdlWcyMU4TDDHo4N3Fij7A=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], @@ -1612,15 +1816,29 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "underscore": ["underscore@1.8.3", "", {}, "sha512-5WsVTFcH1ut/kkhAaHf4PVgI8c7++GiVcpCGxPouI6ZVjsqPnSDf8h/8HtVqc0t4fzRXwnMK70EcZeAs3PIddg=="], + "undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + "union": ["union@0.5.0", "", { "dependencies": { "qs": "^6.4.0" } }, "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], "unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="], @@ -1642,9 +1860,13 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], - "vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.5.2", "", { "dependencies": { "fast-glob": "^3.3.3", "fs-extra": "^11.3.2", "node-html-parser": "^7.0.1", "svgo": "^3.3.2" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-A68obs8XDT+q8q8dKyjrT/v0qw8h5pEBKXJ27aUXjARYeJ6MNvhIhRLLiUwnSrbn/B4TBF4UVaWRXKftAqP7+A=="], + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], + + "vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.9.1", "", { "dependencies": { "svg-icon-baker": "2.0.1", "tinyglobby": "^0.2.16" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-g00nlit2havo0VRxpLiPkeJfMYt0DL/RO8X5HHop72rbMEZB5H1Fk7qXLWbTIO2/PkwJ8zSq0+h28ItaE1YQHQ=="], "vite-static-assets-plugin": ["vite-static-assets-plugin@1.2.2", "", { "dependencies": { "chalk": "^5.4.1", "chokidar": "^3.5.3", "minimatch": "^10.0.1" }, "peerDependencies": { "typescript": "^5.0.0", "vite": "^6.2.0" } }, "sha512-0mzHsxFa46Np5AixQcdWYLVH6eJxeok7qL7tXmxYavg/Uo0e5z+J6gavJ0TJ6dmJSe2Z+gwmDb64bCCZfg+gqA=="], @@ -1666,12 +1888,16 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "winston": ["winston@1.0.2", "", { "dependencies": { "async": "~1.0.0", "colors": "1.0.x", "cycle": "1.0.x", "eyes": "0.1.x", "isstream": "0.1.x", "pkginfo": "0.3.x", "stack-trace": "0.0.x" } }, "sha512-BLxJH3KCgJ2paj2xKYTQLpxdKr9URPDDDLJnRVcbud7izT+m8Xzt5Rod6mnNgEcfT0fRvhEy2Cj3cEnnQpa6qA=="], + + "wordwrap": ["wordwrap@0.0.3", "", {}, "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], @@ -1688,18 +1914,42 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@ap0nia/eden/elysia": ["elysia@1.2.15", "", { "dependencies": { "@sinclair/typebox": "^0.34.15", "cookie": "^1.0.2", "memoirist": "^0.3.0", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-/oUSNb83jIWAGi6uSmbQ7Uy0RSJ9NimbVToSLnYS8jjsGId3zgdHqprsdf4rIMInOmEM8skjsFhZ4x8C5AB6+w=="], + + "@babel/core/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/core/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/parser/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@babel/template/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "@babel/template/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + + "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "@babel/traverse/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + + "@babel/traverse/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -1708,8 +1958,6 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@jimp/core/file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], - "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1746,41 +1994,53 @@ "@jimp/wasm-webp/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@node-minify/core/glob": ["glob@10.3.3", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.0.3", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": { "glob": "dist/cjs/src/bin.js" } }, "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw=="], + "@node-minify/core/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], - "@node-minify/terser/terser": ["terser@5.36.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w=="], + "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@tanstack/router-generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@tanstack/router-utils/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@tanstack/router-utils/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "@types/babel__core/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "babel-dead-code-elimination/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + + "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], "compare-func/dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], - "conventional-changelog-writer/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "concurrently/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], @@ -1794,17 +2054,13 @@ "engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "get-pkg-repo/through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="], - "get-pkg-repo/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], - "git-semver-tags/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "gitconfiglocal/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "handlebars/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "handlebars/wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -1812,8 +2068,14 @@ "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "http-proxy/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "http-server/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "is-core-module/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "load-json-file/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], "meow/read-pkg-up": ["read-pkg-up@7.0.1", "", { "dependencies": { "find-up": "^4.1.0", "read-pkg": "^5.2.0", "type-fest": "^0.8.1" } }, "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg=="], @@ -1822,10 +2084,14 @@ "meow/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "minimist-options/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], "nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], + "optimist/minimist": ["minimist@0.0.10", "", {}, "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1834,36 +2100,46 @@ "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "portfinder/async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + "read-pkg/normalize-package-data": ["normalize-package-data@2.5.0", "", { "dependencies": { "hosted-git-info": "^2.1.4", "resolve": "^1.10.0", "semver": "2 || 3 || 4 || 5", "validate-npm-package-license": "^3.0.1" } }, "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA=="], "read-pkg-up/find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="], - "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - - "standard-version/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], - - "standard-version/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], - "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "tough-cookie-file-store/tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "vite-plugin-svg-icons-ng/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "vite-static-assets-plugin/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "winston/async": ["async@1.0.0", "", {}, "sha512-5mO7DX4CbJzp9zjaFXusQQ4tzKJARjNB1Ih1pVBi8wkbmXy/xzIDgEMXxWePLzt2OdFwaxfneIlT1nCiXubrPQ=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + + "@ap0nia/eden/elysia/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "@ap0nia/eden/elysia/memoirist": ["memoirist@0.3.1", "", {}, "sha512-lmk4Z45IuVZPT67nxAdD3rAsNExxMEBFXgCeJGJnoLkYOjmZnJ8Hmi+MGdl9oLKtAENFAAgG8FvV3Z8BNiqy8w=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -1914,28 +2190,38 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@jimp/core/file-type/strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "@node-minify/core/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "@jimp/core/file-type/token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "@tanstack/router-utils/tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "@node-minify/terser/terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "babel-dead-code-elimination/@babel/core/@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "babel-dead-code-elimination/@babel/core/@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + + "babel-dead-code-elimination/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + + "concurrently/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "concurrently/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "dotgitignore/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], - "get-pkg-repo/through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "get-pkg-repo/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], "get-pkg-repo/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], - "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "http-server/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "http-server/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "meow/read-pkg-up/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "meow/read-pkg-up/read-pkg": ["read-pkg@5.2.0", "", { "dependencies": { "@types/normalize-package-data": "^2.4.0", "normalize-package-data": "^2.5.0", "parse-json": "^5.0.0", "type-fest": "^0.6.0" } }, "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg=="], @@ -1950,13 +2236,7 @@ "sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "standard-version/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], - - "standard-version/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - - "standard-version/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], - - "standard-version/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "through2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], @@ -2010,6 +2290,8 @@ "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "vite-plugin-svg-icons-ng/tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], @@ -2062,11 +2344,19 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "@node-minify/core/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "concurrently/chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "dotgitignore/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "dotgitignore/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - "get-pkg-repo/through2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "http-server/chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "meow/read-pkg-up/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -2080,12 +2370,16 @@ "read-pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - "standard-version/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + "wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "standard-version/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concurrently/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "dotgitignore/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "http-server/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "meow/read-pkg-up/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "meow/read-pkg-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@2.8.9", "", {}, "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="], @@ -2094,8 +2388,6 @@ "read-pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="], - "standard-version/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], - "meow/read-pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "read-pkg-up/find-up/locate-path/p-locate/p-limit/p-try": ["p-try@1.0.0", "", {}, "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww=="], diff --git a/drizzle/0002_flowery_rocket_raccoon.sql b/drizzle/0002_flowery_rocket_raccoon.sql new file mode 100644 index 0000000..5f7942e --- /dev/null +++ b/drizzle/0002_flowery_rocket_raccoon.sql @@ -0,0 +1,31 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_games` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `source_id` text, + `source` text, + `igdb_id` integer, + `name` text, + `ra_id` integer, + `path_fs` text, + `main_glob` text, + `last_played` integer, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `metadata` text DEFAULT '{}' NOT NULL, + `slug` text, + `platform_id` integer NOT NULL, + `cover` blob, + `type` text, + `summary` text, + `version` text, + `version_source` text, + `version_system` text, + FOREIGN KEY (`platform_id`) REFERENCES `platforms`(`id`) ON UPDATE cascade ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", NULL, "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", NULL, NULL, NULL FROM `games`;--> statement-breakpoint +DROP TABLE `games`;--> statement-breakpoint +ALTER TABLE `__new_games` RENAME TO `games`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `games_igdb_id_unique` ON `games` (`igdb_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `games_ra_id_unique` ON `games` (`ra_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `games_slug_unique` ON `games` (`slug`); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..fb182f2 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,479 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "40569ae5-facd-4680-bd48-fe70c5abf498", + "prevId": "6dc00b41-64d7-4cb3-ad90-f9e8e35af643", + "tables": { + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections_games": { + "name": "collections_games", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "collections_games_collection_id_collections_id_fk": { + "name": "collections_games_collection_id_collections_id_fk", + "tableFrom": "collections_games", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "collections_games_game_id_games_id_fk": { + "name": "collections_games_game_id_games_id_fk", + "tableFrom": "collections_games", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "games": { + "name": "games", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ra_id": { + "name": "ra_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path_fs": { + "name": "path_fs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "main_glob": { + "name": "main_glob", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_played": { + "name": "last_played", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform_id": { + "name": "platform_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cover": { + "name": "cover", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version_source": { + "name": "version_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version_system": { + "name": "version_system", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "games_igdb_id_unique": { + "name": "games_igdb_id_unique", + "columns": [ + "igdb_id" + ], + "isUnique": true + }, + "games_ra_id_unique": { + "name": "games_ra_id_unique", + "columns": [ + "ra_id" + ], + "isUnique": true + }, + "games_slug_unique": { + "name": "games_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "games_platform_id_platforms_id_fk": { + "name": "games_platform_id_platforms_id_fk", + "tableFrom": "games", + "tableTo": "platforms", + "columnsFrom": [ + "platform_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "platforms": { + "name": "platforms", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_slug": { + "name": "igdb_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moby_id": { + "name": "moby_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "es_slug": { + "name": "es_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ra_id": { + "name": "ra_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover": { + "name": "cover", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "platforms_igdb_id_unique": { + "name": "platforms_igdb_id_unique", + "columns": [ + "igdb_id" + ], + "isUnique": true + }, + "platforms_igdb_slug_unique": { + "name": "platforms_igdb_slug_unique", + "columns": [ + "igdb_slug" + ], + "isUnique": true + }, + "platforms_moby_id_unique": { + "name": "platforms_moby_id_unique", + "columns": [ + "moby_id" + ], + "isUnique": true + }, + "platforms_es_slug_unique": { + "name": "platforms_es_slug_unique", + "columns": [ + "es_slug" + ], + "isUnique": true + }, + "platforms_ra_id_unique": { + "name": "platforms_ra_id_unique", + "columns": [ + "ra_id" + ], + "isUnique": true + }, + "platforms_slug_unique": { + "name": "platforms_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "screenshots": { + "name": "screenshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "screenshots_game_id_games_id_fk": { + "name": "screenshots_game_id_games_id_fk", + "tableFrom": "screenshots", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c181729..0df44e3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1772998956867, "tag": "0001_outstanding_silk_fever", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1776111721964, + "tag": "0002_flowery_rocket_raccoon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 79ddcec..d770d67 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,26 @@ { "name": "com.simeonradivoev.gameflow-deck", "displayName": "Gameflow", - "version": "1.3.0", + "author": { + "name": "Simeon Radivoev", + "email": "work@simeonradivoev.com", + "url": "https://simeonradivoev.com" + }, + "version": "1.6.0", "description": "Game Launcher", "icon": "./src/mainview/assets/icon.svg", "main": "./src/bun/index.ts", "bin": "gameflow", + "license": "AGPL-3.0", "repository": { "type": "git", "url": "https://github.com/simeonradivoev/gameflow-deck" }, "packageManager": "bun@1.3.9", "type": "module", + "workspaces": [ + "./src/packages/gameflow-sdk" + ], "scripts": { "dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'", "dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'", @@ -21,8 +30,8 @@ "build:prod:vite": "NODE_ENV=production bun run build:vite", "build:dev:vite": "NODE_ENV=development bun run build:vite", "build": "bun run build:vite && bun run ./scripts/package-bun.ts", + "build:non-compiled": "bun run build:vite && NON_COMPILED=true bun run ./scripts/package-bun.ts", "build:prod": "NODE_ENV=production bun run build", - "build:prod:dynamic": "NODE_ENV=production NON_COMPILED=true bun run build", "build:linux": "TARGET=bun-linux-x64 bun run build", "openapi-ts": "bun run ./scripts/romm/openapi-ts.ts", "run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml", @@ -33,99 +42,116 @@ "flatpak:generate-sources": "bun run ./scripts/generate-flatpak-sources.ts", "flatpak:override": "flatpak override org.flatpak.Builder --filesystem=host --device=all", "flatpak:restore": "flatpak override --reset --user org.flatpak.Builder", - "flatpak:build": "flatpak run org.flatpak.Builder build/flatpak flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --force-clean", + "flatpak:build": "FLATPAK_BUILD=true NODE_ENV=production NON_COMPILED=true bun run build && flatpak run org.flatpak.Builder ../gameflow-flatpak/build/flatpak .config/flatpak/com.simeonradivoev.gameflow-deck.json --repo=.config/flatpak/repo --state-dir=../gameflow-flatpak/state --force-clean", "flatpak:install": "bun run flatpak:build && flatpak --user install --reinstall \"$PWD/.config/flatpak/repo\" com.simeonradivoev.gameflow-deck", "build:prod:appimage": "bun run build:prod && bun run ./scripts/build-appimage.ts", "build:dev:appimage": "bun run build && bun run ./scripts/build-appimage.ts", - "version:generate": "standard-version --sign", + "version:generate": "commit-and-tag-version --sign", "package:Linux": "bun run build:prod:appimage", "package:Windows": "bun run build:prod", - "download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium" + "download:chromium": "bun scripts/download-chromium.ts --out=./bin/chromium", + "download:nwjs": "bun scripts/download-nw.ts", + "build:audiosprites": "bun ./scripts/generate-audio-sprites.ts", + "tsc": "tsc --noEmit", + "publish:sdk": "bun publish --cwd ./src/packages/gameflow-sdk/ --access public" }, "dependencies": { "7zip-bin": "^5.2.0", "@auth/core": "^0.34.3", - "@elysiajs/cors": "^1.4.1", - "@elysiajs/eden": "^1.4.6", - "@jimp/wasm-webp": "^1.6.0", + "@elysiajs/cors": "^1.4.2", + "@elysiajs/eden": "^1.4.9", + "@jimp/wasm-webp": "^1.6.1", + "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", - "conf": "^15.0.2", - "drizzle-orm": "^0.45.1", - "elysia": "^1.4.22", - "fs-extra": "^11.3.3", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", "get-folder-size": "^5.0.0", "ini": "^6.0.0", - "jimp": "^1.6.0", + "jimp": "^1.6.1", "mustache": "^4.2.0", "node-7z": "^3.0.0", "node-disk-info": "^1.3.0", - "node-downloader-helper": "^2.1.10", + "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", + "npm-check-updates": "^22.2.0", "open": "^11.0.0", + "p-queue": "^9.2.0", "pathe": "^2.0.3", - "systeminformation": "^5.31.5", - "tapable": "^2.3.0", - "tough-cookie": "^6.0.0", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", + "systeminformation": "^5.31.6", + "tapable": "^2.3.3", + "tough-cookie": "^6.0.1", "tough-cookie-file-store": "^3.3.0", - "ts-igdb-client": "^0.4.2", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { - "@ap0nia/eden": "^1.0.0-next.22", + "@ap0nia/eden": "^1.6.1", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@emulatorjs/emulatorjs": "^4.2.3", - "@hey-api/openapi-ts": "^0.91.0", - "@noriginmedia/norigin-spatial-navigation": "^2.3.0", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-form": "^1.28.0", - "@tanstack/react-query": "^5.90.20", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router": "^1.157.16", - "@tanstack/react-router-devtools": "^1.154.12", - "@tanstack/react-router-ssr-query": "^1.157.17", - "@tanstack/router-plugin": "^1.157.16", - "@tanstack/zod-adapter": "^1.162.4", + "@hey-api/openapi-ts": "^0.91.1", + "@noriginmedia/norigin-spatial-navigation": "^3.1.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.3.0", + "@tanstack/react-form": "^1.32.0", + "@tanstack/react-query": "^5.100.10", + "@tanstack/react-query-devtools": "^5.100.10", + "@tanstack/react-query-persist-client": "^5.100.10", + "@tanstack/react-router": "^1.169.2", + "@tanstack/react-router-devtools": "^1.166.13", + "@tanstack/react-router-ssr-query": "^1.166.12", + "@tanstack/router-plugin": "^1.167.35", + "@tanstack/zod-adapter": "^1.166.9", "@types/adm-zip": "^0.5.8", + "@types/audiosprite": "^0.7.3", "@types/bun": "latest", "@types/fs-extra": "^11.0.4", "@types/howler": "^2.2.12", "@types/ini": "^4.1.1", + "@types/json-schema": "^7.0.15", "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", - "@types/react": "^19.2.9", + "@types/rclone.js": "^0.6.3", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", - "@vitejs/plugin-react": "^5.1.2", - "adm-zip": "^0.5.16", + "@vitejs/plugin-react": "^5.2.0", + "adm-zip": "^0.5.17", "animate.css": "^4.1.1", "app-builder-bin": "^5.0.0-alpha.13", + "audiosprite": "^0.7.2", "babel-plugin-react-compiler": "^1.0.0", "classnames": "^2.5.1", + "commit-and-tag-version": "^12.7.3", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "daisyui": "^5.5.14", - "drizzle-kit": "^0.31.9", - "dts-bundle-generator": "^9.5.1", + "daisyui": "^5.5.19", + "drizzle-kit": "^0.31.10", "eden-tanstack-query": "^0.0.9", "howler": "^2.2.4", + "idb-keyval": "^6.2.2", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-error-boundary": "^6.1.0", + "pretty-ms": "^9.3.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-error-boundary": "^6.1.1", "react-hot-toast": "^2.6.0", - "react-qr-code": "^2.0.18", - "sass-embedded": "^1.97.3", - "standard-version": "^9.5.0", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18", + "react-markdown": "^10.1.0", + "react-qr-code": "^2.0.21", + "sass-embedded": "^1.99.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", "usehooks-ts": "^3.1.1", - "vite": "^7.3.1", - "vite-plugin-svg-icons-ng": "^1.5.2", + "vite": "^7.3.3", + "vite-plugin-svg-icons-ng": "^1.9.1", "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1" } 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 ef3ad70..1331f36 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -2,50 +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"; +import { sleep } from "bun"; const events = new EventEmitter(); const abortController = new AbortController(); +let restarting = false; process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222"; process.env.NODE_ENV = "development"; -let retries = 0; - function spawnServer () { - 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: "inherit", - stdin: "pipe", + stdout: 'inherit', + stderr: 'inherit', + stdin: 'inherit', signal: abortController.signal, - killSignal: 'SIGUSR1', + killSignal: 'SIGKILL', + ipc (message, subprocess, handle) + { + if (message === 'focus') + { + events.emit('focus'); + } else if (message === 'exitapp') + { + events.emit('exitapp'); + } + }, onExit (subprocess, exitCode, signalCode) { - if (exitCode === 1 && retries <= 3) - { - server = spawnServer(); - retries++; - } else + if (!restarting) { + console.log("Existing Dev With", exitCode); process.exit(); } - - } - }); - const rl = createInterface({ input: Readable.fromWeb(s.stdout as any) }); - rl.on('line', e => - { - if (e === 'focus') - { - events.emit('focus'); - } else - { - console.log(e); } }); return s; @@ -56,9 +50,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) { @@ -66,13 +61,44 @@ function spawnBrowser () }; } -let server = spawnServer(); +async function restart () +{ + if (server) + { + restarting = true; + server.kill(); + await server.exited; + server = undefined; + console.log("Old Server stopped"); + } + + server = spawnServer(); + await sleep(1000); + console.log("New Server started"); + restarting = false; +} + +watch("./src/bun", { recursive: true }, (event, filename) => +{ + if (restarting) return; + console.log(`[watcher] ${event}: ${filename} — restarting...`); + restart(); +}); + +watch("./src/packages", { recursive: true }, (event, filename) => +{ + if (restarting) return; + console.log(`[watcher] ${event}: ${filename} — restarting...`); + restart(); +}); + +let server: Bun.Subprocess | undefined = spawnServer(); if (!process.env.HEADLESS) { spawnBrowser()?.then(async e => { - console.log("Sending exit Signal to server"); - await server.stdin.write('shutdown\n'); - await server.stdin.flush(); + if (!server) return; + abortController.abort(); + 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/scripts/drizzle/es-de/0000_sparkling_banshee.sql b/scripts/drizzle/es-de/0000_sparkling_banshee.sql new file mode 100644 index 0000000..c86dd0e --- /dev/null +++ b/scripts/drizzle/es-de/0000_sparkling_banshee.sql @@ -0,0 +1,34 @@ +CREATE TABLE `commands` ( + `system` text, + `label` text, + `command` text NOT NULL, + FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `emulators` ( + `name` text PRIMARY KEY NOT NULL, + `fullname` text, + `systempath` text DEFAULT (json_array()) NOT NULL, + `staticpath` text DEFAULT (json_array()) NOT NULL, + `corepath` text DEFAULT (json_array()) NOT NULL, + `androidpackage` text DEFAULT (json_array()) NOT NULL, + `winregistrypath` text DEFAULT (json_array()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `emulators_name_unique` ON `emulators` (`name`);--> statement-breakpoint +CREATE TABLE `systemMappings` ( + `source` text, + `sourceSlug` text, + `sourceId` integer, + `system` text NOT NULL, + FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `systems` ( + `name` text PRIMARY KEY NOT NULL, + `fullname` text, + `path` text, + `extension` text DEFAULT (json_array()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `systems_name_unique` ON `systems` (`name`); \ No newline at end of file diff --git a/scripts/drizzle/es-de/meta/0000_snapshot.json b/scripts/drizzle/es-de/meta/0000_snapshot.json new file mode 100644 index 0000000..5c40c18 --- /dev/null +++ b/scripts/drizzle/es-de/meta/0000_snapshot.json @@ -0,0 +1,234 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b4ee710f-eaa5-4bbb-9e69-13d490c7142c", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "commands": { + "name": "commands", + "columns": { + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "commands_system_systems_name_fk": { + "name": "commands_system_systems_name_fk", + "tableFrom": "commands", + "tableTo": "systems", + "columnsFrom": [ + "system" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "emulators": { + "name": "emulators", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "systempath": { + "name": "systempath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "staticpath": { + "name": "staticpath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "corepath": { + "name": "corepath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "androidpackage": { + "name": "androidpackage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "winregistrypath": { + "name": "winregistrypath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + } + }, + "indexes": { + "emulators_name_unique": { + "name": "emulators_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "systemMappings": { + "name": "systemMappings", + "columns": { + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceSlug": { + "name": "sourceSlug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceId": { + "name": "sourceId", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "systemMappings_system_systems_name_fk": { + "name": "systemMappings_system_systems_name_fk", + "tableFrom": "systemMappings", + "tableTo": "systems", + "columnsFrom": [ + "system" + ], + "columnsTo": [ + "name" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "systems": { + "name": "systems", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extension": { + "name": "extension", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + } + }, + "indexes": { + "systems_name_unique": { + "name": "systems_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/scripts/drizzle/es-de/meta/_journal.json b/scripts/drizzle/es-de/meta/_journal.json new file mode 100644 index 0000000..65dbc00 --- /dev/null +++ b/scripts/drizzle/es-de/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1776039605377, + "tag": "0000_sparkling_banshee", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/scripts/generate-audio-sprites.ts b/scripts/generate-audio-sprites.ts new file mode 100644 index 0000000..24b65df --- /dev/null +++ b/scripts/generate-audio-sprites.ts @@ -0,0 +1,30 @@ +import audioSprite from 'audiosprite'; +import path from 'node:path'; +import { soundMap } from '../src/mainview/scripts/audio/audioConstants'; + +var allFiles = await Array.fromAsync(new Bun.Glob('*.{ogg,wav}').scan({ cwd: './src/sounds' })); +const files = Object.values(soundMap).map(v => +{ + const existingFile = allFiles.find(f => f.startsWith(v.key)); + if (!existingFile) throw new Error(`Could not find file for sound ${v.key}`); + const filePath = path.join(path.resolve('./src/sounds'), existingFile); + return filePath; +}); +console.log("Loaded", files.join(",")); + +await new Promise((resolve) => +{ + audioSprite(files, + { + output: path.resolve('./src/mainview/assets/sounds'), + path: path.resolve('./src/sounds'), + format: 'howler', + export: 'ogg' + }, + async function (err, obj: any) + { + if (err) return console.error(err); + delete obj.urls; + Bun.file('./src/mainview/assets/sounds.json').write(JSON.stringify(obj, null, 2)).then(r => resolve(true)); + }); +}); \ No newline at end of file diff --git a/scripts/generate-es-de-mapping.ts b/scripts/generate-es-de-mapping.ts index 115c19f..8e8fa74 100644 --- a/scripts/generate-es-de-mapping.ts +++ b/scripts/generate-es-de-mapping.ts @@ -96,12 +96,18 @@ await Promise.all(platforms.map(async ([platform, arch]) => }); const rommMapping = rommPlatforms.data?.find(p => - p.slug === (customMappings as any)[name] || - p.slug === name || - p.igdb_slug === name || - p.hltb_slug === name || - p.moby_slug === name || - p.display_name === fullname + { + const custom = (customMappings as any)[name]; + if (Array.isArray(custom) && custom.some(m => m === p.slug)) + { + return true; + } + + return p.slug === custom || + p.slug === name || + p.igdb_slug === name || + p.display_name === fullname; + } ); const mappings: { diff --git a/scripts/generate-flatpak-sources.ts b/scripts/generate-flatpak-sources.ts index 6217e30..75c6084 100644 --- a/scripts/generate-flatpak-sources.ts +++ b/scripts/generate-flatpak-sources.ts @@ -1,6 +1,5 @@ import { $ } from "bun"; -const lockfile = Bun.argv[2] ?? "bun.lockb"; const output = Bun.argv[3] ?? ".config/flatpak/sources.gen.json"; const text = await $`bun ./bun.lockb --hash: 0000000000000000-0000000000000000-0000000000000000-0000000000000000`.text(); diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index 350607e..4695726 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -1,5 +1,5 @@ -import { TaskQueue } from "./task-queue"; +import { TaskQueue, AppEventMap } from "@simeonradivoev/gameflow-sdk"; import { Database } from "bun:sqlite"; import { CookieJar } from 'tough-cookie'; import FileCookieStore from 'tough-cookie-file-store'; @@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite"; import Conf from "conf"; import projectPackage from '~/package.json'; -import { SettingsSchema, SettingsType } from "@shared/constants"; +import { SettingsType, SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { client } from "@clients/romm/client.gen"; import * as schema from "@schema/app"; import cacheSchema from "@schema/cache"; @@ -18,13 +18,12 @@ import EventEmitter from "node:events"; import { appPath } from "../utils"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { ensureDir } from "fs-extra"; -import UpdateStoreJob from "./jobs/update-store"; -import { getStoreFolder } from "./store/services/gamesService"; import { PluginManager } from "./plugins/plugin-manager"; import registerPlugins from "./plugins/register-plugins"; import controls from './controls/controls'; import { RunAPIServer } from "./rpc"; import { RunBunServer } from "../server"; +import ReloadPluginsJob from "./jobs/reload-plugins-job"; export let config: Conf; export let customEmulators: Conf>; @@ -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', @@ -72,7 +74,7 @@ export async function load () console.log("Config Path Located At: ", config.path); console.log("Custom Emulator Paths Located At: ", customEmulators.path); console.log("App Directory is ", process.env.APPDIR); - console.log("Store Directory is ", getStoreFolder()); + console.log("Cache Path is ", cachePath); cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite'); fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); @@ -84,18 +86,21 @@ export async function load () emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); await reloadDatabase(); plugins = new PluginManager(); - await registerPlugins(plugins); api = await RunAPIServer(); + await registerPlugins(plugins); + taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); controlsHandle = await controls(); if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer(); config.onDidChange('downloadPath', () => reloadDatabase()); config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v })); - taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); } export async function cleanup () { + if (cleaningUp) throw new Error("Already Cleaning Up"); + cleaningUp = true; + if (cleannedUp) throw new Error("Already Cleaned Up. Skipping"); console.log("Cleaning Up"); await bunServer?.cleanup(); await api.cleanup(); @@ -108,6 +113,14 @@ export async function cleanup () config._closeWatcher(); customEmulators._closeWatcher(); console.log("Finished Cleaning Up"); + cleannedUp = true; +} + +/** Reset the cleanup flags. This is mainly used by tests since they run the same app. */ +export async function resetCleanup () +{ + cleaningUp = false; + cleannedUp = false; } export async function reloadDatabase () @@ -120,6 +133,7 @@ export async function reloadDatabase () db = drizzle(sqlite, { schema }); cache = drizzle(cacheSqlite, { schema: cacheSchema }); migrate(db!, { migrationsFolder: appPath("./drizzle") }); + sqlite.run("PRAGMA foreign_keys = ON;"); await cache.run(` CREATE TABLE IF NOT EXISTS item_cache ( key TEXT PRIMARY KEY, diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index b171ed0..47ea019 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -1,5 +1,5 @@ import Elysia, { status } from "elysia"; -import { config, events, jar, plugins, taskQueue } from "./app"; +import { config, events, plugins, taskQueue } from "./app"; import z from "zod"; import { getCurrentUserApiUsersMeGet, tokenApiTokenPost, UserSchema } from "@clients/romm"; import secrets from '../api/secrets'; @@ -46,67 +46,7 @@ export default new Elysia() return status(res.status, res.statusText); }) - .get('/login/twitch', async () => - { - const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); - if (!access_token) - { - return status('Not Found', "Not Logged In"); - } - - const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } }); - if (res.ok) - { - return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; - } - - if (!process.env.TWITCH_CLIENT_ID) - { - return status("Not Found", "Twitch Client ID not set"); - } - - const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" }); - if (!refresh_token) - { - return status("Not Found", "Refresh Token Not Found"); - } - - // refresh token - const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', { - method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ - client_id: process.env.TWITCH_CLIENT_ID, - access_token, - grant_type: "refresh_token", - refresh_token - }) - }); - - if (refreshResponse.ok) - { - const data: { - access_token: string, - refresh_token: string, - token_type: string; - expires_in: number; - } = await refreshResponse.json(); - - await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token }); - await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token }); - await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() }); - - await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' }); - - events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' }); - - const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } }); - if (res.ok) - { - return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; - } - } - - return status(400, res.statusText); - }) + .get('/login/twitch', checkLoginAndRefreshTwitch) .post('/login/romm/qr', async () => { if (taskQueue.hasActiveOfType(LoginJob)) @@ -123,47 +63,7 @@ export default new Elysia() return data.data as UserSchema; }) .post('/login/romm', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) }) - .get('/login/romm', async () => - { - const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' }); - if (!access_token) - { - return { hasLogin: false }; - } - - const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" }); - if (expires_in) - { - const date = new Date(expires_in); - if (date > new Date()) - { - return { hasLogin: true }; - } - } - - const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" }); - if (!refresh_token) - { - return { hasLogin: false }; - } - - const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } }); - - if (refreshResponse.response.ok && refreshResponse.data) - { - await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token }); - if (refreshResponse.data.refresh_token) - await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token }); - await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() }); - - await plugins.hooks.auth.loginComplete.promise({ service: 'romm' }); - - events.emit('notification', { message: "Romm Refresh Successful", type: 'success' }); - return { hasLogin: true }; - } - - return status(refreshResponse.response.status, refreshResponse.response.statusText) as any; - }, + .get('/login/romm', checkLoginAndRefreshRomm, { response: z.object({ hasLogin: z.boolean() }) }) .post('/logout/romm', async () => { @@ -174,6 +74,109 @@ export default new Elysia() }, { response: z.any() }); +export async function checkLoginAndRefreshTwitch () +{ + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + if (!access_token) + { + return status('Not Found', "Not Logged In"); + } + + const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } }); + if (res.ok) + { + return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; + } + + if (!process.env.TWITCH_CLIENT_ID) + { + return status("Not Found", "Twitch Client ID not set"); + } + + const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" }); + if (!refresh_token) + { + return status("Not Found", "Refresh Token Not Found"); + } + + // refresh token + const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', { + method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ + client_id: process.env.TWITCH_CLIENT_ID, + access_token, + grant_type: "refresh_token", + refresh_token + }) + }); + + if (refreshResponse.ok) + { + const data: { + access_token: string, + refresh_token: string, + token_type: string; + expires_in: number; + } = await refreshResponse.json(); + + await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token }); + await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token }); + await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() }); + + await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' }); + + events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' }); + + const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } }); + if (res.ok) + { + return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; + } + } + + return status(400, res.statusText); +} + +export async function checkLoginAndRefreshRomm () +{ + const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' }); + if (!access_token) + { + return { hasLogin: false }; + } + + const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" }); + if (expires_in) + { + const date = new Date(expires_in); + if (date > new Date()) + { + return { hasLogin: true }; + } + } + + const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" }); + if (!refresh_token) + { + return { hasLogin: false }; + } + + const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } }); + + if (refreshResponse.response.ok && refreshResponse.data) + { + await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token }); + if (refreshResponse.data.refresh_token) + await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token }); + await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() }); + + await plugins.hooks.auth.loginComplete.promise({ service: 'romm' }); + + events.emit('notification', { message: "Romm Refresh Successful", type: 'success' }); + return { hasLogin: true }; + } + + return status(refreshResponse.response.status, refreshResponse.response.statusText) as any; +} export async function tryLoginAndSave ({ host, username, password }: { host: string, username: string, password: string; }) { @@ -181,7 +184,7 @@ export async function tryLoginAndSave ({ host, username, password }: { host: str body: { password, username, - scope: 'me.read roms.read platforms.read assets.read firmware.read roms.user.read collections.read me.write roms.user.write' + scope: 'me.read roms.read platforms.read assets.read assets.write firmware.read roms.user.read collections.read me.write roms.user.write' }, baseUrl: host }); diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts index 941ba7a..04abd1e 100644 --- a/src/bun/api/cache.ts +++ b/src/bun/api/cache.ts @@ -1,7 +1,9 @@ import { eq } from "drizzle-orm"; import { cache } from "./app"; import cacheSchema from "@schema/cache"; -import { GithubReleaseSchema } from "@/shared/constants"; +import { GithubReleaseSchema } from '@simeonradivoev/gameflow-sdk/shared'; +import PQueue from "p-queue"; +import z from "zod"; export const CACHE_KEYS = { ROM_PLATFORMS: 'rom-platforms', @@ -9,17 +11,21 @@ export const CACHE_KEYS = { STORE_GAME_MANIFEST: 'store-game-manifest' } as const; -export async function getOrCached (key: string, getter: () => Promise, options?: { expireMs?: number; }): Promise +// we aggressively cache github data so burst of calls is fine. +export const githubRequestQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60 * 60, strict: true }); + +export async function getOrCached (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); @@ -34,12 +40,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 () => + 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()); - }); + 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/clients.ts b/src/bun/api/clients.ts index 7d117d3..470faf8 100644 --- a/src/bun/api/clients.ts +++ b/src/bun/api/clients.ts @@ -5,9 +5,10 @@ import games from "./games/games"; import platforms from "./games/platforms"; import auth from "./auth"; import collections from "./games/collections"; +import emulatorjs from "./emulatorjs/emulatorjs"; export default new Elysia({ prefix: "/api/romm" }) - .use([games, platforms, collections, auth]) + .use([games, platforms, collections, auth, emulatorjs]) .all("/*", async ({ request, set }) => { set.headers["cross-origin-resource-policy"] = 'cross-origin'; diff --git a/src/bun/api/controls/controls.ts b/src/bun/api/controls/controls.ts index 4aa417a..cc3c455 100644 --- a/src/bun/api/controls/controls.ts +++ b/src/bun/api/controls/controls.ts @@ -14,8 +14,8 @@ export default async function Initialize () const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob); if (launchGameTask) { - launchGameTask.abort('exit'); taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300)); + launchGameTask.abort('exit'); } else { events.emit('focus'); diff --git a/src/bun/api/controls/windows.ts b/src/bun/api/controls/windows.ts index 40fc7d9..2621c26 100644 --- a/src/bun/api/controls/windows.ts +++ b/src/bun/api/controls/windows.ts @@ -72,7 +72,6 @@ export class GamepadWindows implements IGamepadBackend private index: number; private buffer = new ArrayBuffer(16); private view = new DataView(this.buffer); - private prevButtons = 0; private currButtons = 0; constructor(index = 0) { this.index = index; } diff --git a/src/bun/api/drives.ts b/src/bun/api/drives.ts index 2df0dd8..99452d8 100644 --- a/src/bun/api/drives.ts +++ b/src/bun/api/drives.ts @@ -1,6 +1,7 @@ import si from 'systeminformation'; import fs from 'node:fs'; import os from "node:os"; +import { Drive } from '@simeonradivoev/gameflow-sdk/shared'; async function getAccess (path: string) { diff --git a/src/bun/api/emulatorjs/emulatorjs.ts b/src/bun/api/emulatorjs/emulatorjs.ts index c8018de..d733d34 100644 --- a/src/bun/api/emulatorjs/emulatorjs.ts +++ b/src/bun/api/emulatorjs/emulatorjs.ts @@ -1,4 +1,12 @@ // ES-DE to emulator JS mapping + +import Elysia, { status } from "elysia"; +import z from "zod"; +import path from 'node:path'; +import { config, events, plugins } from "../app"; +import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService"; +import { SaveFileChange } from "@simeonradivoev/gameflow-sdk/shared"; + // TODO: use the retroarch cores based on ES-DE export const cores: Record = { "atari5200": "atari5200", @@ -43,4 +51,57 @@ export const cores: Record = { "plus4": "plus4", "vic20": "vic20", "dos": "dos" -}; \ No newline at end of file +}; + +export default new Elysia({ prefix: '/emulatorjs' }) + .put('/save', async ({ body: { save, screenshot } }) => + { + await Bun.write(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", save.name), save); + }, { + body: z.object({ + save: z.file(), + screenshot: z.file().optional() + }) + }).get('/load', async ({ query: { filePath } }) => + { + return Bun.file(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", filePath)); + }, { query: z.object({ filePath: z.string() }) }) + .post('/post_play/:source/:id', async ({ params: { source, id }, body: { save } }) => + { + const localGame = await getLocalGame(source, id); + if (!localGame) return status("Not Found"); + + const changedSaveFiles: Record = {}; + if (save) + { + const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS"); + const saveFile = path.join(savesPath, save.name); + await Bun.write(saveFile, save); + changedSaveFiles.gameflow = { subPath: save.name, cwd: savesPath, shared: false }; + events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" }); + } + await updateLocalLastPlayed(localGame.id); + await plugins.hooks.games.postPlay.promise({ + source, + id, + saveFolderSlots: { 'emulatorjs': { cwd: path.join(config.get('downloadPath'), "saves", "EMULATORJS") } }, + gameInfo: { platformSlug: localGame?.platform.slug }, + changedSaveFiles: [], + validChangedSaveFiles: changedSaveFiles, + command: { + id: "EMULATORJS", + command: "", + emulator: "EMULATORJS", + valid: true, + metadata: { + romPath: localGame?.path_fs ?? undefined, + emulatorBin: undefined, + emulatorDir: undefined + } + } + }); + }, { + body: z.object({ + save: z.file().optional() + }) + }); \ No newline at end of file diff --git a/src/bun/api/games/collections.ts b/src/bun/api/games/collections.ts index 6728845..ae31430 100644 --- a/src/bun/api/games/collections.ts +++ b/src/bun/api/games/collections.ts @@ -1,5 +1,6 @@ import Elysia, { status } from "elysia"; import { plugins } from "../app"; +import { FrontEndCollection } from "@simeonradivoev/gameflow-sdk/shared"; export default new Elysia() .get('/collections', async () => diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 9666d44..d922b36 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,25 +1,29 @@ import Elysia, { status } from "elysia"; import { config, db, emulatorsDb, plugins, taskQueue } from "../app"; -import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"; +import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm"; import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; -import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; +import { SERVER_URL } from "@shared/constants"; +import { CommandEntry, DownloadLookupEntry, DownloadsLookupFilterValues, GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; -import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; -import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; +import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; +import buildStatusResponse, { customUpdate, fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; -import { getEmulatorsForSystem, launchCommand } from "./services/launchGameService"; +import { launchCommand } from "./services/launchGameService"; import { getErrorMessage, SeededRandom } from "@/bun/utils"; import { defaultFormats, defaultPlugins } from 'jimp'; import { createJimp } from "@jimp/core"; import webp from "@jimp/wasm-webp"; import * as emulatorSchema from '@schema/emulators'; -import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService"; -import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService"; +import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService"; import { host } from "@/bun/utils/host"; import { LaunchGameJob } from "../jobs/launch-game-job"; +import { cores } from "../emulatorjs/emulatorjs"; +import { findEmulatorPluginIntegration } from "../store/services/emulatorsService"; +import { ImportJob } from "../jobs/import-job"; +import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -57,8 +61,15 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, if (typeof img === 'string') { - const rommFetch = await fetch(img); - return rommFetch; + const res = await fetch(img); + + return new Response(res.body, { + status: res.status, + headers: { + "Content-Type": res.headers.get("Content-Type") ?? "image/jpeg", + "Cache-Control": "public, max-age=86400", + }, + }); } return img; @@ -135,97 +146,142 @@ export default new Elysia() { const games: FrontEndGameType[] = []; - if (query.source === 'store') + const where: any[] = []; + let localGamesSet: Set | undefined; + + if (query.platform_slug) { - const shuffledGames = await getShuffledStoreGames(); - set.headers['x-max-items'] = shuffledGames.length; - const storeGames = await Promise.all(shuffledGames - .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) - .map(async (e) => + where.push(eq(schema.platforms.slug, query.platform_slug)); + } else if (query.platform_id && query.platform_source === 'local') + { + where.push(eq(schema.platforms.id, query.platform_id)); + } + else if (query.platform_id && query.platform_source) + { + const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: query.platform_id ? String(query.platform_id) : undefined }); + if (platform) + { + where.push(eq(schema.platforms.slug, platform?.slug)); + } + } + + if (query.search) + { + where.push(like(schema.games.name, query.search)); + } + + if (query.source) + { + where.push(eq(schema.games.source, query.source)); + } + + const ordering: any[] = []; + + if (query.orderBy) + { + switch (query.orderBy) + { + case 'added': + ordering.push(desc(schema.games.created_at)); + break; + case 'activity': + ordering.push(sql`MAX(COALESCE(${schema.games.created_at}, '1970-01-01'), COALESCE(${schema.games.last_played}, '1970-01-01')) DESC`); + break; + case 'name': + ordering.push(desc(schema.games.name)); + break; + case "release": + ordering.push(sql`json_extract(${schema.games.metadata}, '$.first_release_date') DESC NULLS LAST`); + break; + } + } + + const localGames = await db.select({ + ...getTableColumns(schema.games), + platform: schema.platforms, + screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), + }) + .from(schema.games) + .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) + .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) + .groupBy(schema.games.id) + .orderBy(...ordering) + .where(and(...where)); + + localGamesSet = new Set( + localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`) + .concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`)) + ); + + function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; }) + { + if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true; + if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true; + if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true; + return false; + } + + if (query.collection_id) + { + // Collections are just a remote thing for now. + const remoteGames: FrontEndGameTypeWithIds[] = []; + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.map(g => + { + if (localGameExistsPredicate(g)) { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); + return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!); + } + else + { + return g; + } + })); - const localGame = await db.select({ - ...getTableColumns(schema.games), - platform: schema.platforms, - screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), - }) - .from(schema.games) - .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) - .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) - .groupBy(schema.games.id) - .where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`))); - - if (localGame.length > 0) return convertLocalToFrontend(localGame[0]); - - const storeGame = await getStoreGameFromPath(e.path); - - return convertStoreToFrontend(system, id, storeGame); - })); - games.push(...storeGames.filter(g => g !== undefined)); } else { - const where: any[] = []; - let localGamesSet: Set | undefined; - - if (query.platform_slug) + games.push(...localGames.slice(query.offset, query.limit !== undefined ? ((query.offset ?? 0) + query.limit) : undefined).filter(g => { - where.push(eq(schema.platforms.slug, query.platform_slug)); - } else if (query.platform_id && query.platform_source === 'local') - { - where.push(eq(schema.platforms.id, query.platform_id)); - } - else if (query.platform_id && query.platform_source) - { - const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: String(query.platform_id) }); - if (platform) + if (query.genres && query.genres.length > 0) { - where.push(eq(schema.platforms.slug, platform?.slug)); + if (!g.metadata) return false; + if (!g.metadata.genres) return false; + if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false; } - } - if (query.source) + return true; + }).map(g => { - where.push(eq(schema.games.source, query.source)); - } + return convertLocalToFrontend(g); + })); - const localGames = await db.select({ - ...getTableColumns(schema.games), - platform: schema.platforms, - screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), - }) - .from(schema.games) - .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) - .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) - .groupBy(schema.games.id) - .where(and(...where)); - - localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)); - - if (!query.collection_id) + if (query.localOnly !== true) { - games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g => - { - return convertLocalToFrontend(g); - })); - - const remoteGames: FrontEndGameType[] = []; + const remoteGames: FrontEndGameTypeWithIds[] = []; + const remoteGameSet = new Set(); await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`))); - } else - { - const remoteGames: FrontEndGameType[] = []; - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.map(g => + games.push(...remoteGames.filter(g => { - if (localGamesSet?.has(`${g.id.source}@${g.id.id}`)) + if (localGameExistsPredicate(g)) { - return convertLocalToFrontend(localGames.find(l => l.source === g.id.source && l.source_id === g.id.id)!); - } else - { - return g; + return false; } + + if (g.igdb_id) + { + const igdbId = `igdb@${g.igdb_id}`; + if (remoteGameSet.has(igdbId)) return false; + remoteGameSet.add(igdbId); + } + + if (g.ra_id) + { + const raId = `ra@${g.ra_id}`; + if (remoteGameSet.has(raId)) return false; + remoteGameSet.add(raId); + } + + return true; })); } } @@ -243,6 +299,9 @@ export default new Elysia() case 'name': games.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); break; + case "release": + games.sort((a, b) => (b.metadata.first_release_date?.getTime() ?? 0) - (a.metadata.first_release_date?.getTime() ?? 0)); + break; } } @@ -251,27 +310,55 @@ export default new Elysia() }, { query: GameListFilterSchema, }) - .get('/rom/:source/:id', async ({ params: { id, source } }) => + .get('/games/filters', async ({ query: { source } }) => { - const localGame = await db.query.games.findFirst({ - where: getLocalGameMatch(id, source), - columns: { path_fs: true } + const filterSets: FrontEndFilterSets = { + age_ratings: new Set(), + player_counts: new Set(), + languages: new Set(), + companies: new Set(), + genres: new Set() + }; + + let filter: any = undefined; + if (source) filter = eq(schema.games.source, source); + const local_metadata = await db.query.games.findMany({ columns: { metadata: true }, where: filter }); + + local_metadata.forEach(game => + { + game.metadata.age_ratings?.forEach(r => filterSets.age_ratings.add(r)); + game.metadata.genres?.forEach(r => filterSets.genres.add(r)); + game.metadata.companies?.forEach(r => filterSets.companies.add(r)); + + if (game.metadata.player_count) + filterSets.player_counts.add(game.metadata.player_count); }); - if (!localGame?.path_fs) + await plugins.hooks.games.fetchFilters.promise({ filters: filterSets, source }); + + const filters: FrontEndFilterLists = { + age_ratings: Array.from(filterSets.age_ratings), + player_counts: Array.from(filterSets.player_counts), + languages: Array.from(filterSets.languages), + companies: Array.from(filterSets.companies), + genres: Array.from(filterSets.genres) + }; + + return filters; + }, { + query: z.object({ source: z.string().optional() }) + }) + .get('/rom/:source/:id', async ({ params: { id, source } }) => + { + const filePaths = await plugins.hooks.games.fetchRomFiles.promise({ source, id }); + + if (!filePaths || filePaths.length <= 0) { - return status("Not Found"); + return status("Not Found", "No Valid Roms Found"); } - const downloadPath = config.get('downloadPath'); - const path_fs = path.join(downloadPath, localGame.path_fs); - const stats = await fs.stat(path_fs); - if (stats.isDirectory()) - { - return status("Not Found", "Rom is a folder"); - } + return Bun.file(filePaths[0]); - return Bun.file(path_fs); }, { params: z.object({ source: z.string(), id: z.string() }) }) @@ -286,38 +373,61 @@ export default new Elysia() const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) }); if (systemMapping) { - const emulatorNames = await getEmulatorsForSystem(systemMapping.system); - const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e })))); + const emulatorNames: string[] = []; + await plugins.hooks.emulators.findEmulatorForSystem.promise({ system: systemMapping.system, emulators: emulatorNames }); - sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) => + sourceData.emulators = (await Promise.all(emulatorNames.map(async name => { - if (data) - { - const systems = await buildStoreFrontendEmulatorSystems(data); - return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true }; - } - else if (name === 'EMULATORJS') + if (name === 'EMULATORJS') { return { name: 'EMULATORJS', validSources: [{ binPath: SERVER_URL(host), type: 'embedded', exists: true }], - logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, - systems: [], - gameCount: 0 - } satisfies FrontEndGameTypeDetailedEmulator; - } - else - { - return { - name: name, - logo: "", - systems: [], + logo: 'https://emulatorjs.org/logo/EmulatorJS.png', + systems: await Promise.all(Object.keys(cores).map(async c => + { + const mapping = await emulatorsDb.query.systemMappings.findFirst({ + where (fields, operators) + { + return operators.and(operators.eq(fields.source, "romm"), operators.eq(fields.system, c)); + }, columns: { sourceSlug: true } + }); + const system: EmulatorSystem = { + id: c, + name: c, + iconUrl: `/api/romm/image/romm/assets/platforms/${mapping?.sourceSlug}.svg` + }; + return system; + })), gameCount: 0, - validSources: [] + source: 'local', + integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; } - })); + const foundEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: name }); + + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: name, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(id, execPaths); + + if (foundEmulator) + { + foundEmulator.validSources = execPaths; + foundEmulator.integrations = integrations; + return foundEmulator; + } + + return { + name: name, + logo: "", + source: 'local', + systems: [], + gameCount: 0, + validSources: execPaths, + integrations: integrations + } satisfies FrontEndGameTypeDetailedEmulator; + }))).filter(e => !!e); } } @@ -344,17 +454,18 @@ export default new Elysia() }, { params: z.object({ id: z.string(), source: z.string() }), }) - .post('/game/:source/:id/install', async ({ params: { id, source } }) => + .post('/game/:source/:id/install', async ({ params: { id, source }, body }) => { if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) { - return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source)); + return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body)); } else { return status('Not Implemented'); } }, { params: z.object({ id: z.string(), source: z.string() }), + body: z.object({ downloadId: z.string().optional() }).optional(), response: z.any() }) .delete('/game/:source/:id/install', async ({ params: { id, source } }) => @@ -370,7 +481,56 @@ export default new Elysia() params: z.object({ id: z.string(), source: z.string() }), response: z.any() }) - .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => + .get('/game/:source/:id/validate', async ({ params: { id, source } }) => + { + const valid = await validateGameSource(source, id); + return { valid: valid.valid, reason: valid.reason }; + }) + .post('/game/:source/:id/fix_source', async ({ params: { id, source } }) => + { + return fixSource(source, id); + }) + .post('/game/:source/:id/update', async ({ params: { id, source } }) => + { + return update(source, id); + }) + .post('/game/:source/:id/update', async ({ params: { id, source }, body }) => + { + return customUpdate(source, id, body.source, body.id); + }, { body: z.object({ source: z.string(), id: z.string() }) }) + .get('/lookup', async ({ query: { search } }) => + { + const matches = new Map(); + await plugins.hooks.games.gameLookup.promise(matches, { search }); + return { hadMatchers: matches.size > 0, matches: Array.from(matches.values()).flatMap(m => m) }; + }, { + query: z.object({ search: z.string() }) + }) + .get('/lookup/:source/:id', async ({ params: { source, id } }) => + { + const matches = new Map(); + await plugins.hooks.games.gameLookup.promise(matches, { source, id }); + return Array.from(matches.values()).flatMap(m => m); + }) + .get('/game/:source/:id/commands', async ({ params: { id, source }, set }) => + { + const validCommands = await getValidLaunchCommandsForGame(source, id); + if (validCommands instanceof Error) + { + return errorToResponse(validCommands, set); + } + return validCommands as { + commands: CommandEntry[]; + gameId: FrontEndId; + source?: string; + sourceId?: string; + } | undefined; + }, { + response: z.object({ + commands: z.custom().array() + }) + }) + .post('/game/:source/:id/play', async ({ params: { id, source }, body: { command_id }, set }) => { const validCommands = await getValidLaunchCommandsForGame(source, id); if (validCommands) @@ -383,11 +543,11 @@ export default new Elysia() { try { - const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0]; + const validCommand = command_id ? validCommands.commands.find(c => c.id === command_id) : validCommands.commands[0]; if (validCommand) { // launch command waits for the game to exit, we don't want that. - await launchCommand(validCommand, source, id, validCommands.gameId); + await launchCommand(validCommand, validCommands.gameId, validCommands.source, validCommands.sourceId); return { type: 'application', command: null }; } else { @@ -428,8 +588,6 @@ export default new Elysia() const emulator = await getStoreEmulatorPackage(id); if (!emulator) return status("Not Found"); const systems = await buildStoreFrontendEmulatorSystems(emulator); - const systemsIdSet = new Set(systems.map(s => s.id)); - const games: FrontEndGameType[] = []; @@ -456,28 +614,6 @@ export default new Elysia() await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames }); games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`))); - const gamesManifest = await getStoreGameManifest(); - const storeGames = await Promise.all(gamesManifest - .filter(g => systemsIdSet.has(path.dirname(g.path))) - .map(async (e) => - { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); - - const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) }); - - if (localGame) - { - return undefined; - } - - const storeGame = await getStoreGameFromPath(e.path); - - return convertStoreToFrontend(system, id, storeGame); - })); - - games.push(...storeGames.filter(g => g !== undefined).slice(0, 3)); - return games; }) .get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) => @@ -485,10 +621,10 @@ export default new Elysia() const sourceData = await getSourceGameDetailed(source, id); if (!sourceData) return status("Not Found"); - const sourceCompaniesSet = new Set(sourceData.companies); - const sourceGenresSet = new Set(sourceData.genres); + const sourceCompaniesSet = new Set(sourceData.metadata.companies); + const sourceGenresSet = new Set(sourceData.metadata.genres); + - const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined; const games: (FrontEndGameType & { metadata?: any; })[] = []; @@ -499,37 +635,9 @@ export default new Elysia() const localGamesSourceSet = new Set(localGames.filter(g => g.source).map(g => `${g.source}@${g.source_id}`)); - games.push(...localGames.map(g => ({ ...convertLocalToFrontend(g), metadata: g.metadata }))); + games.push(...localGames.map(g => convertLocalToFrontend(g))); - const shuffledGames = await getShuffledStoreGames(); - const storeGames = await Promise.all(shuffledGames - .filter(g => - { - const system = path.dirname(g.path); - const id = path.basename(g.path, path.extname(g.path)); - if (localGamesSourceSet.has(`${system}@${id}`)) - return false; - - if (esSystem) - { - if (path.dirname(g.path) === esSystem.system) return true; - } - - return false; - }) - .map(async (e) => - { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); - const storeGame = await getStoreGameFromPath(e.path); - return convertStoreToFrontend(system, id, storeGame); - })); - - if (storeGames) - { - games.push(...storeGames.slice(0, 3)); - } const remoteGames: (FrontEndGameType & { metadata?: any; })[] = []; plugins.hooks.games.fetchRecommendedGamesForGame.promise({ @@ -582,4 +690,57 @@ export default new Elysia() rankedGames.sort((lhs, rhs) => rhs.rank - lhs.rank); return rankedGames.map(g => g.game).slice(0, 10); + }) + .post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) => + { + if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running"); + const data = await taskQueue.enqueue(ImportJob.query({ source, id }), new ImportJob(source, id, gamePath, platformId), { + throwOnCancel: true + + }); + return { source: 'local', id: data.localId }; + }, { + body: z.object({ + source: z.string(), + id: z.string(), + gamePath: z.string(), + platformId: z.number() + }) + }).get('/downloads/lookup', async ({ query: { search, page, rows, orderBy, sortDirection, source } }) => + { + const matches = new Map(); + await plugins.hooks.games.downloadsLookup.promise(matches, { search, page, rows, orderBy, sortDirection, source }); + const allValues = Array.from(matches.values()); + return { hadMatchers: matches.size > 0, matches: allValues.flatMap(m => m.items), totalCount: allValues.reduce((p, c) => p + c.count, 0) }; + }, { + query: z.object({ + search: z.string().optional(), + page: z.coerce.number().optional(), + rows: z.coerce.number().optional(), + orderBy: z.string().optional(), + sortDirection: z.literal(["desc", "asc"]).optional(), + source: z.string().optional() + }) + }).get('/download/lookup/:source/:id', async ({ params: { source, id } }) => + { + const match = await plugins.hooks.games.downloadLookup.promise({ source, id }); + if (!match) return status("Not Found"); + return match; + }).get('/download/file/info', async ({ query: { file_url } }) => + { + const response = await fetch(file_url, { method: "HEAD" }); + if (!response.ok) return status('Internal Server Error', response.statusText); + return { size: Number(response.headers.get('content-length')), content_type: response.headers.get('content-type') }; + }, { + query: z.object({ file_url: z.url() }) + }).get('/download/lookup/filters', async () => + { + const filters: DownloadsLookupFilterValues = { + source: [], + orderBy: [] + }; + + await plugins.hooks.games.downloadsLookupFilters.promise({ filters }); + + return filters; }); \ No newline at end of file diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index a33e155..10aaf42 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -1,8 +1,10 @@ import Elysia, { status } from "elysia"; import z from "zod"; -import { and, count, eq, getTableColumns, not } from "drizzle-orm"; -import { db, plugins } from "../app"; +import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm"; +import { config, db, plugins } from "../app"; import * as schema from "@schema/app"; +import { findPlatform } from "./services/utils"; +import { FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared"; export default new Elysia() .get('/platforms', async () => @@ -91,9 +93,11 @@ export default new Elysia() { const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id }); if (!remotePlatform) return status("Not Found"); - return remotePlatform; + const local = await db.query.platforms.findFirst({ where: or(eq(schema.platforms.slug, remotePlatform?.slug), eq(schema.platforms.name, remotePlatform?.name)) }); + return { ...remotePlatform, hasLocal: !!local }; } - }, { params: z.object({ source: z.string(), id: z.string() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) => + }, { params: z.object({ source: z.string(), id: z.string() }) }) + .get('/platform/local/:id/cover', async ({ params: { id }, set }) => { set.headers["cross-origin-resource-policy"] = 'cross-origin'; @@ -112,4 +116,70 @@ export default new Elysia() set.headers["content-type"] = coverBlob.cover_type; } return status(200, coverBlob.cover); - }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }); \ No newline at end of file + }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }) + .post('/platform/:source/:id/update', async ({ params: { source, id } }) => + { + const where: any[] = []; + if (source === 'local') + { + where.push(eq(schema.platforms.id, Number(id))); + } else + { + const remotePlatform = await plugins.hooks.games.fetchPlatform.promise({ source, id }); + if (remotePlatform) + { + where.push(eq(schema.platforms.slug, remotePlatform.slug)); + } + } + + const localPlatform = await db.query.platforms.findFirst({ + where: or(...where) + + }); + if (!localPlatform) return status("Not Found"); + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: localPlatform.slug + }); + let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${localPlatform.slug}.svg`); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo); + } + + await db.update(schema.platforms).set({ + name: platformLookup?.name, + cover: Buffer.from(await platformCover.arrayBuffer()), + cover_type: platformCover.headers.get('content-type'), + }).where(eq(schema.platforms.id, localPlatform.id)); + }) + .delete('/platform/local/:id', async ({ params: { id } }) => + { + const deleted = await db.delete(schema.platforms).where(and(eq(schema.platforms.id, Number(id)), + notExists( + db + .select() + .from(schema.games) + .where(eq(schema.games.platform_id, Number(id))) + ))).returning(); + if (deleted.length <= 0) return status("Not Found"); + }) + .get('/platform/lookup/match/:source/:id', async ({ params: { source, id } }) => + { + const platformLookup = await plugins.hooks.games.platformLookup.promise({ source, id }); + if (!platformLookup) return status("Not Found"); + const match = await findPlatform({ + system_slug: platformLookup.slug, + platform: { + source_slug: platformLookup.slug, + source_id: Number(id), + source: source, + name: platformLookup.name + } + }); + return { details: platformLookup, match }; + }, { + detail: { + description: "Find matches of remote platform lookups. Returns the operations for each platform if it were to be imported. If platform locally exists. Will a new local platform be created from say romm. Unknown is returned if no match is found." + } + }); \ No newline at end of file diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index add3c59..490850d 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -1,275 +1,23 @@ import path from 'node:path'; -import { Glob, which } from 'bun'; +import { Glob } from 'bun'; import fs from 'node:fs/promises'; -import { existsSync, readFileSync } from 'node:fs'; -import * as schema from '@schema/emulators'; -import { eq } from 'drizzle-orm'; -import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; -import os, { platform } from 'node:os'; -import { cores } from '../../emulatorjs/emulatorjs'; +import { existsSync } from 'node:fs'; +import { config, taskQueue } from '../../app'; import { LaunchGameJob } from '../../jobs/launch-game-job'; -import { EmulatorPackageType } from '@/shared/constants'; -import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService'; +import { getStoreEmulatorPackage } from '../../store/services/gamesService'; +import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; +import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared'; -export const varRegex = /%([^%]+)%/g; -export const assignRegex = /(%\w+%)=(\S+) /g; - -export async function launchCommand (validCommand: CommandEntry, source: string, sourceId: string, id: number) +export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string) { if (taskQueue.hasActiveOfType(LaunchGameJob)) { - throw new Error(`${id} currently running`); + throw new Error(`Game currently running`); } taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId)); } -/** - * Get the emulators related to the given system - * @param systemSlug the ES-DE slug for the system - */ -export async function getEmulatorsForSystem (systemSlug: string) -{ - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${systemSlug}'`); - } - - const emulators = new Set(); - await Promise.all(system.commands.map(async (command, index) => - { - let cmd = command.command; - - const matches = Array.from(cmd.matchAll(varRegex)); - matches.forEach(([value]) => - { - if (value.startsWith("%EMULATOR_")) - { - const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - emulators.add(emulatorName); - return; - } - }); - })); - - - - if (cores[systemSlug]) - { - emulators.add('EMULATORJS'); - } - - return Array.from(emulators); -} - -/** - * - * @param data Uses es-de system slug - * @returns - */ -export async function getValidLaunchCommands (data: { - systemSlug: string; - gamePath: string; -}): Promise -{ - - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, data.systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${data.systemSlug}'`); - } - - if (!system.extension || system.extension.length <= 0) - { - throw new Error(`No extensions listed for system '${data.systemSlug}'`); - } - - const downloadPath = config.get('downloadPath'); - const gamePath = path.join(downloadPath, data.gamePath); - - const validFiles: string[] = []; - if (!existsSync(gamePath)) - { - throw new Error(`Provided rom path is missing: '${gamePath}'`); - } - - const gamePathStat = await fs.stat(gamePath); - - const extensionList = system.extension.join(','); - - if (gamePathStat.isDirectory()) - { - for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) - { - validFiles.push(file); - } - - if (validFiles.length <= 0) - { - throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); - } - } else - { - if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) - { - validFiles.push(gamePath); - } - else - { - throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); - } - } - - function escapeWindowsArg (arg: string): string - { - if (process.platform === 'win32') - { - return `"${arg - .replace(/(\\*)"/g, '$1$1\\"') // escape quotes - .replace(/(\\*)$/, '$1$1') // escape trailing backslashes - }"`; - } else - { - if (arg.includes(' ')) - { - return `"${arg}"`; - } else - { - return arg; - } - } - } - - const formattedCommands = await Promise.all(system.commands - .filter(c => !c.command.includes(`%ENABLESHORTCUTS%`)) - .map(async (command, index) => - { - const label = command.label; - let cmd = command.command; - - let emulator: string | undefined = undefined; - let rom = validFiles[0]; - - if (cmd.includes('%ESCAPESPECIALS%')) - rom = rom.replace(/[&()^=;,]/g, ''); - - - - const staticVars: Record = { - '%ROM%': escapeWindowsArg(rom), - '%ROMRAW%': validFiles[0], - '%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')), - '%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)), - '%ROMPATH%': escapeWindowsArg(gamePath), - '%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))), - '%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])), - '%ESCAPESPECIALS%': "", - '%HIDEWINDOW%': "" - }; - - cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => - { - try - { - const resolvedInjectFile = injectFile.replace(varRegex, (a) => - { - return staticVars[a] ?? a; - }); - if (existsSync(resolvedInjectFile)) - { - const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); - return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); - } - - return ''; - } catch (error) - { - return ''; - } - }); - - const matches = Array.from(cmd.matchAll(varRegex)); - const varList = await Promise.all(matches.map(async ([value]) => - { - if (value.startsWith("%EMULATOR_")) - { - const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - let execs = await findExecsByName(emulatorName); - let validExec = execs.find(e => e.exists); - - emulator = emulatorName; - return [ - [value, validExec ? validExec.binPath : undefined] as [string, string | undefined], - [`%EMUSOURCE%`, validExec?.type] as [string, string | undefined], - ['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined], - ['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined] - ]; - - } - - const key = value[0].substring(1, value.length - 1); - return [[value, process.env[key]] as [string, string | undefined]]; - })); - - const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; - let startDir: string | undefined = undefined; - - if ('%STARTDIR%' in vars) - { - delete vars['%STARTDIR%']; - - cmd = cmd.replace(assignRegex, (match, p1, p2) => - { - if (p1 === '%STARTDIR%') - { - startDir = varRegex.test(p2) ? staticVars[p2] : p2; - } - return ""; - }); - } - - // missing variable - const invalid = Object.entries(vars).find(c => c[1] === undefined); - - const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim(); - - return { - id: index, - label: label ?? undefined, - command: formattedCommand, - startDir, - valid: !invalid, emulator, - emulatorSource: vars['%EMUSOURCE%'] as any, - metadata: { - romPath: validFiles[0], - emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1], - emulatorDir: vars['%EMUDIRRAW%'] - } - } satisfies CommandEntry; - })); - - return formattedCommands.filter(c => !!c); -} - -export async function findExecsByName (emulatorName: string) -{ - const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) }); - if (!emulator) - { - throw new Error(`Could not find emulator ${emulatorName}`); - } - return findExecs(emulatorName, emulator); -} - export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise { const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); @@ -285,11 +33,27 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath const storeExecName = (await Promise.all(storeEmulator.downloads[`${process.platform}:${process.arch}`].map(async dl => { // glob file search causes issues so do manual search - const glob = new Glob(dl.pattern); if (await fs.exists(storeEmulatorFolder)) { + const glob = (dl as any).pattern ? new Glob((dl as any).pattern) : undefined; + let bin: string | undefined = (dl as any).bin; + if (!bin && dl.type === 'scoop') + { + const data = await getOrCachedScoopPackage(id, dl.url); + + if (data) + { + bin = data.bin; + } + } + const files = (await fs.readdir(storeEmulatorFolder)) - .filter(f => glob.match(f)); + .filter(f => + { + if (glob && glob.match(f)) return true; + if (bin && f === bin) return true; + }); + return files.map(f => path.join(storeEmulatorFolder, f)); } return []; @@ -306,112 +70,3 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath return undefined; } -export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) -{ - const execs: EmulatorSourceEntryType[] = []; - - if (customEmulators.has(id)) - { - execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) }); - } - - if (emulator && emulator.systempath.length > 0) - { - const storePath = await findStoreEmulatorExec(id, emulator); - if (storePath) execs.push(storePath); - } - - if (emulator && os.platform() === 'win32') - { - const regValues = emulator.winregistrypath; - if (regValues.length > 0) - { - for (const node of regValues) - { - const registryValue = await readRegistryValue(node); - if (registryValue) - { - execs.push({ binPath: registryValue, type: 'registry', exists: true }); - } - } - - } - } - - if (emulator && emulator.systempath.length > 0) - { - const systemPath = await resolveSystemPath(emulator.systempath); - if (systemPath) - { - execs.push({ binPath: systemPath, type: 'system', exists: true }); - } - } - - if (emulator && emulator.staticpath.length > 0) - { - const staticPath = await resolveStaticPath(emulator.staticpath); - if (staticPath) - { - execs.push({ binPath: staticPath, type: 'static', exists: true }); - } - } - - return execs; -} - -async function readRegistryValue (text: string) -{ - const params = text.split('|'); - const key = path.dirname(params[0]); - const value = path.basename(params[0]); - const bin = params.length > 1 ? params[1] : undefined; - - const proc = Bun.spawn({ - cmd: ["reg", "QUERY", key, "/v", value], - stdout: "pipe", - stderr: "pipe", - }); - - const output = await new Response(proc.stdout).text(); - await proc.exited; - - if (!output.includes(value)) return null; - - const lines = output.split("\n"); - for (const line of lines) - { - if (line.includes(value)) - { - const parts = line.trim().split(/\s{4,}/); - return bin ? path.join(parts[2], bin) : parts[2]; // registry value - } - } - - return null; -} - -async function resolveStaticPath (entries: string[]) -{ - for (const entry of entries) - { - const resolved = entry.replace("~", os.homedir()); - if (await fs.exists(resolved)) - { - return resolved; - } - } - return null; -} - -async function resolveSystemPath (entries: string[]) -{ - for (const entry of entries) - { - try - { - const found = which(entry); - return found; - } catch { } - } - return null; -} \ No newline at end of file diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index af9e62a..1eaed5b 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,20 +1,19 @@ -import { RPC_URL, } from "@shared/constants"; -import { config, customEmulators, db, emulatorsDb, plugins, taskQueue } from "../../app"; -import { getValidLaunchCommands } from "./launchGameService"; -import * as emulatorSchema from '@schema/emulators'; -import { and, eq } from "drizzle-orm"; -import { getErrorMessage, hashFile } from "@/bun/utils"; -import { checkFiles, getLocalGameMatch } from "./utils"; +import { config, db, plugins, taskQueue } from "../../app"; +import { eq } from "drizzle-orm"; +import { getErrorMessage } from "@/bun/utils"; +import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import fs from 'node:fs/promises'; -import { getStoreGameFromId } from "../../store/services/gamesService"; -import { cores } from "../../emulatorjs/emulatorjs"; -import { host } from "@/bun/utils/host"; import Elysia from "elysia"; import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; +import * as appSchema from "@schema/app"; +import { RPC_URL } from "@/shared/constants"; +import { DownloadSourceSchema } from '@simeonradivoev/gameflow-sdk/shared'; +import { host } from "@/bun/utils/host"; +import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared"; -class CommandSearchError extends Error +export class CommandSearchError extends Error { constructor(status: GameStatusType, message: string) { @@ -26,7 +25,15 @@ class CommandSearchError extends Error export async function getLocalGame (source: string, id: string) { const localGame = await db.query.games.findFirst({ - columns: { id: true, path_fs: true }, + columns: { + id: true, + path_fs: true, + source: true, + source_id: true, + igdb_id: true, + ra_id: true, + main_glob: true + }, where: getLocalGameMatch(id, source), with: { platform: { columns: { slug: true } } @@ -36,62 +43,243 @@ export async function getLocalGame (source: string, id: string) return localGame; } -export async function getValidLaunchCommandsForGame (source: string, id: string) +/** Update local game's metadata from custom source, not the actual source of the game. Say from metadata providers like IGDB */ +export async function customUpdate (source: string, id: string, destination: string, destinationId: string) +{ + const localGame = await getLocalGame(source, id); + if (!localGame) throw new Error("Could not find Local Game"); + + const matchesMap = new Map(); + await plugins.hooks.games.gameLookup.promise(matchesMap, { source: destination, id: destinationId }); + const matches = matchesMap.values().next().value; + if (!matches || matches?.length <= 0) throw new Error("Could not find destination"); + const match = matches[0]; + + await db.transaction(async (tx) => + { + await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id)); + + // pre-fetch screenshots + const screenshots = await Promise.all(match.screenshotUrls.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof appSchema.screenshots.$inferInsert = { + game_id: localGame.id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + + let cover: Buffer | undefined = undefined; + if (match.coverUrl) + { + const coverResponse = await fetch(match.coverUrl); + if (coverResponse.ok) + { + cover = Buffer.from(await coverResponse.arrayBuffer()); + } + } + + await tx.update(appSchema.games).set({ + cover, + metadata: { + age_ratings: match.age_ratings, + genres: match.genres, + player_count: match.player_count ?? undefined, + companies: match.companies, + game_modes: match.game_modes, + average_rating: match.average_rating ?? undefined, + first_release_date: match.first_release_date, + } + }).where(eq(appSchema.games.id, localGame.id)); + }); +} + +export async function update (source: string, id: string) +{ + const localGame = await getLocalGame(source, id); + if (!localGame) throw new Error("Could not find Local Game"); + if (!localGame.source || !localGame.source_id) throw new Error("Game has not source defined"); + const sourceGame = await getSourceGameDetailed(localGame.source, localGame.source_id, { sourceOnly: true }); + if (!sourceGame) throw new Error("Could not find source game"); + + await db.transaction(async (tx) => + { + await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id)); + + const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)]; + if (paths_screenshots.length <= 0 && sourceGame.igdb_id) + { + const matches = new Map(); + await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(sourceGame.igdb_id) }); + if (matches.size > 0) + { + const firstMatches = matches.values().next().value; + if (firstMatches && firstMatches.length > 0) + { + paths_screenshots.push(...firstMatches[0].screenshotUrls); + } + } + } + + // pre-fetch screenshots + const screenshots = await Promise.all(paths_screenshots.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof appSchema.screenshots.$inferInsert = { + game_id: localGame.id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + + await tx.update(appSchema.games).set({ + metadata: { + age_ratings: sourceGame.metadata.age_ratings, + genres: sourceGame.metadata.genres, + player_count: sourceGame.metadata.player_count ?? undefined, + companies: sourceGame.metadata.companies, + game_modes: sourceGame.metadata.game_modes, + average_rating: sourceGame.metadata.average_rating ?? undefined, + first_release_date: sourceGame.metadata.first_release_date?.getTime() ?? undefined, + } + }).where(eq(appSchema.games.id, localGame.id)); + }); +} + +export async function fixSource (source: string, id: string) +{ + const valid = await validateGameSource(source, id); + if (!valid.valid) + { + if (!valid.localGame) throw new Error("No Local Game"); + if (!valid.localGame.source) throw new Error("No Valid Source"); + + const foundGame = await plugins.hooks.games.searchGame.promise({ + igdb_id: valid.localGame.igdb_id ?? undefined, + ra_id: valid.localGame.ra_id ?? undefined, + source: valid.localGame.source + }); + + if (foundGame) + { + await db.update(appSchema.games).set({ + source: foundGame.id.source, + source_id: foundGame.id.id, + metadata: { + age_ratings: foundGame.metadata.age_ratings, + genres: foundGame.metadata.genres, + player_count: foundGame.metadata.player_count ?? undefined, + companies: foundGame.metadata.companies, + game_modes: foundGame.metadata.game_modes, + average_rating: foundGame.metadata.average_rating ?? undefined, + first_release_date: foundGame.metadata.first_release_date?.getTime() ?? undefined, + } + }).where(eq(appSchema.games.id, valid.localGame.id)); + return true; + } else + { + throw new Error("Could not find Source Game"); + } + } else + { + throw new Error("Game Source Already Valid"); + } +} + +export async function validateGameSource (source: string, id: string): Promise<{ + valid: boolean, + localGame?: { id: number; igdb_id: number | null; ra_id: number | null; source: string | null; }, + reason?: string; +}> +{ + const localGame = await getLocalGame(source, id); + if (!localGame) return { valid: true }; + if (localGame.source && localGame.source_id) + { + const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); + if (!sourceGame) return { valid: false, reason: "Source Missing", localGame }; + // Store should be immutable + if (localGame.source !== 'store' && sourceGame.igdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) + { + return { valid: false, reason: "Metadata Missmatch", localGame }; + } + } + + return { valid: true, localGame }; +} + +export async function updateLocalLastPlayed (id: number) +{ + await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(id))); +} + +export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined> { const localGame = await getLocalGame(source, id); if (localGame) { - const rommPlatform = localGame.platform.slug; - const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) }); + const commands = await plugins.hooks.games.buildLaunchCommands.promise({ + source: localGame.source, + sourceId: localGame.source_id, + id: { source: 'local', id: String(localGame.id) }, + systemSlug: localGame.platform.slug, + gamePath: localGame.path_fs, + mainGlob: localGame.main_glob, + }); - if (esPlatform) + if (commands instanceof Error || !commands) return commands; + + const validCommand = commands.find(c => c.valid); + if (validCommand) { - if (localGame.path_fs) - { - try - { - const commands = await getValidLaunchCommands({ systemSlug: esPlatform.system, gamePath: localGame.path_fs }); - - if (cores[esPlatform.system]) - { - const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`; - commands.push({ - id: 'EMULATORJS', - label: "Emulator JS", - command: `core=${cores[esPlatform.system]}&gameUrl=${encodeURIComponent(gameUrl)}`, - valid: true, - emulator: 'EMULATORJS', - metadata: { - romPath: gameUrl - } - }); - } - - const validCommand = commands.find(c => c.valid); - if (validCommand) - { - return { commands: commands.filter(c => c.valid), gameId: localGame.id, source: source, sourceId: id }; - } - else - { - return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`); - } - } catch (error) - { - console.error(error); - return new CommandSearchError('error', getErrorMessage(error)); - } - - } else - { - return new CommandSearchError('error', 'Missing Path'); - } + return { + commands: commands.filter(c => c.valid), + gameId: { id: String(localGame.id), source: 'local' }, + source: localGame.source ?? source, + sourceId: localGame.source_id ? String(localGame.source_id) : id, + }; } else { - return new CommandSearchError('error', `Missing Platform ${localGame.platform.slug}`); + return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`); } + } else if (source === 'emulator') + { + const commands = await plugins.hooks.games.buildLaunchCommands.promise({ + source, + sourceId: id, + id: { source: source, id: id }, + systemSlug: "", + gamePath: null + }); + if (commands instanceof Error || !commands) return commands; + + const validCommand = commands.find(c => c.valid); + if (validCommand) + { + return { + commands: commands.filter(c => c.valid), + gameId: { id, source } + }; + } + else + { + return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`); + } } return undefined; @@ -106,7 +294,7 @@ export default function buildStatusResponse () z.object({ status: z.literal('refresh'), localId: z.number().optional() }), z.object({ status: z.literal(['queued']) }), z.object({ status: z.literal('playing'), details: z.string() }), - z.object({ status: z.literal('install'), details: z.string() }), + z.object({ status: z.literal('install'), details: z.string(), sources: DownloadSourceSchema.array() }), z.object({ status: z.literal('present'), details: z.string() }), z.object({ status: z.literal(['download', 'extract']), progress: z.number() }), ]), @@ -120,7 +308,7 @@ export default function buildStatusResponse () }, async open (ws) { - sendLatests(); + sendLatests().catch(e => ws.send({ status: 'error', error: JSON.stringify(e) })); const installJobId = InstallJob.query({ source: ws.data.params.source, id: ws.data.params.id }); async function sendLatests () @@ -143,6 +331,7 @@ export default function buildStatusResponse () } else { + const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source) }); const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id); if (validCommand) { @@ -159,9 +348,11 @@ export default function buildStatusResponse () }); } - } else if (ws.data.params.source === 'store') + } else if (!localGame && ws.data.params.source === 'store') { - const storeGame = await getStoreGameFromId(ws.data.params.id); + const downloads = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id }); + const sources = downloads?.map(d => ({ id: d.id, name: d.id })) ?? []; + /*const storeGame = await getStoreGame(ws.data.params.id); const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); const size = Number(fileResponse.headers.get('content-length')); const stats = await fs.statfs(config.get('downloadPath')); @@ -172,19 +363,22 @@ export default function buildStatusResponse () } else { ws.send({ status: 'install', details: 'Install' }); - } - } else + }*/ + + ws.send({ status: 'install', details: 'Install', sources }); + } else if (!localGame) { const files = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id }); + const sources = files?.map(d => ({ id: d.id, name: d.id })) ?? []; let filesChecked: LocalDownloadFileEntry[] | undefined; - if (files) + if (files && files.length) { - filesChecked = await checkFiles(files.files, !!files.extract_path); + filesChecked = await checkFiles(files[0].files, !!files[0].extract_path); } if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false)) @@ -199,15 +393,16 @@ export default function buildStatusResponse () ws.send({ status: 'error', error: "Not Enough Free Space" }); } else if (filesChecked?.some(f => f.exists === true && f.matches === false)) { - ws.send({ status: 'install', details: 'Some Files Present, Install' }); + ws.send({ status: 'install', details: 'Some Files Present, Install', sources }); } else { - ws.send({ status: 'install', details: 'Install' }); + ws.send({ status: 'install', details: 'Install', sources }); } } - - + } else + { + ws.send({ status: 'error', error: "No Way To Launch" }); } } } diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index f40eb8a..9bef2f4 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -2,24 +2,31 @@ import getFolderSize from "get-folder-size"; import fs from "node:fs/promises"; import path from "node:path"; import { config, db, emulatorsDb, plugins } from "../../app"; -import { and, eq } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import * as schema from "@schema/app"; -import { StoreGameType } from "@shared/constants"; -import { DetailedRomSchema, getCurrentUserApiUsersMeGet, getRomApiRomsIdGet, SimpleRomSchema } from "@clients/romm"; +import { RPC_URL } from "@shared/constants"; +import { hashFile } from "@/bun/utils"; +import { host } from "@/bun/utils/host"; import * as emulatorSchema from "@schema/emulators"; -import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService"; -import { hashFile, isSteamDeck, isSteamDeckGameMode } from "@/bun/utils"; +import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared"; export async function calculateSize (installPath: string | null) { if (!installPath) return null; - return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size; + const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath); + return (await getFolderSize(finalPath)).size; } export async function checkInstalled (installPath: string | null) { if (!installPath) return false; - return fs.exists(path.join(config.get('downloadPath'), installPath)); + const finalPath = path.isAbsolute(installPath) ? installPath : path.join(config.get('downloadPath'), installPath); + return fs.exists(finalPath); +} + +export function getScreenshotLocalGameMatch (id: string, source: string) +{ + return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id)); } export function getLocalGameMatch (id: string, source: string) @@ -33,10 +40,10 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { }) { const game: FrontEndGameType = { - platform_display_name: g.platform?.name ?? "Local", + platform_display_name: g.platform?.name ?? null, id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, - path_cover: `/api/romm/game/local/${g.id}/cover`, + path_covers: [`/api/romm/game/local/${g.id}/cover`], source_id: g.source_id, source: g.source, path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, @@ -46,22 +53,29 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { slug: g.slug, name: g.name, platform_id: g.platform_id, - platform_slug: g.platform?.slug ?? null + platform_slug: g.platform?.slug ?? null, + metadata: { + first_release_date: g.metadata?.first_release_date !== undefined ? new Date(g.metadata?.first_release_date) : null + } }; return game; } -export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & { - platform?: typeof schema.platforms.$inferSelect | null; +export async function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & { + platform?: { name: string | null, slug: string | null; } | null; screenshotIds?: number[]; }) { + + const exists = await checkInstalled(g.path_fs); + const fileSize = await calculateSize(g.path_fs); + const game: FrontEndGameTypeDetailed = { platform_display_name: g.platform?.name ?? "Local", id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, - path_cover: `/api/romm/game/local/${g.id}/cover`, + path_covers: [`/api/romm/game/local/${g.id}/cover`], source_id: g.source_id, source: g.source, path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, @@ -73,70 +87,28 @@ export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSel platform_id: g.platform_id, platform_slug: g.platform?.slug ?? null, summary: g.summary, - fs_size_bytes: 0, - missing: false, - local: true + fs_size_bytes: fileSize, + missing: !exists, + local: true, + ra_id: g.ra_id, + version: g.version, + version_source: g.version_source, + version_system: g.version_system, + igdb_id: g.igdb_id, + metadata: { + genres: g.metadata.genres ?? [], + companies: g.metadata.companies ?? [], + game_modes: g.metadata.game_modes ?? [], + age_ratings: g.metadata.age_ratings ?? [], + player_count: g.metadata.player_count ?? null, + average_rating: g.metadata.average_rating ?? null, + first_release_date: g.metadata.first_release_date ? new Date(g.metadata.first_release_date) : null + } }; return game; } -export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise -{ - const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ - where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm')) - }); - - const platformDef = await emulatorsDb.query.systems.findFirst({ - where: eq(emulatorSchema.systems.name, system), - columns: { fullname: true } - }); - - const gameId = `${system}@${id}`; - - const game: FrontEndGameType = { - platform_display_name: platformDef?.fullname ?? system, - path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`, - id: { source: 'store', id: gameId }, - source: null, - source_id: null, - path_fs: null, - path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`, - last_played: null, - updated_at: new Date(), - slug: null, - name: storeGame.title, - platform_id: null, - platform_slug: rommSystem?.sourceSlug ?? system, - paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [] - }; - - return game; -} - -export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise -{ - let size: number | null = null; - try - { - const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); - size = Number(fileResponse.headers.get('content-length')); - } catch (error) - { - console.error(error); - } - - const detailed: FrontEndGameTypeDetailed = { - ...await convertStoreToFrontend(system, id, storeGame), - summary: storeGame.description, - fs_size_bytes: size, - missing: false, - local: false, - }; - - return detailed; -} - export async function getLocalGameDetailed (match: any) { const localGame = await db.query.games.findFirst({ @@ -149,35 +121,13 @@ export async function getLocalGameDetailed (match: any) if (localGame) { - const exists = await checkInstalled(localGame.path_fs); - const fileSize = await calculateSize(localGame.path_fs); - const game: FrontEndGameTypeDetailed = { - path_cover: `/api/romm/game/local/${localGame.id}/cover`, - updated_at: localGame.created_at, - id: { id: String(localGame.id), source: 'local' }, - path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`, - fs_size_bytes: fileSize ?? null, - paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`), - local: true, - missing: !exists, - platform_display_name: localGame.platform?.name, - summary: localGame.summary, - source: localGame.source, - source_id: localGame.source_id, - path_fs: localGame.path_fs, - last_played: localGame.last_played, - slug: localGame.slug, - name: localGame.name, - platform_id: localGame.platform_id, - platform_slug: localGame.platform.slug - }; - return game; + return convertLocalToFrontendDetailed({ ...localGame, screenshotIds: localGame.screenshots.map(s => s.id) }); } return undefined; } -export async function getSourceGameDetailed (source: string, id: string) +export async function getSourceGameDetailed (source: string, id: string, options?: { sourceOnly?: boolean; }) { if (source === 'local') { @@ -189,30 +139,13 @@ export async function getSourceGameDetailed (source: string, id: string) { const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); - if (source === 'store') + const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame }); + if (localGame && options?.sourceOnly !== true) { - const gameId = extractStoreGameSourceId(id); - const storeGame = await getStoreGame(gameId.system, gameId.id); - if (!storeGame) return undefined; - const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame); - if (localGame) - { - return { ...storeFrontendGame, ...localGame }; - } - return storeFrontendGame; - } else - { - const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame }); - if (remoteGame) - { - return remoteGame; - } else if (localGame) - { - return localGame; - } + return localGame; } - return undefined; + return remoteGame; } } @@ -241,4 +174,333 @@ export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean } return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry; })); +} + +export async function findPlatform (info: { + system_slug: string; platform: { + igdb_id?: number; + igdb_slug?: string; + ra_id?: number; + moby_id?: number; + source: string; + source_id?: number; + source_slug?: string; + family_name?: string; + name?: string; + } | undefined; +}): + Promise<{ + type: string | null; + slug?: string | null; + name?: string | null; + family_name?: string | null; + es_slug?: string | null; + coverUrl?: string | null; + }> +{ + // Search for existing platform + const platformSearch = [eq(schema.platforms.slug, info.system_slug)]; + const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)]; + + if (info.platform) + { + if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id)); + if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug)); + if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id)); + if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id)); + + esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source)); + if (info.platform.source_slug) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug)); + } else if (info.platform.source_id) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id)); + } else + { + throw new Error("Must Provide at least one source id or slug"); + } + } + + const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ + with: { system: true }, + where: and(...esPlatformSearch) + }); + + if (esPlatform) + platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name)); + + let existingPlatform = await db.query.platforms.findFirst({ where: or(...platformSearch) }); + + if (!existingPlatform) + { + // TODO: use something else than the romm demo as CDN + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: info.platform?.source_slug ?? info.system_slug + }); + let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`, { method: "HEAD" }); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo, { method: "HEAD" }); + } + + if (!esPlatform && !info.platform) + { + // go to unknown platform + existingPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); + + if (existingPlatform) + { + return { + type: "existing", + slug: existingPlatform.slug, + name: existingPlatform.name, + family_name: existingPlatform.family_name, + es_slug: existingPlatform.es_slug, + coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover` + }; + } else + { + return { type: "unknown" }; + } + } else + { + return { + type: "new", + slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '', + name: info.platform?.name ?? esPlatform?.system.fullname ?? '', + family_name: info.platform?.family_name, + es_slug: esPlatform?.system.name ?? undefined, + coverUrl: platformCover.url + }; + } + + } else + { + return { + type: "existing", + slug: existingPlatform.slug, + name: existingPlatform.name, + family_name: existingPlatform.family_name, + es_slug: existingPlatform.es_slug, + coverUrl: `${RPC_URL(host)}/api/romm/platform/local/${existingPlatform.id}/cover` + }; + } +} + +export async function createLocalGame (info: { + name: string; + system_slug: string | undefined; + source: string | undefined; + source_id: string | undefined; + slug: string | null | undefined; + path_fs: string | null | undefined; + summary: string | null | undefined; + igdb_id: number | undefined; + ra_id: number | undefined; + main_glob: string | undefined; + cover: Buffer | undefined; + coverType: string | null | undefined; + version: string | undefined; + version_source: string | undefined; + screenshotUrls: string[]; + version_system: string | undefined; + last_played?: Date; + metadata: LocalGameMetadata | undefined, + platform: { + igdb_id?: number; + igdb_slug?: string; + ra_id?: number; + moby_id?: number; + source: string; + source_id?: number; + source_slug?: string; + family_name?: string; + name?: string; + } | undefined; +}) +{ + const id = await db.transaction(async (tx) => + { + // Search for existing platform + const platformSearch = []; + const esPlatformSearch = []; + if (info.system_slug) + { + platformSearch.push(eq(schema.platforms.slug, info.system_slug)); + esPlatformSearch.push(eq(emulatorSchema.systemMappings.system, info.system_slug)); + } + + if (info.platform) + { + if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id)); + if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug)); + if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id)); + if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id)); + + esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, info.platform.source)); + if (info.platform.source_slug) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.source_slug)); + } else if (info.platform.source_id) + { + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceId, info.platform.source_id)); + } else + { + throw new Error("Must Provide at least one source id or slug"); + } + } + + const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ + with: { system: true }, + where: and(...esPlatformSearch) + }); + + if (esPlatform) + platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name)); + + let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) }); + let platformId: number; + if (!existingPlatform) + { + // TODO: use something else than the romm demo as CDN + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: info.platform?.source_slug ?? info.system_slug + }); + let platformCover = await fetch(`${config.get('rommAddress') ?? 'https://demo.romm.app'}/assets/platforms/${info.platform?.source_slug ?? info.system_slug}.svg`); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo); + } + + if (!esPlatform && !info.platform) + { + // go to unknown platform + existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); + + if (existingPlatform) + { + platformId = existingPlatform.id; + } else + { + const [{ id }] = await tx.insert(schema.platforms).values({ + slug: 'unknown', + name: "Unknown" + }).returning({ id: schema.platforms.id }); + platformId = id; + } + } else + { + // Create new local platform + const platform: typeof schema.platforms.$inferInsert = { + slug: info.platform?.source_slug ?? esPlatform?.system.name ?? '', + igdb_id: info.platform?.igdb_id, + igdb_slug: info.platform?.igdb_slug, + ra_id: info.platform?.ra_id, + cover: Buffer.from(await platformCover.arrayBuffer()), + cover_type: platformCover.headers.get('content-type'), + name: info.platform?.name ?? esPlatform?.system.fullname ?? '', + family_name: info.platform?.family_name, + es_slug: esPlatform?.system.name ?? undefined, + }; + + // TODO: add ES slug once I have better way to query ES + const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id }); + platformId = id; + } + + } else + { + platformId = existingPlatform.id; + } + + // create the rom + const game: typeof schema.games.$inferInsert = { + source_id: info.source_id, + source: info.source, + slug: info.slug, + path_fs: info.path_fs, + last_played: info.last_played, + platform_id: platformId, + igdb_id: info.igdb_id, + ra_id: info.ra_id, + summary: info.summary, + name: info.name, + cover: info.cover, + cover_type: info.coverType, + metadata: info.metadata, + main_glob: info.main_glob, + version: info.version, + version_source: info.version_source, + version_system: info.version_system + }; + + const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); + + if (info.screenshotUrls.length <= 0 && info.igdb_id) + { + const matches = new Map(); + await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(info.igdb_id) }); + info.screenshotUrls.push(...(matches.values().next().value?.[0].screenshotUrls ?? [])); + } + + // pre-fetch screenshots + const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof schema.screenshots.$inferInsert = { + game_id: id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + + return id; + }); + + return id; +} + +export async function downloadGame (ctx: { + downloads: DownloadFileEntry[], + auth?: string, + id: string, + abortSignal?: AbortSignal, + setProgress?: (progress: number, state: "download" | "extract", info: Partial>) => void, + extract_path?: string; + path_fs?: string; + +}): Promise +{ + const downloadedFiles = await plugins.hooks.downloadFiles.promise({ + id: ctx.id, + auth: ctx.auth, + files: ctx.downloads, + downloadPath: config.get('downloadPath'), + abortSignal: ctx.abortSignal, + updateProgress: (stats) => ctx.setProgress?.(stats.progress, 'download', stats) + }); + + if (!downloadedFiles) + { + return; + } + + const finalFiles = await plugins.hooks.postDownloadFiles.promise({ + files: downloadedFiles.files, + source: downloadedFiles.source, + extract_path: ctx.extract_path, + downloadPath: config.get('downloadPath'), + path_fs: ctx.path_fs + }) ?? downloadedFiles.files; + + return finalFiles; } \ No newline at end of file diff --git a/src/bun/api/hooks/app.ts b/src/bun/api/hooks/app.ts deleted file mode 100644 index bf592a0..0000000 --- a/src/bun/api/hooks/app.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AuthHooks } from "./auth"; -import { EmulatorHooks } from "./emulators"; -import { GameHooks } from "./games"; - -export class GameflowHooks -{ - games = new GameHooks(); - emulators = new EmulatorHooks(); - auth = new AuthHooks(); -} \ No newline at end of file diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts deleted file mode 100644 index f48ea9f..0000000 --- a/src/bun/api/hooks/emulators.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AsyncSeriesBailHook } from "tapable"; - -export class EmulatorHooks -{ - fetchBiosDownload = new AsyncSeriesBailHook<[ctx: { - emulator: string; - systems: EmulatorSystem[]; - biosFolder: string; - }], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']); -} \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts deleted file mode 100644 index ff3ec04..0000000 --- a/src/bun/api/hooks/games.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { EmulatorPackageType, GameListFilterType } from '@/shared/constants'; -import { SyncBailHook, AsyncSeriesHook, SyncWaterfallHook, AsyncSeriesBailHook, AsyncHook, AsyncParallelHook, SyncHook, AsyncSeriesWaterfallHook } from 'tapable'; - -export class GameHooks -{ - /** override the launch command for an emulator - * @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing - * @param ctx.emulator The emulator ID if any - * @param ctx.game.source The source of the game - * @param ctx.game.sourceId The ID of the source. This could be for example the ROMM ID the game was - * @returns The argument list to be used when running the emulator. - * If no emulator bin in the command entry is found the actual command will be used as the bin. - */ - emulatorLaunch = new AsyncSeriesBailHook<[ctx: { - autoValidCommand: CommandEntry; - game: { - source: string; - id: number; - }; - }], string[] | undefined>(['ctx']); - /** - * Fetches and returns a list of games converted to frontend. - * @param ctx.localGameIds This is local game ids in the format '@' - */ - fetchGames = new AsyncSeriesHook<[ctx: { - query: GameListFilterType; - games: FrontEndGameType[]; - }]>(['ctx']); - fetchGame = new AsyncSeriesBailHook<[ctx: { - source: string; - localGame?: FrontEndGameTypeDetailed; - id: string; - }], FrontEndGameTypeDetailed | undefined>(['ctx']); - /** Get download file URLs - * @param ctx.checksum Check if file already exists using checksums - */ - fetchDownloads = new AsyncSeriesBailHook<[ctx: { - source: string; - id: string; - }], DownloadInfo | undefined>(['ctx']); - fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: { - game: FrontEndGameTypeDetailed, - games: (FrontEndGameType & { metadata?: any; })[]; - }]>(['ctx']); - fetchRecommendedGamesForEmulator = new AsyncSeriesHook<[cts: { - emulator: EmulatorPackageType; - systems: EmulatorSystem[]; - games: FrontEndGameType[]; - }]>(['ctx']); - fetchPlatform = new AsyncSeriesBailHook<[ctx: { - source: string; - id: string; - }], FrontEndPlatformType | undefined>(['ctx']); - platformLookup = new AsyncSeriesBailHook<[ctx: { - source: string; - id: string; - }], { slug: string; } | undefined>(['ctx']); - fetchPlatforms = new AsyncSeriesHook<[ctx: { - platforms: FrontEndPlatformType[]; - }]>(['ctx']); - updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]); - fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']); - fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); -} \ No newline at end of file diff --git a/src/bun/api/jobs/bios-download-job.ts b/src/bun/api/jobs/bios-download-job.ts index be46c5f..7a4edba 100644 --- a/src/bun/api/jobs/bios-download-job.ts +++ b/src/bun/api/jobs/bios-download-job.ts @@ -1,35 +1,44 @@ -import z from "zod"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { config, plugins } from "../app"; import { simulateProgress } from "@/bun/utils"; import { Downloader } from "@/bun/utils/downloader"; import path from 'node:path'; import { ensureDir } from "fs-extra"; import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService"; +import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared"; -export class BiosDownloadJob implements IJob, "download"> +interface BiosDownloadJobData extends DownloadJobData +{ + emulator: string; +} + +export class BiosDownloadJob implements IJob { static id = "bios-download-job" as const; - static dataSchema = z.object({ emulator: z.string() }); static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`; group: string = "bios-download"; - emulator: string; + data: BiosDownloadJobData; dryRun: boolean; constructor(emulator: string, init?: { dryRun?: boolean; }) { - this.emulator = emulator; + this.data = { + emulator, + name: "Download Emulator Bios" + }; this.dryRun = init?.dryRun ?? false; } - async start (context: JobContext, "download">, z.infer, "download">) + async start (context: JobContext, BiosDownloadJobData, "download">) { - const emulator = await getStoreEmulatorPackage(this.emulator); + const emulator = await getStoreEmulatorPackage(this.data.emulator); if (!emulator) throw new Error("Could Not Find Emulator"); + this.data.name = `${emulator.name} Bios`; + this.data.preview_url = emulator.logo; const systems = await buildStoreFrontendEmulatorSystems(emulator); - const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); + const biosFolder = path.join(config.get('downloadPath'), "bios", this.data.emulator); await ensureDir(biosFolder); - const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.emulator, systems, biosFolder }); + const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.data.emulator, systems, biosFolder }); if (!files) throw new Error("Could not find source to download from"); @@ -45,9 +54,12 @@ export class BiosDownloadJob implements IJob { context.setProgress(stats.progress, "download"); + this.data.downloaded = stats.downloaded; + this.data.speed = stats.speed; + this.data.total = stats.total; }, }); @@ -57,6 +69,6 @@ export class BiosDownloadJob implements IJob, EmulatorDownloadStates> +interface EmulatorDownloadJobData extends DownloadJobData +{ + emulator: string; +} + +export class EmulatorDownloadJob implements IJob { static id = "download-emulator" as const; - static dataSchema = z.object({ emulator: z.string() }); - emulator: string; downloadSource: string; emulatorPackage?: EmulatorPackageType; - dryRun?: boolean; + dryRun: boolean; + isUpdate: boolean; + data: EmulatorDownloadJobData = { + name: "Download Emulator", + emulator: "" + }; - constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; }) + constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; }) { - this.emulator = emulator; + this.data.emulator = emulator; this.downloadSource = downloadSource; this.dryRun = init?.dryRun ?? false; + this.isUpdate = init?.isUpdate ?? false; } - async start (context: JobContext, EmulatorDownloadStates>) + async start (context: JobContext) { - this.emulatorPackage = await getStoreEmulatorPackage(this.emulator); + this.emulatorPackage = await getStoreEmulatorPackage(this.data.emulator); if (!this.emulatorPackage) throw new Error("Emulator not found"); - if (!this.emulatorPackage.downloads) throw new Error("Emulator has no downloads"); + this.data.name = this.emulatorPackage.name; + this.data.preview_url = this.emulatorPackage.logo; + const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource); - const validDownloads = this.emulatorPackage.downloads[`${process.platform}:${process.arch}`]; - if (!validDownloads) throw new Error(`Now downloads in ${this.emulatorPackage.name} for platform ${process.platform}:${process.arch}`); - - const validDownload = validDownloads.find(d => d.type === this.downloadSource); - if (!validDownload) throw new Error(`Download type ${this.downloadSource} not found`); - - let downloadUrl: URL; - if (validDownload.type === 'github') - { - console.log("Trying To Download from ", `https://api.github.com/repos/${validDownload.path}/releases/latest`); - const latestRelease = await getOrCachedGithubRelease(validDownload.path); - const glob = new Glob(validDownload.pattern); - const validAsset = latestRelease.assets.find(a => glob.match(a.name)); - if (!validAsset) throw new Error("Could Not Find Valid Asset"); - downloadUrl = new URL(validAsset.browser_download_url); - } else if (validDownload.type === 'direct') - { - downloadUrl = new URL(validDownload.url); - } else - { - throw new Error("Download Type Unsupported"); - } - - const emulatorsFolder = path.join(config.get('downloadPath'), "emulators", this.emulator); + const emulatorsFolder = getEmulatorPath(this.data.emulator); if (this.dryRun) { @@ -69,41 +57,54 @@ export class EmulatorDownloadJob implements IJob { context.setProgress(stats.progress, 'download'); + this.data.total = stats.total; + this.data.downloaded = stats.downloaded; + this.data.speed = stats.speed; }, }); const destinationPaths = await downloader.start(); + context.abortSignal.throwIfAborted(); if (destinationPaths) { - const isArchive = destinationPaths[0].endsWith('.7z') || destinationPaths[0].endsWith('.zip'); + const archive = isArchive(destinationPaths[0]); const isAppImage = destinationPaths[0].endsWith(".AppImage"); - if (!isArchive && !isAppImage) + if (!archive && !isAppImage) { throw new Error("Invalid Download Type"); } - if (isArchive) + if (archive) { if (destinationPaths[0]) { let destinationPath = destinationPaths[0]; - await new Promise((resolve, reject) => + if (destinationPath.endsWith('.tar')) { - const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true }); - seven.on('progress', p => context.setProgress(p.percent, "extract")); - seven.on('error', e => reject(e)); - seven.on('end', () => resolve(true)); - }); - await fs.rm(destinationPath, { recursive: true }); + context.setProgress(0, "extract"); + await ensureDir(emulatorsFolder); + await $`tar -xf ${destinationPath} -C ${emulatorsFolder}`; + await fs.rm(destinationPath, { recursive: true }); + } else + { + await new Promise((resolve, reject) => + { + const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true }); + seven.on('progress', p => context.setProgress(p.percent, "extract")); + seven.on('error', e => reject(e)); + seven.on('end', () => resolve(true)); + }); + await fs.rm(destinationPath, { recursive: true }); + } // check if 1 root folder we need to get rid of const contents = await fs.readdir(emulatorsFolder); @@ -127,6 +128,19 @@ export class EmulatorDownloadJob implements IJob e.type === 'store')?.binPath ?? emulatorsFolder, + info, + update: this.isUpdate + }); } } @@ -134,7 +148,7 @@ export class EmulatorDownloadJob implements IJob +{ + static id = "update-store" as const; + static dataSchema = z.never(); + packageName: string; + storeVersion: string; + + constructor() + { + this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store"; + this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0"; + } + + async start (context: JobContext) + { + const storeFolder = getStoreRootFolder(); + await ensureDir(storeFolder); + const storePackageFile = Bun.file(path.join(storeFolder, "package.json")); + if (!await storePackageFile.exists()) + { + await storePackageFile.write(JSON.stringify({ dependencies: {} }, null, 3)); + } + + const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json(); + + if (IsPluginAllowed(sdkPkg.name)) + { + if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) + { + let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } + + // probably just means we couldn't find a version of the sdk, just install latest + if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) + { + let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } + } else + { + console.log("Ignoring SDK package"); + } + + if (process.env.CUSTOM_STORE_PATH) return; + + if (!storePackage.dependencies?.['@simeonradivoev/gameflow-store']) + { + context.setProgress(0.5, "Adding Store"); + let response = await runBunPackageCommand(["add", `${this.packageName}@${this.storeVersion}`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } + } +} \ No newline at end of file diff --git a/src/bun/api/jobs/import-job.ts b/src/bun/api/jobs/import-job.ts new file mode 100644 index 0000000..7f080c7 --- /dev/null +++ b/src/bun/api/jobs/import-job.ts @@ -0,0 +1,143 @@ +import { eq, inArray, or } from "drizzle-orm"; +import { db, plugins } from "../app"; +import { createLocalGame, downloadGame } from "../games/services/utils"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; +import * as schema from "@schema/app"; +import { DownloadJobData, GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; +import { isUrl } from "@/shared/utils"; +import { basename } from "node:path"; +import path from 'node:path'; +import { isArchive } from "@/bun/utils"; + +interface ImportJobData extends DownloadJobData +{ + localId: number | null; +} + +export class ImportJob implements IJob +{ + static id = "import-job" as const; + static query = (q: { source: string; id: string; }) => `${ImportJob.id}-${q.source}-${q.id}`; + data: ImportJobData = { + localId: null, + name: "Import Game" + }; + group?: 'import-job'; + gamePath: string; + source: string; + id: string; + platformId: number; + + constructor(source: string, id: string, gamePath: string, platformId: number) + { + this.gamePath = gamePath; + this.source = source; + this.id = id; + this.platformId = platformId; + } + + exposeData () + { + return this.data; + } + + async start (context: JobContext, ImportJobData, string>): Promise + { + const matchesMap = new Map(); + await plugins.hooks.games.gameLookup.promise(matchesMap, { source: this.source, id: this.id }); + const matches = matchesMap.values().next().value; + if (!matches || matches.length <= 0) throw Error("Could not Find Game"); + const match = matches[0]; + this.data.name = match.name; + this.data.preview_url = match.coverUrl; + + let cover: Buffer | undefined = undefined; + let coverType: string | undefined = undefined; + if (match.coverUrl) + { + const coverResponse = await fetch(match.coverUrl); + if (coverResponse.ok) + { + cover = Buffer.from(await coverResponse.arrayBuffer()); + coverType = coverResponse.headers.get('content-type') ?? undefined; + } + } + + const platformMatch = match.platforms.find(p => p.id === this.platformId); + + const finalFiles: string[] = []; + + if (isUrl(this.gamePath)) + { + const archive = isArchive(this.gamePath); + const downloadedFiles = await downloadGame({ + downloads: [{ + file_path: this.id, + file_name: basename(this.gamePath), + url: new URL(this.gamePath) + }], + extract_path: archive ? '.tmp' : undefined, + path_fs: path.join('roms', platformMatch?.slug ?? this.source, this.id), + abortSignal: context.abortSignal, + id: `game-${this.source}-${this.id}`, + setProgress: (progress, state, info) => + { + context.setProgress(progress, state); + this.data.speed = info.speed; + this.data.total = info.total; + this.data.downloaded = info.downloaded; + }, + }); + + if (downloadedFiles) + finalFiles.push(...downloadedFiles); + } else + { + finalFiles.push(this.gamePath); + } + + const localSearchFilters: any[] = []; + if (match.igdb_id) localSearchFilters.push(eq(schema.games.igdb_id, match.igdb_id)); + if (match.slug) localSearchFilters.push(eq(schema.games.slug, match.slug)); + localSearchFilters.push(eq(schema.games.name, match.name)); + localSearchFilters.push(inArray(schema.games.path_fs, finalFiles)); + const existingLocalGame = await db.query.games.findFirst({ where: or(...localSearchFilters) }); + context.abortSignal.throwIfAborted(); + + if (existingLocalGame) throw new Error("Game Already Exists"); + + this.data.localId = await createLocalGame({ + name: match.name, + system_slug: platformMatch?.slug, + source: undefined, + source_id: undefined, + slug: match.slug, + path_fs: finalFiles[0], + summary: match.summary, + igdb_id: match.igdb_id, + ra_id: undefined, + main_glob: undefined, + cover, + coverType, + version: undefined, + version_source: undefined, + screenshotUrls: match.screenshotUrls, + version_system: undefined, + platform: platformMatch ? { + source_slug: platformMatch.slug, + source_id: platformMatch.id, + source: this.source, + name: platformMatch.displayName + } : undefined, + metadata: { + game_modes: match.game_modes, + companies: match.companies, + first_release_date: match.first_release_date ?? undefined, + player_count: match.player_count, + age_ratings: match.age_ratings, + average_rating: match.average_rating, + genres: match.genres, + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index a45fe7b..3d3c867 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -1,30 +1,23 @@ -import { IJob, JobContext } from "../task-queue"; -import { and, eq, or } from 'drizzle-orm'; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import fs from 'node:fs/promises'; -import * as schema from "@schema/app"; -import * as emulatorSchema from "@schema/emulators"; import path from 'node:path'; -import { config, db, emulatorsDb, events, plugins } from "../app"; -import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; -import * as igdb from 'ts-igdb-client'; -import secrets from "../secrets"; +import { config, events, plugins } from "../app"; import { simulateProgress } from "@/bun/utils"; -import { Downloader } from "@/bun/utils/downloader"; -import Seven from 'node-7z'; import z from "zod"; -import { checkFiles } from "../games/services/utils"; +import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils"; import { ensureDir } from "fs-extra"; -import { path7za } from "7zip-bin"; +import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared"; interface JobConfig { dryRun?: boolean; dryDownload?: boolean; + downloadId?: string; } export type InstallJobStates = 'download' | 'extract'; -export class InstallJob implements IJob +export class InstallJob implements IJob { static id = "install-job" as const; static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`; @@ -35,6 +28,10 @@ export class InstallJob implements IJob // The local game ID of newly created entry, if successful public localGameId?: number; public group = InstallJob.id; + public localPath?: string; + data: DownloadJobData = { + name: "Install Game" + }; constructor(id: string, source: string, config?: JobConfig) { @@ -43,91 +40,47 @@ export class InstallJob implements IJob this.source = source; } - public async start (cx: JobContext) + public async start (cx: JobContext) { cx.setProgress(0, 'download'); await fs.mkdir(config.get('downloadPath'), { recursive: true }); const downloadPath = config.get('downloadPath'); + const finalFiles: string[] = []; let info: DownloadInfo | undefined; - switch (this.source) - { - case 'store': - const game = await getStoreGameFromId(this.gameId); - const gameId = extractStoreGameSourceId(this.gameId); - info = { - coverUrl: game.pictures.titlescreens[0], - screenshotUrls: game.pictures.screenshots, - files: [{ - url: new URL(game.file), - file_path: `roms/${game.system}`, - file_name: path.basename(decodeURI(game.file)), - size: 0 - }], - slug: this.gameId, - source_id: this.gameId, - name: game.title, - summary: game.description, - system_slug: gameId.system, - extract_path: path.join('roms', gameId.system), - }; - - break; - default: - info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId }); - break; - } - - if (!info) throw new Error(`Could not find downloader for source ${this.source}`); - - const files = await checkFiles(info.files, !!info.extract_path); - if (this.config?.dryRun !== true) { + const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); + info = allDownloads?.[0]; + + if (!info) throw new Error(`Could not find downloader for source ${this.source}`); + + this.data.name = info.name; + this.data.preview_url = info.coverUrl; + + const files = await checkFiles(info.files, !!info.extract_path); + if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches)) { - const headers: Record = {}; - if (info.auth) - headers['Authorization'] = info.auth; - const downloader = new Downloader(`game-${this.source}-${this.gameId}`, - files.filter(f => !f.exists || !f.matches), - config.get('downloadPath'), + const downloadedFiles = await downloadGame({ + downloads: files.filter(f => !f.exists || !f.matches), + extract_path: info.extract_path, + path_fs: info.path_fs, + abortSignal: cx.abortSignal, + auth: info.auth, + id: `game-${this.source}-${this.gameId}`, + setProgress: (process, state, info) => { - signal: cx.abortSignal, - headers, - onProgress (stats) - { - cx.setProgress(stats.progress, 'download'); - }, - }); + cx.setProgress(process, state); + this.data.downloaded = info.downloaded; + this.data.speed = info.speed; + this.data.total = info.total; + }, + }); - const downloadedFiles = await downloader.start(); - if (info.extract_path && downloadedFiles) - { - let progress = 0; - const progressDelta = 1 / downloadedFiles.length; - for (const filePath of downloadedFiles) - { - const extractPath = path.join(config.get('downloadPath'), info.extract_path); - await new Promise((resolve, reject) => - { - const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true }); - seven.on('progress', p => - { - cx.setProgress(progress + p.percent * progressDelta, "extract"); - }); - - seven.on('error', e => reject(e)); - seven.on('end', async () => - { - await fs.rm(filePath); - resolve(true); - }); - }); - progress += progressDelta * 100; - } - } + if (downloadedFiles) + finalFiles.push(...downloadedFiles); } if (this.config?.dryDownload === true && info.extract_path) @@ -138,138 +91,34 @@ export class InstallJob implements IJob const coverResponse = await fetch(info.coverUrl); const cover = Buffer.from(await coverResponse.arrayBuffer()); - if (cx.abortSignal.aborted) return; + cx.abortSignal.throwIfAborted(); - await db.transaction(async (tx) => - { - // Search for existing platform - const platformSearch = [eq(schema.platforms.slug, info.system_slug)]; - const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, info.system_slug)]; - - if (info.platform) - { - if (info.platform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, info.platform.igdb_id)); - if (info.platform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, info.platform.igdb_slug)); - if (info.platform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, info.platform.ra_id)); - if (info.platform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, info.platform.moby_id)); - - esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm')); - esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, info.platform.slug)); - } - - const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ - with: { system: true }, - where: and(...esPlatformSearch) - }); - - if (esPlatform) - platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name)); - - let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) }); - let platformId: number; - if (!existingPlatform) - { - // TODO: use something else than the romm demo as CDN - const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.system_slug}.svg`); - - if (!esPlatform && !info.platform) - { - // go to unknown platform - existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); - - if (existingPlatform) - { - platformId = existingPlatform.id; - } else - { - const [{ id }] = await tx.insert(schema.platforms).values({ - slug: 'unknown', - name: "Unknown" - }).returning({ id: schema.platforms.id }); - platformId = id; - } - } else - { - // Create new local platform - const platform: typeof schema.platforms.$inferInsert = { - slug: info.platform?.slug ?? esPlatform?.system.name ?? '', - igdb_id: info.platform?.igdb_id, - igdb_slug: info.platform?.igdb_slug, - ra_id: info.platform?.ra_id, - cover: Buffer.from(await platformCover.arrayBuffer()), - cover_type: platformCover.headers.get('content-type'), - name: info.platform?.name ?? esPlatform?.system.fullname ?? '', - family_name: info.platform?.family_name, - es_slug: esPlatform?.system.name ?? undefined - }; - - // TODO: add ES slug once I have better way to query ES - const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id }); - platformId = id; - } - - } else - { - platformId = existingPlatform.id; - } - - // create the rom - const game: typeof schema.games.$inferInsert = { - source_id: info.source_id, - source: this.source, - slug: info.slug, - path_fs: info.path_fs, - last_played: info.last_played, - platform_id: platformId, - igdb_id: info.igdb_id, - ra_id: info.ra_id, - summary: info.summary, - name: info.name, - cover, - cover_type: coverResponse.headers.get('content-type'), - metadata: info.metadata - }; - - const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); - - if (info.screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID) - { - const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); - if (access_token) - { - const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); - - const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', info.igdb_id)).execute(); - - info.screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!)); - } - } - - // pre-fetch screenshots - const screenshots = await Promise.all(info.screenshotUrls.map(s => fetch(s))); - - if (screenshots.length > 0) - { - await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) => - { - const screenshot: typeof schema.screenshots.$inferInsert = { - game_id: id, - content: Buffer.from(await response.arrayBuffer()), - type: response.headers.get('content-type') - }; - - return screenshot; - }))); - } - - this.localGameId = id; + this.localGameId = await createLocalGame({ + cover, + coverType: coverResponse.headers.get('content-type'), + system_slug: info.system_slug, + source_id: info.source_id, + source: this.source, + slug: info.slug, + path_fs: info.path_fs ?? (info.extract_path ? path.join(downloadPath, info.extract_path) : undefined), + summary: info.summary, + igdb_id: info.igdb_id, + ra_id: info.ra_id, + name: info.name, + main_glob: info.main_glob, + version: info.version, + version_source: info.version_source, + screenshotUrls: info.screenshotUrls, + version_system: info.version_system, + metadata: info.metadata, + platform: info.platform }); + + if (this.source && this.gameId) await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info }); + events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 }); } else { await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal); } - - - events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 }); } } \ No newline at end of file diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 8f836a5..5471e56 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -3,21 +3,24 @@ import z, { _ZodType } from "zod"; import { taskQueue } from "../app"; import { LoginJob } from "./login-job"; import TwitchLoginJob from "./twitch-login-job"; -import UpdateStoreJob from "./update-store"; +import EnsureStore from "./ensure-store"; import { EmulatorDownloadJob } from "./emulator-download-job"; import { getErrorMessage } from "@/bun/utils"; -import { IJob } from "../task-queue"; +import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue"; import { LaunchGameJob } from "./launch-game-job"; import { BiosDownloadJob } from "./bios-download-job"; import { InstallJob } from "./install-job"; +import ReloadPluginsJob from "./reload-plugins-job"; +import { FrontEndJob } from "@simeonradivoev/gameflow-sdk/shared"; function registerJob< const Path extends string, - const Schema extends z.ZodTypeAny, - const Query extends z.ZodTypeAny, + Schema, const States extends string, - T extends IJob, States> -> (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T)) +> (_job: { + id: Path; + query?: (q: any) => string; +} & (new (...args: any[]) => IJob)) { return new Elysia().ws(_job.id, { body: z.discriminatedUnion('type', [ @@ -29,9 +32,10 @@ function registerJob< type: z.literal(['data', 'started', 'progress']), state: z.string().optional(), progress: z.number(), - data: _job.dataSchema + data: z.custom() }), - z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }), + z.object({ type: z.literal(['completed', 'ended']), data: z.custom() }), + z.object({ type: z.literal('waiting') }), z.object({ type: z.literal('error'), error: z.string() }) ]), open (ws) @@ -40,7 +44,10 @@ function registerJob< const job = taskQueue.findJob(jobId, _job); if (job) { - ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() }); + ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() as Schema }); + } else + { + ws.send({ type: 'waiting' }); } (ws.data as any).cleanup = [ @@ -97,10 +104,88 @@ function registerJob< } export const jobs = new Elysia({ prefix: '/api/jobs' }) + .ws('/list', { + response: z.discriminatedUnion('type', [ + z.object({ type: z.literal("allJobs"), active: z.custom().array(), queued: z.custom().array() }), + z.object({ type: z.literal("started"), job: z.custom() }), + z.object({ type: z.literal("progress"), job: z.custom() }), + z.object({ type: z.literal("queued"), job: z.custom() }), + z.object({ type: z.literal("aborted"), id: z.string() }), + z.object({ type: z.literal("ended"), id: z.string() }), + ]), + body: z.discriminatedUnion('type', [ + z.object({ type: z.literal("cancel"), id: z.string() }) + ]), + message (ws, message) + { + switch (message.type) + { + case "cancel": + taskQueue.cancelJob(message.id); + break; + } + }, + open (ws) + { + ws.send({ + type: 'allJobs', + active: taskQueue.getActiveJobs().map(j => + { + const job: FrontEndJob = { + id: j.id, + data: j.job.exposeData?.(), + progress: j.progress, + state: j.state, + status: j.status + }; + + return job; + }), + queued: taskQueue.getQueuedJobs()?.map(j => + { + const job: FrontEndJob = { + id: j.id, + data: j.job.exposeData?.(), + progress: j.progress, + state: j.state, + status: j.status + }; + + return job; + }) ?? [] + }); + + (ws.data as any).dispose = [taskQueue.on('started', (e: BaseEvent) => + { + ws.send({ type: "started", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } }); + }), + taskQueue.on('progress', (e: BaseEvent) => + { + ws.send({ type: "progress", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } }); + }), + taskQueue.on('queued', (e: BaseEvent) => + { + ws.send({ type: "queued", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } }); + }), + taskQueue.on('abort', (e: BaseEvent) => + { + ws.send({ type: "aborted", id: e.id }); + }), + taskQueue.on('ended', (e: BaseEvent) => + { + ws.send({ type: "ended", id: e.id }); + })]; + }, + close (ws, code, reason) + { + (ws.data as any).dispose.forEach((d: any) => d()); + }, + }) + .use(registerJob(LaunchGameJob)) .use(registerJob(LoginJob)) .use(registerJob(TwitchLoginJob)) - .use(registerJob(UpdateStoreJob)) - .use(registerJob(LaunchGameJob)) + .use(registerJob(EnsureStore)) .use(registerJob(BiosDownloadJob)) .use(registerJob(InstallJob)) + .use(registerJob(ReloadPluginsJob)) .use(registerJob(EmulatorDownloadJob)); diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 91004bb..3ce0e83 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,143 +1,272 @@ 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 { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; +import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk"; +import { config, db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; -import { eq, sql } from "drizzle-orm"; -import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import { eq } from "drizzle-orm"; +import { spawn } from 'node:child_process'; +import { updateLocalLastPlayed } from "../games/services/statusService"; +import { getErrorMessage } from "@/bun/utils"; +import { CommandEntry, FrontEndId, SaveSlots } from "@simeonradivoev/gameflow-sdk/shared"; -export class LaunchGameJob implements IJob, "playing"> +export class LaunchGameJob implements IJob, string> { static id = "launch-game" as const; - static dataSchema = z.optional(ActiveGameSchema); + static dataSchema = z.nullable(ActiveGameSchema); group = "launch-game"; - activeGame?: ActiveGameType; - gameId: number; + activeGame: ActiveGameType | null; + gameId: FrontEndId; validCommand: CommandEntry; - gameSource: string; - gameSourceId: string; + gameSource?: string; + gameSourceId?: string; + changedSaveFiles: Map; + saveSlots: SaveSlots = {}; - constructor(gameId: number, validCommand: CommandEntry, source: string, sourceId: string) + constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string) { this.gameId = gameId; this.validCommand = validCommand; this.gameSource = source; this.gameSourceId = sourceId; + this.activeGame = null; + this.changedSaveFiles = new Map(); } - async start (context: JobContext, "playing">, z.infer, "playing">) + async postPlay (gameInfo: { platformSlug?: string; }) { - const localGame = await db.query.games.findFirst({ - where: eq(appSchema.games.id, this.gameId), columns: { - name: true, - source_id: true, - source: true - } + if (this.gameId.source === 'local') + { + await updateLocalLastPlayed(Number(this.gameId.id)); + } + + const source = this.gameSource ?? this.gameId.source; + const id = this.gameSourceId ?? this.gameId.id; + + await new Promise(async (resolve) => + { + await plugins.hooks.games.postPlay.promise( + { + source, + id, + command: this.validCommand, + changedSaveFiles: Array.from(this.changedSaveFiles.values()), + validChangedSaveFiles: {}, + saveFolderSlots: this.saveSlots, + gameInfo + }).catch(e => + { + console.error(e); + events.emit('notification', { message: getErrorMessage(e), type: 'error' }); + }).then(() => resolve(false)); + const timeoutHandler = () => resolve(false); + setTimeout(timeoutHandler, 5000); }); + } + + prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; }) + { + return plugins.hooks.games.prePlay.promise({ + source: this.gameSource ?? this.gameId.source, + id: this.gameSourceId ?? this.gameId.id, + saveFolderSlots: this.saveSlots, + command: this.validCommand, + setProgress: setProgress, + gameInfo + }); + } + + async start (context: JobContext, string>, z.infer, string>) + { + let gameInfo: { name?: string, source_id?: string, source?: string; platformSlug?: string; } | undefined = undefined; + if (this.gameId.source === 'emulator') + { + gameInfo = { name: this.gameId.id }; + } else + { + const localGame = await db.query.games.findFirst({ + where: eq(appSchema.games.id, Number(this.gameId.id)), columns: { + name: true, + source_id: true, + source: true, + }, + with: { + platform: { + columns: { + es_slug: true, + slug: true + } + } + } + }); + + if (localGame) + gameInfo = { + name: localGame.name ?? undefined, + source_id: localGame.source_id ?? undefined, + source: localGame.source ?? undefined, + platformSlug: localGame.platform.es_slug ?? localGame.platform.slug + }; + } const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({ autoValidCommand: this.validCommand, - game: { source: this.gameSource, id: this.gameId } + game: { + source: this.gameSource, + sourceId: this.gameSourceId, + id: this.gameId, + platformSlug: gameInfo?.platformSlug + }, + dryRun: false }); - await new Promise((resolve, reject) => + await new Promise(async (resolve, reject) => { - let game: any; - if (!commandArgs) + try { - // ES-DE commands require shell execution. Some emulators fail otherwise. - const spawnGame = spawn(this.validCommand.command, { - shell: true, - cwd: this.validCommand.startDir, - signal: context.abortSignal - }); - - spawnGame.stdout.on('data', data => console.log(data)); - spawnGame.on('close', (code) => + let game: any; + if (!commandArgs) { - resolve(code); - }); - spawnGame.on('error', e => + await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(e)); + + if (Array.isArray(this.validCommand.command)) + { + let command = this.validCommand.command; + if (process.env.FLATPAK_BUILD) command = ['flatpak-spawn', '--host', `--directory=${config.get('downloadPath')}`, ...command]; + + const bunGame = Bun.spawn(command, { + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + ...process.env, + ...this.validCommand.env + }, + onExit (subprocess, exitCode, signalCode, error) + { + if (error) + { + console.error(error); + reject(error); + } else + { + resolve(true); + } + }, + }); + + context.setProgress(0, "playing"); + + game = bunGame; + } else + { + + let command = this.validCommand.command; + + if (process.env.FLATPAK_BUILD) command = `flatpak-spawn --host --directory=${config.get('downloadPath')} ${command}`; + + // ES-DE commands require shell execution. Some emulators fail otherwise. + const spawnGame = spawn(command, { + shell: this.validCommand.shell ?? true, + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + ...process.env, + ...this.validCommand.env + }, + }); + + context.setProgress(0, "playing"); + + spawnGame.stdout.on('data', data => console.log(data)); + spawnGame.on('close', (code) => + { + resolve(code); + }); + spawnGame.on('error', e => + { + console.error(e); + resolve(1); + }); + + game = spawnGame; + } + } + else if (this.validCommand.metadata.emulatorBin) { - console.error(e); - reject(e); - }); + this.saveSlots = commandArgs.savesPath ?? {}; - game = spawnGame; - } - else if (this.validCommand.metadata.emulatorBin) - { - // We have full control over launching integrated emulators better to use bun spawn - const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], { - cwd: this.validCommand.startDir, - signal: context.abortSignal - }); + await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }); - bunGame.exited.then(resolve).catch(e => + let command = [this.validCommand.metadata.emulatorBin, ...commandArgs.args]; + if (process.env.FLATPAK_BUILD) command = ['flatpak-spawn', '--host', `--directory=${config.get('downloadPath')}`, ...command]; + + // We have full control over launching integrated emulators better to use bun spawn + const bunGame = Bun.spawn(command, { + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + ...process.env, + ...commandArgs.env + }, + onExit (subprocess, exitCode, signalCode, error) + { + if (error) + { + console.error(error); + reject(error); + } else + { + resolve(true); + } + }, + }); + + context.setProgress(0, "playing"); + + // TODO: this isn't really useful, maybe add it later if needed + /*if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath)) + { + const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal }); + console.log("Starting To Watch", commandArgs.savesPath, "for save file changes"); + savesWatcher.on('change', (type, filename) => + { + if (typeof filename === 'string') + { + console.log("Save File Changed", filename); + this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! }); + } + }); + + bunGame.exited.then(() => + { + savesWatcher.close(); + console.log("Closing Save File Watching for", commandArgs.savesPath); + }); + }*/ + + game = bunGame; + + } else { - console.error(e); - reject(e); - }); - game = bunGame; - } else - { - reject(new Error("No Emulator Bin")); - return; - } + reject(new Error("No Emulator Bin")); + return; + } - this.activeGame = { - process: game, - name: localGame?.name ?? "Unknown", - gameId: this.gameId, - command: this.validCommand - }; - - const updatePlayed = async (source: string, id: string) => + this.activeGame = { + process: game, + name: gameInfo?.name ?? "Unknown", + gameId: this.gameId, + source: this.gameSource, + sourceId: this.gameSourceId, + command: this.validCommand + }; + } catch (e) { - await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, this.gameId)); - await plugins.hooks.games.updatePlayed.promise({ source, id }).then(v => - { - if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' }); - }); - }; - - if (this.gameSource !== 'local') - { - updatePlayed(this.gameSource, this.gameSourceId); - } - else if (localGame?.source && localGame?.source !== 'local' && localGame.source_id) - { - updatePlayed(localGame.source, localGame.source_id); + context.abort(e); + resolve(e); } }); - /* Old spawn lanching, cases issues, needs to be ran as shell - - const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]); - const game = setActiveGame({ - process: Bun.spawn({ - cmd, - env: { - ...process.env - }, - onExit (subprocess, exitCode, signalCode, error) - { - events.emit('activegameexit', { subprocess, exitCode, signalCode, error }); - }, - stdin: "ignore", - stdout: "inherit", - stderr: "inherit", - }), - name: localGame?.name ?? "Unknown", - gameId: validCommand.gameId, - command: validCommand.command.command - }); - - await game.process.exited; - if (game.process.exitCode && game.process.exitCode > 0) - { - return status('Internal Server Error'); - }*/ + await this.postPlay({ platformSlug: gameInfo?.platformSlug }); } exposeData () diff --git a/src/bun/api/jobs/login-job.ts b/src/bun/api/jobs/login-job.ts index f0726bd..fb5d69a 100644 --- a/src/bun/api/jobs/login-job.ts +++ b/src/bun/api/jobs/login-job.ts @@ -1,5 +1,5 @@ import Elysia, { status } from "elysia"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { LOGIN_PORT, SERVER_URL } from "@/shared/constants"; import { host, localIp } from "@/bun/utils/host"; import cors from "@elysiajs/cors"; diff --git a/src/bun/api/jobs/plugin-operation-job.ts b/src/bun/api/jobs/plugin-operation-job.ts new file mode 100644 index 0000000..db39819 --- /dev/null +++ b/src/bun/api/jobs/plugin-operation-job.ts @@ -0,0 +1,62 @@ +import z from "zod"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; +import { plugins } from "../app"; +import { canUninstall, runBunPackageCommand } from "../plugins/services"; +import { getPlugin, registerPlugin, unregisterPlugin } from "../plugins/register-plugins"; +import { PluginRegistry } from "@/shared/constants"; + +export default class PluginOperationJob implements IJob +{ + static id = "plugin-operation-job" as const; + static dataSchema = z.never(); + group = "plugin-operations"; + operation: "add" | "update" | "remove"; + plugin: string; + + constructor(operation: "add" | "update" | "remove", plugin: string) + { + this.plugin = plugin; + this.operation = operation; + } + + async start (context: JobContext, never, string>) + { + switch (this.operation) + { + case "add": + //TODO: find the latest compatible version with the current sdk version + const addResponse = await runBunPackageCommand(["add", this.plugin, '--omit', 'peer', "--registry", PluginRegistry]); + console.log(addResponse); + const addPlugin = await getPlugin(this.plugin, plugins); + if (!addPlugin) throw new Error(`${this.plugin} Not Found`); + await registerPlugin(addPlugin, 'store', plugins); + break; + case "update": + const existingPlugin = plugins.plugins[this.plugin]; + if (!existingPlugin) throw new Error(`${this.plugin} Not Found`); + if (!existingPlugin.update?.new) throw new Error(`No Update Found`); + let updatePlugin = await getPlugin(this.plugin, plugins); + if (!updatePlugin) throw new Error(`${this.plugin} Not Found`); + await unregisterPlugin(this.plugin, plugins); + const updateResponse = await runBunPackageCommand(["update", `${this.plugin}@${existingPlugin.update?.new}`, '--omit', 'peer', "--registry", PluginRegistry, '--latest']); + console.log(updateResponse); + updatePlugin = await getPlugin(this.plugin, plugins); + if (!updatePlugin) throw new Error(`Something Went Wrong during update. Missing Plugin: ${this.plugin}`); + await registerPlugin(updatePlugin, existingPlugin.source, plugins); + break; + case "remove": + const removePlugin = plugins.plugins[this.plugin]; + if (!removePlugin) throw new Error(`${this.plugin} Not Found`); + if (!canUninstall(removePlugin.description, removePlugin.source)) + { + throw new Error("Uninstall Not Allowed"); + } + const response = await runBunPackageCommand(['remove', this.plugin, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + await unregisterPlugin(this.plugin, plugins); + break; + } + + + } +} \ No newline at end of file diff --git a/src/bun/api/jobs/reload-plugins-job.ts b/src/bun/api/jobs/reload-plugins-job.ts new file mode 100644 index 0000000..5e404d3 --- /dev/null +++ b/src/bun/api/jobs/reload-plugins-job.ts @@ -0,0 +1,15 @@ +import z from "zod"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; +import { plugins } from "../app"; + +export default class ReloadPluginsJob implements IJob +{ + static id = "reload-plugins-job" as const; + static dataSchema = z.never(); + group = "reload-plugins"; + + async start (context: JobContext, never, string>) + { + await plugins.reloadAll(context); + } +} \ No newline at end of file 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..ca2684e --- /dev/null +++ b/src/bun/api/jobs/self-update-job.ts @@ -0,0 +1,121 @@ +import z from "zod"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; +import { events } from "../app"; +import { Downloader } from "@/bun/utils/downloader"; +import path from 'node:path'; +import os from "node:os"; +import winUpdateScript from '@/bun/utils/update-gameflow-windows.bat' with { type: "text" }; +import linuxUpdateScript from '@/bun/utils/update-gameflow-linux.sh' with { type: "text" }; +import mustache from "mustache"; +import pkg from '~/package.json'; +import { sleep } from "bun"; + +export default class SelfUpdateJob implements IJob +{ + 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)); + if (!validAsset) + { + validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-*.zip`).match(e.name)); + } + break; + case "linux": + validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.AppImage`).match(e.name)); + if (!validAsset) + { + validAsset = data.assets.find((e: any) => new Bun.Glob(`*.AppImage`).match(e.name)); + } + break; + default: + events.emit('notification', { message: "Unsupported Platfrom", title: 'Failed Update', type: "error" }); + return; + } + + if (!validAsset) + { + events.emit('notification', { message: "Could not find download", title: 'Failed Update', type: "error" }); + return; + } + + console.log("Found Download", validAsset.browser_download_url); + console.log("Starting Download"); + + switch (process.platform) + { + case "linux": + const appimage = process.env.APPIMAGE; + if (!appimage) + { + events.emit('notification', { + message: "Only AppImage supported", + title: 'Failed Update', + type: 'error' + }); + return; + } + const linuxDownloads = await this.downloadUpdate(new URL(validAsset.browser_download_url), undefined, path.basename(appimage), context); + if (!linuxDownloads) return; + const shPath = path.join(os.tmpdir(), "update-gameflow.sh"); + await Bun.write(shPath, mustache.render(linuxUpdateScript, { + tempFile: linuxDownloads[0], + appImagePath: appimage + })); + context.setProgress(0, "Restarting App To Update"); + events.emit('exitapp'); + Bun.spawn(["bash", shPath], { detached: true }); + process.exit(0); + case "win32": + const winDownloads = await this.downloadUpdate(new URL(validAsset.browser_download_url), undefined, "Gameflow-update.zip", context); + if (!winDownloads) return; + const batPath = path.join(os.tmpdir(), "update-gameflow.bat"); + await Bun.write(batPath, mustache.render(winUpdateScript, { + tempFile: winDownloads[0], + installDir: path.dirname(process.execPath), + extractDir: path.join(os.tmpdir(), 'gameflow-update-extract'), + exePath: `${pkg.bin}.exe` + })); + context.setProgress(0, "Restarting App To Update"); + events.emit('exitapp'); + Bun.spawn(["cmd", "/c", "start", "cmd", "/c", batPath], { detached: true }); + process.exit(0); + } + + } else + { + events.emit('notification', { message: latest.statusText, title: 'Failed Update', type: "error" }); + } + } +} \ No newline at end of file diff --git a/src/bun/api/jobs/test-download-job.ts b/src/bun/api/jobs/test-download-job.ts new file mode 100644 index 0000000..313b00b --- /dev/null +++ b/src/bun/api/jobs/test-download-job.ts @@ -0,0 +1,30 @@ +import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; +import { sleep } from "bun"; + +export class TestDownloadJob implements IJob +{ + data: DownloadJobData = { + speed: 1686, + downloaded: 0, + total: 6615841, + name: "Test Download Job" + }; + + group = "test-download"; + + async start (context: JobContext, DownloadJobData, string>): Promise + { + for (let i = 0; i < 10; i++) + { + await sleep(1000); + context.setProgress(i / 10 * 100, 'download'); + if (context.abortSignal.aborted) return; + } + } + exposeData (): DownloadJobData + { + return this.data; + } + +} \ No newline at end of file diff --git a/src/bun/api/jobs/twitch-login-job.ts b/src/bun/api/jobs/twitch-login-job.ts index 1023e83..42d98a9 100644 --- a/src/bun/api/jobs/twitch-login-job.ts +++ b/src/bun/api/jobs/twitch-login-job.ts @@ -1,4 +1,4 @@ -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; import secrets from "../secrets"; import open from "open"; import z from "zod"; diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/update-store.ts deleted file mode 100644 index cd99584..0000000 --- a/src/bun/api/jobs/update-store.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ensureDir } from "fs-extra"; -import { IJob, JobContext } from "../task-queue"; -import { getStoreRootFolder } from "../store/services/gamesService"; -import { STORE_VERSION } from "@/shared/constants"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import z from "zod"; - -export default class UpdateStoreJob implements IJob -{ - static id = "update-store" as const; - static dataSchema = z.never(); - packageName: string; - registry: URL; - storeVersion: string; - - constructor() - { - this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store"; - this.registry = new URL(process.env.STORE_REGISTRY ?? "https://registry.npmjs.org"); - this.storeVersion = process.env.STORE_VERSION ?? STORE_VERSION; - } - - async start (context: JobContext) - { - if (process.env.CUSTOM_STORE_PATH) return; - - const tempCache = path.join(tmpdir(), "gameflow-bun-cache"); - const storeFolder = getStoreRootFolder(); - await ensureDir(storeFolder); - - console.log("Updating Store"); - const proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--production", "--registry", this.registry.href], { - cwd: storeFolder, - stdout: 'pipe', - stderr: 'pipe', - env: { - BUN_BE_BUN: "1", - BUN_INSTALL_CACHE_DIR: tempCache - } - }); - - const stdout = await new Response(proc.stdout).text(); - console.log(stdout); - const stderr = await new Response(proc.stderr).text(); - if (stderr) - console.error(stderr); - await proc.exited; - } -} \ No newline at end of file diff --git a/src/bun/api/notifications.ts b/src/bun/api/notifications.ts index 1a49080..e1c135c 100644 --- a/src/bun/api/notifications.ts +++ b/src/bun/api/notifications.ts @@ -1,4 +1,5 @@ +import { FrontendNotification } from '@simeonradivoev/gameflow-sdk/shared'; import { events } from './app'; export default function buildNotificationsStream () 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 new file mode 100644 index 0000000..a9e6865 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -0,0 +1,46 @@ +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import desc from './package.json'; +import path from 'node:path'; +import { config } from "@/bun/api/app"; + +export default class CEMUIntegration implements PluginType +{ + emulator = 'CEMU'; + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] }; + }); + + ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) => + { + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + validChangedSaveFiles[this.emulator] = { + cwd: saveFolderSlots[this.emulator].cwd, + shared: true, + subPath: '*.{tga,xml,dat}', + isGlob: true + }; + }); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const args: string[] = []; + + args.push(`--fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); + + const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator); + + args.push(`--mlc=${savesPath}`); + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); + } + + return { args, savesPath: { [this.emulator]: { cwd: savesPath } } }; + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json new file mode 100644 index 0000000..9a0c5c6 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json @@ -0,0 +1,15 @@ +{ + "name": "com.simeonradivoev.gameflow.cemu", + "displayName": "CEMU Integration", + "version": "0.0.1", + "description": "CEMU Emulator Integration", + "main": "./cemu.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png", + "category": "emulators", + "keywords": [ + "integration", + "emulator", + "wiiu", + "cemu" + ] +} \ 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 new file mode 100644 index 0000000..6a44901 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -0,0 +1,87 @@ + +import { config } from "@/bun/api/app"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import path from 'node:path'; +import desc from './package.json'; +import { ensureDir } from "fs-extra"; +import { getSavePaths, getType } from "./utils"; + +export default class DOLPHINIntegration implements PluginType +{ + emulator = 'DOLPHIN'; + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "saves"] }; + }); + + ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); + }); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const args: string[] = []; + + const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); + args.push(`--user=${storageFolder}`); + + args.push(`--config=Dolphin.Display.Fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); + args.push(`--config=Dolphin.General.ISOPath0=${path.join(config.get('downloadPath'), 'roms', 'gc')}`); + args.push(`--config=Dolphin.General.ISOPath1=${path.join(config.get('downloadPath'), 'roms', 'wii')}`); + args.push(`--config=Dolphin.Interface.ConfirmStop=False`); + args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); + args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); + + const resolution = config.get('emulatorResolution'); + const resolutionMapping = { + "720p": 2, + "1080p": 3, + "1440p": 4, + "4k": 6 + }; + args.push(`--config=GFX.Settings.InternalResolution=${resolutionMapping[resolution] ?? 1}`); + args.push(`--config=GFX.Settings.wideScreenHack=${config.get('emulatorWidescreen') ? "True" : "False"}`); + args.push(`--config=GFX.Settings.AspectRatio=${config.get('emulatorWidescreen') ? "1" : "0"}`); + + const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator); + + args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); + args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`); + args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`); + args.push(`--config=Dolphin.Core.GCIFolderAPath=${path.join(savesPath, 'GC')}`); + + if (!ctx.dryRun) + { + await ensureDir(path.join(savesPath, 'GC', "JAP")); + await ensureDir(path.join(savesPath, 'GC', "EUR")); + await ensureDir(path.join(savesPath, 'GC', "USA")); + } + + let finalSavesPath: string | undefined = undefined; + if (ctx.autoValidCommand.metadata.romPath) + { + args.push("--batch"); + args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`); + + finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder; + return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } }; + } + + return { args }; + }); + + ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) => + { + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + validChangedSaveFiles[this.emulator] = { + cwd: saveFolderSlots[this.emulator].cwd, + subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir), + shared: false + }; + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json new file mode 100644 index 0000000..e413d06 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json @@ -0,0 +1,16 @@ +{ + "name": "com.simeonradivoev.gameflow.dolphin", + "displayName": "DOLPHIN Integration", + "version": "0.0.1", + "description": "DOLPHIN Emulator Integration", + "main": "./dolphin.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg", + "category": "emulators", + "keywords": [ + "integration", + "emulator", + "wii", + "gc", + "dolphin" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts new file mode 100644 index 0000000..8794e80 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts @@ -0,0 +1,164 @@ +import { join } from "path"; +import { platform } from "os"; +import fs from "node:fs/promises"; +import path from "node:path"; + +type DolphinLocation = + | { type: "path"; toolPath: string; } + | { type: "appimage"; appImagePath: string; }; + +async function findDolphinTool (bundledDir?: string): Promise +{ + const os = platform(); + const toolName = os === "win32" ? "DolphinTool.exe" : "dolphin-tool"; + + if (bundledDir) + { + if (os === "linux") + { + const glob = new Bun.Glob("*.AppImage"); + for await (const file of glob.scan(bundledDir)) + { + return { type: "appimage", appImagePath: join(bundledDir, file) }; + } + throw new Error(`No AppImage found in ${bundledDir}`); + } else + { + return { type: "path", toolPath: join(bundledDir, toolName) }; + } + } + + // Fallback 1: check PATH + const inPath = Bun.which(toolName); + if (inPath) return { type: "path", toolPath: inPath }; + + // Fallback 2: platform default install locations + if (os === "win32") + { + const candidates = [ + "C:/Program Files/Dolphin/DolphinTool.exe", + "C:/Program Files (x86)/Dolphin/DolphinTool.exe", + ]; + for (const candidate of candidates) + { + if (await Bun.file(candidate).exists()) + { + return { type: "path", toolPath: candidate }; + } + } + } else if (os === "darwin") + { + const candidate = "/Applications/Dolphin.app/Contents/MacOS/dolphin-tool"; + if (await Bun.file(candidate).exists()) + { + return { type: "path", toolPath: candidate }; + } + } else if (os === "linux") + { + const home = process.env.HOME ?? ""; + const candidates = [ + join(home, "Applications/Dolphin-x86_64.AppImage"), + join(home, "Applications/Dolphin.AppImage"), + "/opt/Dolphin-x86_64.AppImage", + ]; + for (const candidate of candidates) + { + if (await Bun.file(candidate).exists()) + { + return { type: "appimage", appImagePath: candidate }; + } + } + } + + throw new Error(`Could not find ${toolName}. Install Dolphin or pass its folder path explicitly.`); +} + +async function runDolphinTool (args: string[], location: DolphinLocation): Promise +{ + if (location.type === "path") + { + const proc = Bun.spawnSync([location.toolPath, ...args]); + if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`); + return proc.stdout.toString(); + } else + { + const mount = Bun.spawn([location.appImagePath, "--appimage-mount"], { + stdout: "pipe", + stderr: "pipe", + }); + const mountPoint = (await new Response(mount.stdout).text()).trim(); + try + { + const proc = Bun.spawnSync([`${mountPoint}/usr/bin/dolphin-tool`, ...args]); + if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`); + return proc.stdout.toString(); + } finally + { + mount.kill(); + } + } +} + +async function readGameId (romPath: string, location: DolphinLocation): Promise +{ + const output = await runDolphinTool(["header", "-i", romPath], location); + const match = output.match(/Game ID:\s*(\w{6})/); + if (!match) throw new Error("Could not read game ID"); + return match[1]; +} + +function getRegion (regionCode: string) +{ + switch (regionCode) + { + case "E": return "USA"; + case "P": return "EUR"; + case "J": return "JAP"; + default: return "USA"; + } +} + +async function getGCSavePaths (romPath: string, savesPath: string, location: DolphinLocation) +{ + const gameId = await readGameId(romPath, location); + const region = getRegion(gameId[3]); + + const makerCode = gameId.slice(4, 6); // e.g. "01" or "7D" — already the right format + const gameCode = gameId.slice(0, 4); // e.g. "GZLE" or "GM5E" + const cardPath = join(savesPath, "GC", region); + + const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`); + const saves: string[] = []; + for await (const file of glob.scan(cardPath)) + { + saves.push(path.join("GC", region, file)); + } + + return saves; +} + +export async function getType (romPath: string, bundledEmulatorDir?: string): Promise<"gamecube" | "wii"> +{ + const location = await findDolphinTool(bundledEmulatorDir); + const gameId = await readGameId(romPath, location); + const isGameCube = gameId[0] === "G" || gameId[0] === "D"; + return isGameCube ? "gamecube" : "wii"; +} + +export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise +{ + const location = await findDolphinTool(bundledEmulatorDir); + const gameId = await readGameId(romPath, location); + const isGameCube = gameId[0] === "G" || gameId[0] === "D"; + + if (isGameCube) + { + return getGCSavePaths(romPath, savesPath, location); + } else + { + const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase(); + const rootFolder = join(savesPath, "Wii", "title", "00010000", folder); + const files = await fs.readdir(rootFolder, { recursive: true }); + return files.map(f => path.join("Wii", "title", "00010000", f)); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini index cbafaf8..e1403c5 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini @@ -21,7 +21,6 @@ CdvdShareWrite = false EnablePatches = true EnableCheats = false EnablePINE = false -EnableWideScreenPatches = false EnableNoInterlacingPatches = false EnableRecordingTools = true EnableGameFixes = true @@ -92,7 +91,7 @@ VsyncEnable = 0 FramerateNTSC = 59.94 FrameratePAL = 50 SyncToHostRefreshRate = false -AspectRatio = Auto 4:3/3:2 +AspectRatio = {{ASPECT_RATIO}} FMVAspectRatioSwitch = Off ScreenshotSize = 0 ScreenshotFormat = 0 @@ -168,7 +167,6 @@ linear_present_mode = 1 deinterlace_mode = 0 OsdScale = 100 Renderer = 14 -upscale_multiplier = 1 mipmap_hw = -1 accurate_blending_unit = 1 crc_hack_level = -1 @@ -371,18 +369,6 @@ Multitap2_Slot4_Enable = false Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2 -[Folders] -Bios = {{{BIOS_PATH}}} -Snapshots = {{{SNAPSHOTS_PATH}}} -SaveStates = {{{SAVE_STATES_PATH}}} -MemoryCards = {{{MEMORY_CARDS_PATH}}} -Cache = {{{CACHE_PATH}}} -Covers = {{{COVERS_PATH}}} -Logs = logs -Textures = {{{TEXTURES_PATH}}} -Videos = videos - - [InputSources] Keyboard = true Mouse = true @@ -488,6 +474,3 @@ RDown = SDL-1/+RightY RLeft = SDL-1/-RightX LargeMotor = SDL-1/LargeMotor SmallMotor = SDL-1/SmallMotor - -[GameList] -RecursivePaths = {{{RECURSIVE_PATHS}}} diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json index bab4f08..6b8c725 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json @@ -5,6 +5,7 @@ "description": "PCSX2 Emulator Integration", "main": "./pcsx2.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png", + "category": "emulators", "keywords": [ "integration", "emulator", 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 072752b..58d61aa 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 @@ -1,34 +1,87 @@ -import { config, db } from "@/bun/api/app"; -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; -import configFile from './PCSX2.ini' with { type: 'file' }; -import Mustache from 'mustache'; +import { config } from "@/bun/api/app"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import defaultConfig from './PCSX2.ini' with { type: 'file' }; import path from 'node:path'; import { ensureDir } from "fs-extra"; import desc from './package.json'; +import ini from 'ini'; +import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared"; export default class PCSX2Integration implements PluginType { - load (ctx: PluginContextType) + emulator = "PCSX2"; + + async load (ctx: PluginLoadingContextType) { - ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - if (ctx.autoValidCommand.emulator === 'PCSX2' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"]; + + if (ctx.source?.type === 'store') { - const args = ["-batch"]; - if (config.get('launchInFullscreen')) - { - args.push("-fullscreen"); - } - args.push(...["-bigpicture", "-portable", "--", ctx.autoValidCommand.metadata.romPath]); + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "config", "resolution"] + }; + } + else + { + return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; + } + }); - const configFileContents = await Bun.file(configFile).text(); + 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 + }; + }); - const biosFolder = path.join(config.get('downloadPath'), "bios", 'PCSX2'); - const storageFolder = path.join(config.get('downloadPath'), "storage", 'PCSX2'); - const savesFolder = path.join(config.get('downloadPath'), "saves", 'PCSX2'); + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const args: string[] = []; + if (ctx.autoValidCommand.metadata.romPath) + { + args.push(ctx.autoValidCommand.metadata.romPath); + args.push("-batch"); + } + if (config.get('launchInFullscreen')) + { + args.push("-fullscreen"); + } + args.push(...["-bigpicture", "-portable", "--"]); - const view = { + if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) + { + let pscx2Path = ''; + if (process.platform === 'win32') + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); + else + pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis'); + + const configPath = path.join(pscx2Path, 'PCSX2.ini'); + const existingConfigFile = Bun.file(configPath); + + const configFile = await existingConfigFile.exists() ? ini.parse(await existingConfigFile.text()) : ini.parse(await Bun.file(defaultConfig).text()); + + const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); + const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); + const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator); + const resolutionMapping = { + "720p": 2, + "1080p": 3, + "1440p": 4, + "4k": 6, + }; + + const paths = { BIOS_PATH: biosFolder, SNAPSHOTS_PATH: path.join(storageFolder, 'snaps'), SAVE_STATES_PATH: path.join(savesFolder, 'states'), @@ -36,21 +89,37 @@ export default class PCSX2Integration implements PluginType CACHE_PATH: path.join(storageFolder, 'cache'), COVERS_PATH: path.join(storageFolder, 'covers'), TEXTURES_PATH: path.join(storageFolder, 'textures'), + VIDEOS_PATH: path.join(storageFolder, 'videos'), + LOGS_PATH: path.join(storageFolder, 'logs'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), }; - await Promise.all(Object.values(view).map(p => ensureDir(p))); + await Promise.all(Object.values(paths).map(p => ensureDir(p))); - let pscx2Path = ''; - if (process.platform === 'win32') - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis'); - else - pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis'); + configFile.EmuCore ??= {}; + configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen'); + configFile['EmuCore/GS'] ??= {}; + configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2"; + configFile['EmuCore/GS'].upscale_multiplier = resolutionMapping[config.get('emulatorResolution')] ?? 1; + configFile.Folders ??= {}; + configFile.Folders.Bios = paths.BIOS_PATH; + configFile.Folders.Snapshots = paths.SNAPSHOTS_PATH; + configFile.Folders.SaveStates = paths.SAVE_STATES_PATH; + configFile.Folders.MemoryCards = paths.MEMORY_CARDS_PATH; + configFile.Folders.Cache = paths.CACHE_PATH; + configFile.Folders.Covers = paths.COVERS_PATH; + configFile.Folders.Textures = paths.TEXTURES_PATH; + configFile.Folders.Videos = paths.VIDEOS_PATH; + configFile.Folders.Logs = paths.LOGS_PATH; + configFile.GameList ??= {}; + configFile.GameList.RecursivePaths = paths.RECURSIVE_PATHS; - await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view)); + await Bun.write(configPath, ini.stringify(configFile)); - return args; + return { args, savesPath: { [this.emulator]: { cwd: paths.MEMORY_CARDS_PATH } } }; } + + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini index edd196b..c138918 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini @@ -96,7 +96,6 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = 3 AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +108,6 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = True FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json index f8e00f5..3801e34 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json @@ -5,6 +5,7 @@ "description": "PPSSPP Emulator Integration", "main": "./ppsspp.ts", "icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png", + "category": "emulators", "keywords": [ "integration", "emulator", 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 8384213..f69fdaf 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 @@ -1,4 +1,4 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { config } from "@/bun/api/app"; import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' }; @@ -9,40 +9,93 @@ import path from "node:path"; import Mustache from "mustache"; import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; +import ini from 'ini'; +import fs from 'node:fs/promises'; +import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared"; -export default class PCSX2Integration implements PluginType +export default class PPSSPPIntegration implements PluginType { - load (ctx: PluginContextType) - { - ctx.hooks.games.emulatorLaunch.tapPromise(desc.name, async (ctx) => - { - if (ctx.autoValidCommand.emulator === 'PPSSPP' && ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir) - { - const args = [ctx.autoValidCommand.metadata.romPath, "--escape-exit", "--pause-menu-exit"]; - if (config.get('launchInFullscreen')) - { - args.push("--fullscreen"); - } + emulator = "PPSSPP"; - let confPath: string | undefined = undefined; - let controlsPath: string | undefined = undefined; + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const stat = await fs.stat(ctx.path); + if (stat.isDirectory()) + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); + if (process.platform === 'win32') + { + await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator)); + } + } + }); + + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => + { + const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"]; + + if (ctx.source?.type === 'store') + { + return { + id: desc.name, + supportLevel: "full", + capabilities: [...baseCapabilities, "config", "resolution"] + }; + } + else + { + return { id: desc.name, supportLevel: "partial", capabilities: [...baseCapabilities] }; + } + }); + + ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) => + { + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + validChangedSaveFiles[this.emulator] = { + cwd: saveFolderSlots[this.emulator].cwd, + shared: true, + subPath: '*.{SFO,sfo,PNG,png}', + isGlob: true + }; + }); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const args: string[] = []; + if (ctx.autoValidCommand.metadata.romPath) + { + args.push(ctx.autoValidCommand.metadata.romPath); + } + + args.push("--escape-exit", "--pause-menu-exit"); + if (config.get('launchInFullscreen')) + { + args.push("--fullscreen"); + } + + if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun) + { + let defaultConfigPath: string | undefined = undefined; + let defaultControlsPath: string | undefined = undefined; switch (process.platform) { case "win32": - confPath = configFilePathWin32; - controlsPath = configControlsFilePathWin32; + defaultConfigPath = configFilePathWin32; + defaultControlsPath = configControlsFilePathWin32; break; case 'linux': - confPath = configFilePathLinux; - controlsPath = configControlsFilePathLinux; + defaultConfigPath = configFilePathLinux; + defaultControlsPath = configControlsFilePathLinux; break; } let ppssppPath = ''; if (process.platform === 'win32') { - ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); + ppssppPath = path.join(config.get('downloadPath'), 'saves', this.emulator, 'PSP', 'SYSTEM'); } else { //TODO: Use way to set custom memstick path when they support it @@ -52,20 +105,43 @@ export default class PCSX2Integration implements PluginType ensureDir(ppssppPath); - if (confPath) + if (defaultConfigPath) { - const configFileContents = await Bun.file(confPath).text(); - await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {})); + const resolutionMapping: Record = { + "720p": 2, + "1080p": 4, + "1440p": 6, + "4k": 8 + }; + const configPath = path.join(ppssppPath, 'ppsspp.ini'); + const configFile = Bun.file(configPath); + + const ppssppConfig = await configFile.exists() ? ini.parse(await configFile.text()) : ini.parse(await Bun.file(defaultConfigPath).text()); + + ppssppConfig.Graphics ??= {}; + ppssppConfig.Graphics.InternalResolution = resolutionMapping[config.get('emulatorResolution')] ?? 0; + ppssppConfig.Graphics.FullScreen = config.get('launchInFullscreen'); + + await Bun.write(configPath, ini.stringify(ppssppConfig)); } - if (controlsPath) + if (defaultControlsPath) { - const controlsFileContents = await Bun.file(controlsPath).text(); + const controlsFileContents = await Bun.file(defaultControlsPath).text(); await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); } - return args; + return { + args, + savesPath: { + [this.emulator]: { + cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") + } + } + }; } + + return { args }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini index f24ea4b..f448165 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini @@ -96,7 +96,6 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = 3 AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +108,6 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = True FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin new file mode 100644 index 0000000..55874b0 Binary files /dev/null and b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin differ diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json new file mode 100644 index 0000000..937ebc3 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json @@ -0,0 +1,15 @@ +{ + "name": "com.simeonradivoev.gameflow.xemu", + "displayName": "XEMU Integration", + "version": "0.0.1", + "description": "XEMU Emulator Integration", + "main": "./xemu.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/8/8e/Xemu_logo_green.svg", + "category": "emulators", + "keywords": [ + "integration", + "emulator", + "xbox", + "xemu" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts new file mode 100644 index 0000000..57506de --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts @@ -0,0 +1,74 @@ +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import desc from './package.json'; +import { config } from "@/bun/api/app"; +import path from "node:path"; +import toml, { TomlTable } from 'smol-toml'; +import fs from 'node:fs/promises'; +import bin from './eeprom.bin' with { type: 'file' }; + +export default class XEMUIntegration implements PluginType +{ + emulator = 'XEMU'; + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] }; + }); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const args: string[] = []; + + if (config.get('launchInFullscreen')) + { + args.push("-full-screen"); + } + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push("-dvd_path"); + args.push(ctx.autoValidCommand.metadata.romPath); + } + + const configPath = path.join(config.get('downloadPath'), 'storage', this.emulator, 'xemu.toml'); + let configFile: { general: TomlTable & { misc: TomlTable; }, sys: TomlTable & { files: TomlTable; }; } = { general: { misc: {} }, sys: { files: {} } }; + if (await Bun.file(configPath).exists()) + { + configFile = toml.parse(await Bun.file(configPath).text()) as any; + } + + configFile.general.misc ??= {}; + configFile.general.misc.skip_boot_anim = true; + configFile.general.show_welcome = false; + configFile.general.games_dir = path.join(config.get('downloadPath'), 'roms', 'xbox'); + configFile.sys.mem_limit = '128'; + const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); + if (await fs.exists(biosFolder)) + { + const biosPaths = (await fs.readdir(biosFolder)); + const flash = biosPaths.find(f => f.endsWith('.bin') && !f.includes('mcpx')); + const bootrom = biosPaths.find(f => f.endsWith('.bin') && f.includes('mcpx')); + const hardDrive = biosPaths.find(f => f.endsWith('qcow2')); + if (flash) configFile.sys.files.flashrom_path = path.join(biosFolder, flash); + if (bootrom) configFile.sys.files.bootrom_path = path.join(biosFolder, bootrom); + if (hardDrive) configFile.sys.files.hdd_path = path.join(biosFolder, hardDrive); + } + + if (!ctx.dryRun) + { + const eepromPath = path.join(config.get('downloadPath'), "storage", this.emulator, 'eeprom.bin'); + await Bun.write(eepromPath, await Bun.file(bin).arrayBuffer()); + configFile.sys.files.eeprom_path = eepromPath; + + await Bun.write(configPath, toml.stringify(configFile)); + args.push("-config_path"); + args.push(configPath); + } + + + return { args }; + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json new file mode 100644 index 0000000..280f14f --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json @@ -0,0 +1,16 @@ +{ + "name": "com.simeonradivoev.gameflow.xenia", + "displayName": "XENIA Integration", + "version": "0.0.1", + "description": "XENIA Emulator Integration", + "main": "./xenia.ts", + "icon": "https://xenia.jp/images/logo-256x256.png", + "category": "emulators", + "keywords": [ + "integration", + "emulator", + "xbox360", + "xenia", + "xenia-edge" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts new file mode 100644 index 0000000..b3c26f9 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/utils.ts @@ -0,0 +1,140 @@ +import { join } from "path"; + +const SECTOR_SIZE = 0x800; +const MAGIC = "MICROSOFT*XBOX*MEDIA"; + +const PARTITION_OFFSETS: Record = { + XSF: 0x0, + GDF: 0xFD90000, + XGD3: 0x2080000, +}; + +async function readBytes (file: ReturnType, offset: number, length: number): Promise +{ + return Buffer.from(await file.slice(offset, offset + length).arrayBuffer()); +} + +async function parseTitleIdFromXexReader ( + read: (offset: number, length: number) => Promise +): Promise +{ + // Read just the fixed header (magic + flags + offsets + header count) + const header = await read(0, 0x18); + if (header.toString("ascii", 0, 4) !== "XEX2") + { + throw new Error("Not a valid XEX2 file"); + } + + const headerCount = header.readUInt32BE(0x14); + const EXEC_INFO_KEY = 0x40006; + + // Read the optional header table + const table = await read(0x18, headerCount * 8); + + for (let i = 0; i < headerCount; i++) + { + const key = table.readUInt32BE(i * 8); + const valueOrOffset = table.readUInt32BE(i * 8 + 4); + + if (key === EXEC_INFO_KEY) + { + // valueOrOffset is a file offset — read the exec info struct there + // TitleID is at +0x0C within it + const execInfo = await read(valueOrOffset, 0x18); + return execInfo.readUInt32BE(0x0C) + .toString(16).toUpperCase().padStart(8, "0"); + } + } + + throw new Error("Execution info header not found in XEX"); +} + +async function titleIdFromXexFile (xexPath: string): Promise +{ + const file = Bun.file(xexPath); + return parseTitleIdFromXexReader((offset, length) => + readBytes(file, offset, length) + ); +} + +async function titleIdFromIso (isoPath: string): Promise +{ + const file = Bun.file(isoPath); + const fileSize = file.size; + + for (const partitionOffset of Object.values(PARTITION_OFFSETS)) + { + const vdOffset = partitionOffset + 0x20 * SECTOR_SIZE; + if (vdOffset + 28 > fileSize) continue; + + const vd = await readBytes(file, vdOffset, 28); + if (vd.toString("ascii", 0, 20) !== MAGIC) continue; + + const rootSector = vd.readUInt32LE(20); + const rootSize = vd.readUInt32LE(24); + const rootOffset = partitionOffset + rootSector * SECTOR_SIZE; + const dir = await readBytes(file, rootOffset, rootSize); + + let pos = 0; + while (pos < dir.length) + { + if (dir[pos] === 0xFF) break; + if (pos + 14 > dir.length) break; + + const nameLen = dir[pos + 13]; + if (nameLen === 0 || nameLen === 0xFF) break; + if (pos + 14 + nameLen > dir.length) break; + + const name = dir.toString("ascii", pos + 14, pos + 14 + nameLen); + const fileSector = dir.readUInt32LE(pos + 4); + + if (name.toLowerCase() === "default.xex") + { + const xexBase = partitionOffset + fileSector * SECTOR_SIZE; + // Reader that translates relative XEX offsets to absolute ISO offsets + return parseTitleIdFromXexReader((offset, length) => + readBytes(file, xexBase + offset, length) + ); + } + + const entryLen = 14 + nameLen; + pos += (entryLen + 3) & ~3; + } + } + + throw new Error("Not a valid Xbox 360 ISO or default.xex not found"); +} + +async function titleIdFromFolder (folderPath: string): Promise +{ + return titleIdFromXexFile(join(folderPath, "default.xex")); +} + +type XeniaRomType = "iso" | "xex" | "folder"; + +function detectRomType (romPath: string): XeniaRomType +{ + const lower = romPath.toLowerCase(); + if (lower.endsWith(".iso")) return "iso"; + if (lower.endsWith(".xex")) return "xex"; + return "folder"; // extracted game folder containing default.xex +} + +async function getTitleId (romPath: string): Promise +{ + switch (detectRomType(romPath)) + { + case "iso": return titleIdFromIso(romPath); + case "xex": return titleIdFromXexFile(romPath); + case "folder": return titleIdFromFolder(romPath); + } +} + +export async function getXeniaSavePaths ( + romPath: string, + xeniaDir: string +): Promise +{ + const titleId = await getTitleId(romPath); + return join(xeniaDir, titleId); +}; \ No newline at end of file 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 new file mode 100644 index 0000000..9d37d99 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -0,0 +1,102 @@ +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import desc from './package.json'; +import { GameflowHooks } from "@simeonradivoev/gameflow-sdk"; +import { config } from "@/bun/api/app"; +import path from "node:path"; +import { ensureDir } from "fs-extra"; +import toml, { TomlTable } from 'smol-toml'; +import fs from 'node:fs/promises'; +import { getXeniaSavePaths } from "./utils"; + +export default class XENIAIntegration implements PluginType +{ + emulator = 'XENIA'; + emulatorEdge = 'XENIA-EDGE'; + + async handlePostInstall (ctx: Parameters['0']) + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); + } + + async handleLaunch (ctx: Parameters['0']): + ReturnType + { + const args: string[] = []; + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push(ctx.autoValidCommand.metadata.romPath); + } + + const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`); + + args.push(`--config`, configPath); + + if (config.get('launchInFullscreen')) + { + args.push(`--fullscreen`); + } + + if (!ctx.dryRun) + { + await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!)); + let configFile: TomlTable & { Storage: TomlTable, GPU: TomlTable, Display: TomlTable; } = { Storage: {}, GPU: {}, Display: {} }; + if (await fs.exists(configPath)) + { + configFile = toml.parse(await Bun.file(configPath).text()) as any; + } + + const resolutionMapping = { + "720p": 1, + "1080p": 2, + "1440p": 3, + "4k": 3 + }; + + configFile.Display.fullscreen = config.get('launchInFullscreen'); + configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1; + configFile.GPU.draw_resolution_scale_y = resolutionMapping[config.get('emulatorResolution')] ?? 1; + const savesPath = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!); + await ensureDir(savesPath); + configFile.Storage.content_root = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!); + configFile.Storage.storage_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'config'); + configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache'); + + await Bun.write(configPath, toml.stringify(configFile)); + + let finalSavesPath: string | undefined = undefined; + if (ctx.autoValidCommand.metadata.romPath) + { + finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath); + return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } }; + } + + return { args }; + }; + + return { args }; + } + + handleEmulatorLaunchSupport (ctx: Parameters['0']): + ReturnType + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] }; + } + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, this.handleEmulatorLaunchSupport); + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, this.handleEmulatorLaunchSupport); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch); + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch); + + ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) => + { + if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return; + const files = await fs.readdir(saveFolderSlots[this.emulator].cwd, { recursive: true }); + validChangedSaveFiles.xenia = { cwd: saveFolderSlots[this.emulator].cwd, subPath: files, shared: false }; + + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts new file mode 100644 index 0000000..77cd201 --- /dev/null +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts @@ -0,0 +1,521 @@ +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import desc from './package.json'; +import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app"; +import * as emulatorSchema from '@schema/emulators'; +import { and, eq } from "drizzle-orm"; +import { cores } from "@/bun/api/emulatorjs/emulatorjs"; +import { RPC_URL } from "@/shared/constants"; +import { host } from "@/bun/utils/host"; +import path from 'node:path'; +import { existsSync, readFileSync } from "node:fs"; +import fs from "node:fs/promises"; +import { findStoreEmulatorExec } from "@/bun/api/games/services/launchGameService"; +import { which } from "bun"; +import os from 'node:os'; +import { getLocalGameMatch } from "@/bun/api/games/services/utils"; +import { CommandEntry, EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared"; + +export default class IgdbIntegration implements PluginType +{ + varRegex = /%([^%]+)%/g; + assignRegex = /(%\w+%)=(\S+) /g; + + /** + * Get the emulators related to the given system + * @param systemSlug the ES-DE slug for the system + */ + async getEmulatorsForSystem (systemSlug: string) + { + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${systemSlug}'`); + } + + const emulators = new Set(); + await Promise.all(system.commands.map(async (command, index) => + { + let cmd = command.command; + + const matches = Array.from(cmd.matchAll(this.varRegex)); + matches.forEach(([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + emulators.add(emulatorName); + return; + } + }); + })); + + if (cores[systemSlug]) + { + emulators.add('EMULATORJS'); + } + + return Array.from(emulators); + } + + async findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) + { + const execs: EmulatorSourceEntryType[] = []; + + if (customEmulators.has(id)) + { + execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) }); + } + + if (emulator && emulator.systempath.length > 0) + { + const storePath = await findStoreEmulatorExec(id, emulator); + if (storePath) execs.push(storePath); + } + + if (emulator && process.platform === 'win32') + { + const regValues = emulator.winregistrypath; + if (regValues.length > 0) + { + for (const node of regValues) + { + const registryValue = await this.readRegistryValue(node); + if (registryValue) + { + execs.push({ binPath: registryValue, type: 'registry', exists: true }); + } + } + + } + } + + if (emulator && emulator.systempath.length > 0) + { + const systemPath = await this.resolveSystemPath(emulator.systempath); + if (systemPath) + { + execs.push({ binPath: systemPath, type: 'system', exists: true }); + } + } + + if (emulator && emulator.staticpath.length > 0) + { + const staticPath = await this.resolveStaticPath(emulator.staticpath); + if (staticPath) + { + execs.push({ binPath: staticPath, type: 'static', exists: true }); + } + } + + return execs; + } + + async readRegistryValue (text: string) + { + const params = text.split('|'); + const key = path.dirname(params[0]); + const value = path.basename(params[0]); + const bin = params.length > 1 ? params[1] : undefined; + + const proc = Bun.spawn({ + cmd: ["reg", "QUERY", key, "/v", value], + stdout: "pipe", + stderr: "pipe", + }); + + const output = await new Response(proc.stdout).text(); + await proc.exited; + + if (!output.includes(value)) return null; + + const lines = output.split("\n"); + for (const line of lines) + { + if (line.includes(value)) + { + const parts = line.trim().split(/\s{4,}/); + return bin ? path.join(parts[2], bin) : parts[2]; // registry value + } + } + + return null; + } + + async resolveStaticPath (entries: string[]) + { + for (const entry of entries) + { + const resolved = entry.replace("~", os.homedir()); + if (await fs.exists(resolved)) + { + return resolved; + } + } + return null; + } + + async resolveSystemPath (entries: string[]) + { + for (const entry of entries) + { + try + { + const found = which(entry); + return found; + } catch { } + } + return null; + } + + async findExecsByName (emulatorName: string) + { + const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorName) }); + if (!emulator) + { + return []; + } + return this.findExecs(emulatorName, emulator); + } + + async getRomFilePaths (gamePath: string, config: { systemSlug?: string; mainGlob?: string | null; }) + { + if (!existsSync(gamePath)) + { + throw new Error(`Provided rom path is missing: '${gamePath}'`); + } + + const gamePathStat = await fs.stat(gamePath); + const validFiles: string[] = []; + + if (gamePathStat.isDirectory()) + { + if (config.mainGlob) + { + const files = await Array.fromAsync(fs.glob(config.mainGlob, { cwd: gamePath })); + if (files.length > 1) + { + throw new Error("Found multiple rom files"); + } else if (files.length === 0) + { + throw new Error("Found no valid roms"); + } + + validFiles.push(path.join(gamePath, files[0])); + } else + { + if (!config.systemSlug) throw new Error("Needs system to find valid file"); + + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, config.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${config.systemSlug}'`); + } + + const extensionList = system.extension.join(','); + + for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) + { + validFiles.push(file); + } + + if (validFiles.length <= 0) + { + throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); + } + } + } else if (config.systemSlug) + { + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, config.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${config.systemSlug}'`); + } + + if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) + { + validFiles.push(gamePath); + } + else + { + const extensionList = system.extension.join(','); + throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); + } + } else + { + validFiles.push(gamePath); + } + + return validFiles; + } + + /** + * + * @param data Uses es-de system slug + * @param mainGlob The main file glob supported pattern to search for if game path is a directory + * @returns + */ + async getValidLaunchCommands (data: { + systemSlug: string; + gamePath: string; + mainGlob?: string | null; + }): Promise + { + + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, data.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${data.systemSlug}'`); + } + + if (!system.extension || system.extension.length <= 0) + { + throw new Error(`No extensions listed for system '${data.systemSlug}'`); + } + + const downloadPath = config.get('downloadPath'); + const gamePath = path.isAbsolute(data.gamePath) ? data.gamePath : path.join(downloadPath, data.gamePath); + + const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob }); + + function escapeWindowsArg (arg: string): string + { + if (process.platform === 'win32') + { + return `"${arg + .replace(/(\\*)"/g, '$1$1\\"') // escape quotes + .replace(/(\\*)$/, '$1$1') // escape trailing backslashes + }"`; + } else + { + if (arg.includes(' ')) + { + return `"${arg}"`; + } else + { + return arg; + } + } + } + + const formattedCommands = await Promise.all(system.commands + .filter(c => !c.command.includes(`%ENABLESHORTCUTS%`)) + .map(async (command, index) => + { + const label = command.label; + let cmd = command.command; + + let emulator: string | undefined = undefined; + let rom = validFiles[0]; + + if (cmd.includes('%ESCAPESPECIALS%')) + rom = rom.replace(/[&()^=;,]/g, ''); + + + + const staticVars: Record = { + '%ROM%': escapeWindowsArg(rom), + '%ROMRAW%': validFiles[0], + '%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')), + '%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)), + '%ROMPATH%': escapeWindowsArg(gamePath), + '%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))), + '%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])), + '%ESCAPESPECIALS%': "", + '%HIDEWINDOW%': "" + }; + + cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => + { + try + { + const resolvedInjectFile = injectFile.replace(this.varRegex, (a) => + { + return staticVars[a] ?? a; + }); + if (existsSync(resolvedInjectFile)) + { + const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); + return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); + } + + return ''; + } catch (error) + { + return ''; + } + }); + + const matches = Array.from(cmd.matchAll(this.varRegex)); + const varList = await Promise.all(matches.map(async ([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + let execs = await this.findExecsByName(emulatorName); + let validExec = execs.find(e => e.exists); + + emulator = emulatorName; + return [ + [value, validExec ? validExec.binPath : undefined] as [string, string | undefined], + [`%EMUSOURCE%`, validExec?.type] as [string, string | undefined], + ['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined], + ['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined] + ]; + + } + + const key = value[0].substring(1, value.length - 1); + return [[value, process.env[key]] as [string, string | undefined]]; + })); + + const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; + let startDir: string | undefined = undefined; + + if ('%STARTDIR%' in vars) + { + delete vars['%STARTDIR%']; + + cmd = cmd.replace(this.assignRegex, (match, p1, p2) => + { + if (p1 === '%STARTDIR%') + { + startDir = this.varRegex.test(p2) ? staticVars[p2] : p2; + } + return ""; + }); + } + + // missing variable + const invalid = Object.entries(vars).find(c => c[1] === undefined); + + const formattedCommand = cmd.replace(this.varRegex, (s) => vars[s] ?? '').trim(); + + return { + id: index, + label: label ?? undefined, + command: formattedCommand, + startDir, + valid: !invalid, emulator, + emulatorSource: vars['%EMUSOURCE%'] as any, + metadata: { + romPath: validFiles[0], + emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1], + emulatorDir: vars['%EMUDIRRAW%'] + } + } satisfies CommandEntry; + })); + + return formattedCommands.filter(c => !!c); + } + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.emulators.findEmulatorSource.tapPromise(desc.name, async ({ sources, emulator }) => + { + sources.push(...await this.findExecsByName(emulator)); + }); + + ctx.hooks.emulators.findEmulatorForSystem.tapPromise(desc.name, async ({ system, emulators }) => + { + emulators.push(...await this.getEmulatorsForSystem(system)); + }); + + ctx.hooks.games.fetchRomFiles.tapPromise(desc.name, async ({ source, id }) => + { + const localGame = await db.query.games.findFirst({ + where: getLocalGameMatch(id, source), + columns: { path_fs: true, main_glob: true }, + with: { platform: { columns: { es_slug: true } } } + }); + + if (!localGame?.path_fs) + { + return; + } + + const downloadPath = config.get('downloadPath'); + const path_fs = path.isAbsolute(localGame.path_fs) ? localGame.path_fs : path.join(downloadPath, localGame.path_fs); + + return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob }); + }); + + ctx.hooks.games.buildLaunchCommands.tapPromise(desc.name, async ({ systemSlug, source, id, gamePath, mainGlob }) => + { + if (source === 'emulator') + { + const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id.id) }); + const allExecs = await this.findExecs(id.id, esEmulator); + return allExecs.map(exec => ({ + command: exec.binPath, + id: exec.type, + emulator: id.id, + emulatorSource: exec.type, + metadata: { + emulatorBin: exec.binPath, + emulatorDir: exec.rootPath + }, + valid: true + } satisfies CommandEntry)); + } + + const rommPlatform = systemSlug; + let esSystem: string | undefined = undefined; + const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) + }); + + if (systemMapping) esSystem = systemMapping.system; + + if (!esSystem) + { + const system = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.systems.name, systemSlug), columns: { name: true } }); + if (system) esSystem = system.name; + } + + if (esSystem && gamePath) + { + try + { + const commands = await this.getValidLaunchCommands({ systemSlug: esSystem, gamePath, mainGlob }); + + if (cores[esSystem]) + { + const gameUrl = `${RPC_URL(host)}/api/romm/rom/${id.source}/${id.id}`; + commands.push({ + id: 'EMULATORJS', + label: "Emulator JS", + command: `core=${cores[esSystem]}&gameUrl=${encodeURIComponent(gameUrl)}`, + valid: true, + emulator: 'EMULATORJS', + metadata: { + romPath: gameUrl + } + }); + } + + return commands; + } catch (error) + { + console.error(error); + if (error instanceof Error) return error; + } + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json new file mode 100644 index 0000000..90815b7 --- /dev/null +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json @@ -0,0 +1,14 @@ +{ + "name": "com.simeonradivoev.gameflow.es", + "displayName": "ES-DE Launcher", + "version": "0.0.1", + "description": "ES-DE Launch Configurations. Used as fallback", + "main": "./es-de.ts", + "icon": "https://impro.usercontent.one/appid/oneComWsb/domain/es-de.org/media/es-de.org/onewebmedia/ES-DE_logo.png", + "canDisable": false, + "category": "launchers", + "keywords": [ + "integration", + "es-de" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json new file mode 100644 index 0000000..2c42339 --- /dev/null +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.rclone", + "displayName": "Rclone Integration", + "version": "0.0.1", + "description": "Rclone integration for syncing saves", + "main": "./rclone.ts", + "icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3ERclone%3C%2Ftitle%3E%3Cpath%20d%3D%22M11.842.6258C9.3647.6813%206.9754%201.9906%205.646%204.2933c-.7593%201.3144-1.0647%202.7662-.966%204.1745a7.99%207.99%200%200%201%202.6568-.4541l1.4705-.0013c-.0093-.5594.1245-1.1284.4245-1.6482.8827-1.5284%202.837-2.0522%204.3654-1.1695%201.5284.8824%202.0519%202.8366%201.1695%204.365l-1.4782%202.5647%201.1955%202.0714%202.3914-.0004%201.4775-2.5655c2.0262-3.5088.8239-7.9959-2.6853-10.0217C14.4614.9118%2013.1396.5967%2011.842.6258m-1.5451%208.073-2.9605.0029C3.2844%208.7017%200%2011.9867%200%2016.0383c0%204.052%203.2844%207.3367%207.3364%207.3367%201.5174%200%202.9267-.4609%204.0967-1.2497a8%208%200%200%201-1.72-2.0748l-.7368-1.273c-.4799.288-1.0392.4565-1.6395.4565-1.765%200-3.1958-1.4307-3.1958-3.1958%200-1.7647%201.4307-3.1954%203.1958-3.1954l2.96-.0022%201.1962-2.0708zm9.587.7475a7.99%207.99%200%200%201-.935%202.5278l-.7344%201.2745c.4892.2717.915.6719%201.2153%201.192.8823%201.528.3585%203.4826-1.1699%204.365-1.528.8823-3.4828.3588-4.3651-1.1696l-1.482-2.5628h-2.3915L8.8256%2017.144l1.483%202.5626c2.0262%203.5091%206.513%204.7112%2010.022%202.685%203.5089-2.0257%204.7112-6.5125%202.6853-10.0216-.7588-1.3144-1.863-2.3052-3.132-2.9237%22%20%2F%3E%3C%2Fsvg%3E", + "category": "saves", + "keywords": [ + "integration", + "rclone" + ] +} \ 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 new file mode 100644 index 0000000..8ab31a0 --- /dev/null +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts @@ -0,0 +1,473 @@ +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import desc from './package.json'; +import { config, db, events } from "@/bun/api/app"; +import path from 'node:path'; +import unzip from 'unzip-stream'; +import { ensureDir } from "fs-extra"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import fs from 'node:fs/promises'; +import { randomUUIDv7 } from "bun"; +import z from "zod"; +import { createInterface } from "node:readline"; +import { getLocalGameMatch } from "@/bun/api/games/services/utils"; +import { getErrorMessage } from "@/bun/utils"; + +const DefaultLocalName = "Default_Local"; + +const SettingsSchema = z.object({ + runWebGui: z.boolean() + .default(false) + .describe("Run the Web GUI that can be accessed at http://localhost:5572") + .meta({ title: "Run Web GUI" }), + globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"), + webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"), + remoteName: z.string().default(DefaultLocalName), + verboseLog: z.boolean() + .default(false) + .describe("Show detailed log of operation for debugging") + .meta({ $comment: JSON.stringify({ category: "debug" }) }), + importSaves: z.boolean().default(true).describe("Import Saves From the Destination. This will override local saves"), + exportSaves: z.boolean().default(true).describe("Export saves to remove. This will sync current saves with remote") +}); + +type SettingsType = z.infer; +const loginTokenUrlRegex = /http:\/\/[\w\d:\-@\[\]\.\/?=]+/gm; + +export default class RcloneIntegration implements PluginType +{ + settingsSchema = SettingsSchema; + rclonePath: string | undefined; + server: Bun.Subprocess | undefined; + password: string; + user = "gameflow"; + loginUrl: string | undefined = undefined; + eventsNames = [{ + id: "open-web-gui", + title: "Open Web GUI", + description: "Open Web GUI", + action: "Open" + }, { + id: "refresh", + title: "Refresh Sources", + action: "Refresh" + }]; + + constructor() + { + this.password = randomUUIDv7(); + } + + async onEvent (id: string) + { + switch (id) + { + case "open-web-gui": + return { openTab: this.loginUrl }; + break; + case "refresh": + await this.refresh(); + return { reload: true }; + break; + } + } + + async setup (ctx: PluginLoadingContextType) + { + ctx.zodRegistry.add(SettingsSchema.shape.runWebGui, { requiresRestart: true }); + ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true }); + + const toolsPath = path.join(config.get('downloadPath'), "tools"); + await ensureDir(toolsPath); + const binaryMap: Record = { + win32: '**/rclone.exe', + linux: 'rclone-*/rclone', + darwin: 'rclone-*/rclone' + }; + const existingRclones = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath })); + if (existingRclones[0]) + { + this.rclonePath = path.join(toolsPath, existingRclones[0]); + await this.startServer(ctx); + return; + } + + ctx.setProgress(0.5, "Downloading RClone"); + const platformMap: Record = { + linux: "linux", + win32: "windows", + darwin: "osx" + }; + const archMap: Record = { + x64: "amd64", + arm64: "arm64" + }; + const downloadUrl = `https://downloads.rclone.org/rclone-current-${platformMap[process.platform]}-${archMap[process.arch]}.zip`; + console.log("Starting Download", downloadUrl); + const rcCloseZip = await fetch(downloadUrl); + + await ensureDir(toolsPath); + await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath })); + const dests = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath })); + if (dests[0]) + { + this.rclonePath = path.join(toolsPath, dests[0]); + await fs.chmod(this.rclonePath, 0o755); + await this.startServer(ctx); + return; + } + } + + async refresh () + { + try + { + const data = await this.request('/config/listremotes', {}); + z.globalRegistry.add(SettingsSchema.shape.remoteName, { + examples: [''].concat(...data.remotes), + description: "The name of the remote to sync with" + }); + } catch (error) + { + events.emit('notification', { message: getErrorMessage(error), type: 'error' }); + z.globalRegistry.add(SettingsSchema.shape.remoteName, { + examples: [''], + description: "The name of the remote to sync with" + }); + } + } + + async startServer (ctx: PluginLoadingContextType) + { + const args: string[] = []; + if (ctx.config.get('runWebGui')) + { + args.push("--rc-web-gui"); + args.push("--rc-web-gui-no-open-browser"); + } + if (ctx.config.get('')) + { + args.push('-vv'); + } + let env: Record | undefined = undefined; + if (!ctx.config.get('globalConfig')) + { + env = { RCLONE_CONFIG: path.join(config.get('downloadPath'), 'tools', 'config', 'rclone', 'rclone.conf') }; + } + ctx.config.set('webGuiPassword', this.password); + this.server = Bun.spawn([this.rclonePath!, "rcd", '--use-json-log', `--rc-user=${this.user}`, ...args, `--rc-pass=${this.password}`, "--rc-addr", "localhost:5572"], { + stdout: "pipe", + stderr: "pipe", + env + }); + const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) }); + rl.on('line', e => + { + try + { + const data = JSON.parse(e); + + if (data.level === 'error') + { + console.error(data.msg); + } else if (data.level === 'critical') + { + console.error(data.msg); + } + + else + { + console.log(e); + if (loginTokenUrlRegex.test(data.msg)) + { + this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e); + } + } + } catch (error) + { + console.log(e); + } + + }); + + await new Promise((resolve, reject) => + { + const handleResolve = (line: string) => + { + try + { + const data = JSON.parse(line); + if (!loginTokenUrlRegex.test(data.msg)) return; + rl.off('line', handleResolve); + resolve(data); + } catch (error) + { + + } + }; + rl.on('line', handleResolve); + setTimeout(() => { reject("Timeout"); }, 5000); + }); + + await this.refresh(); + } + + async request (path: string, body: any) + { + const response = await fetch(`http://localhost:5572${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${Buffer.from(`${this.user}:${this.password}`).toString('base64')}` + }, + body: JSON.stringify(body) + }); + + const data = await response.json(); + if (response.ok) + { + return data; + } else + { + throw new Error(response.statusText, { cause: data }); + } + } + + async cleanup () + { + await new Promise((resolve) => + { + this.request('/core/quit', {}).catch(e => + { + this.server?.kill("SIGKILL"); + this.server = undefined; + }); + + setTimeout(() => + { + this.request('/core/quit', { exitCode: 9 }).then(e => + { + resolve(false); + this.server = undefined; + }).catch(e => + { + resolve(false); + this.server?.kill("SIGKILL"); + this.server = undefined; + }); + + + }, 5000); + + this.server?.exited.then(() => resolve(true)); + }); + + } + + async load (ctx: PluginLoadingContextType) + { + await this.setup(ctx); + + ctx.hooks.games.prePlay.tapPromise({ + name: desc.name, + stage: 10, + }, async ({ source, id, setProgress, saveFolderSlots, command }) => + { + if (!this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return; + + const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined; + if (!destination) return; + + const remoteName = ctx.config.get('remoteName'); + + for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) + { + let supportsMetadata = true; + let src: string; + + if (remoteName && remoteName !== DefaultLocalName) + { + src = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`; + + const exists = await this.request('/operations/stat', { + fs: `${remoteName}:`, + remote: `gameflow/saves/${destination.join('/')}/${slot}` + }).catch(e => undefined); + if (!exists || !exists.item) return; + const remote = await this.request('/operations/fsinfo', { + fs: `${remoteName}:` + }); + supportsMetadata = !remote.ReadMetadata; + if (supportsMetadata) + { + console.warn("Remote", remoteName, "does not support metadata"); + } + } else + { + src = path.join(config.get('downloadPath'), 'saves', ...destination, slot); + if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', ...destination, slot))) return; + } + + const job = await this.request('/sync/copy', { + srcFs: src, + dstFs: cwd, + createEmptySrcDirs: true, + _async: true, + _config: { + CheckFirst: true, + Metadata: true, + NoCheckDest: supportsMetadata + } + }).catch(e => + { + events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); + return undefined; + });; + + await new Promise(async (resolve, reject) => + { + setProgress(0, "RClone: Syncing Saves"); + + const checkInterval = setInterval(async () => + { + const stat = await this.request('/job/status', { jobid: job.jobid }); + if (stat.finished) + { + clearInterval(checkInterval); + console.log(stat.output); + resolve(true); + + } else if (stat.error) + { + reject(stat.error); + } else + { + setProgress(stat.progress, "RClone: Syncing Saves"); + } + }, 500); + }); + } + + }); + + ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) => + { + if (!this.rclonePath || !ctx.config.get('exportSaves')) return; + const local = await db.query.games.findFirst({ where: getLocalGameMatch(id, source) }); + console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(",")); + + const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined; + if (!destination) return; + + const remoteName = ctx.config.get('remoteName'); + + await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) => + { + let suportsMetadata = false; + let dest: string; + if (remoteName && remoteName !== DefaultLocalName) + { + dest = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`; + const remote = await this.request('/operations/fsinfo', { + fs: `${remoteName}:` + }); + suportsMetadata = !remote.ReadMetadata; + if (suportsMetadata) + { + console.warn("Remote", remoteName, "does not support metadata"); + } + } else + { + dest = path.join(config.get('downloadPath'), 'saves', ...destination, slot); + } + + const filter = { + IncludeRule: Array.isArray(change.subPath) ? + change.subPath.map(s => + { + if (change.isGlob) return s; + else s.replaceAll('\\', '/'); + }) : + [change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/')] + }; + + let jobid: number | undefined = undefined; + + if (change.fixedSize) + { + await this.request('/sync/copy', { + srcFs: change.cwd, + dstFs: dest, + createEmptySrcDirs: true, + _async: true, + _config: { + NoCheckDest: true + }, + _filter: filter + }) + .then(job => jobid = job.jobid) + .catch(e => + { + events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); + return undefined; + }); + } else + { + await this.request('/sync/sync', { + srcFs: change.cwd, + dstFs: dest, + createEmptySrcDirs: true, + _async: true, + _config: { + CheckSum: true, + CheckFirst: true, + Metadata: true, + MetadataSet: { + igdb_id: local?.igdb_id ? String(local?.igdb_id) : undefined, + ra_id: local?.ra_id ? String(local?.ra_id) : undefined + } + }, + _filter: filter + }) + .then(job => jobid = job.jobid) + .catch(e => + { + events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); + return undefined; + }); + } + + if (!jobid) return; + await new Promise(async (resolve, reject) => + { + const checkInterval = setInterval(async () => + { + const stat = await this.request('/job/status', { jobid }); + if (stat.finished) + { + clearInterval(checkInterval); + console.log(stat.output); + resolve(true); + + } else if (stat.error) + { + reject(stat.error); + } else + { + + } + }, 500); + }); + + const stats = await this.request('/core/stats', { + group: `job/${jobid}` + }); + + if (stats.transfers > 0) + { + events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' }); + } + })); + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts new file mode 100644 index 0000000..c2be6d3 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts @@ -0,0 +1,125 @@ +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import desc from './package.json'; +import secrets from "@/bun/api/secrets"; +import PQueue from 'p-queue'; +import * as igdb from '@phalcode/ts-igdb-client'; +import { checkLoginAndRefreshTwitch } from "@/bun/api/auth"; +import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; + +export default class IgdbIntegration implements PluginType +{ + queue: PQueue; + + constructor() + { + this.queue = new PQueue({ concurrency: 8, interval: 1000, intervalCap: 4, strict: true }); + } + + async apiCall (subPath: string, query: string) + { + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + const headers = new Headers({ + "Client-ID": process.env.TWITCH_CLIENT_ID ?? '', + Authorization: `Bearer ${access_token}`, + Accept: "application/json" + }); + const response = await this.queue.add(() => fetch(`https://api.igdb.com/v4${subPath}`, { + headers: headers, + method: "POST", + body: query + })); + if (response.ok) + { + return response.json() as T; + } + } + + async cleanup () + { + this.queue.clear(); + } + + async load (ctx: PluginLoadingContextType) + { + await checkLoginAndRefreshTwitch(); + + ctx.hooks.games.gameLookup.tapPromise(desc.name, async (matches, { source, id, search }) => + { + if (!process.env.TWITCH_CLIENT_ID) return matches; + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + if (!access_token) + { + return matches; + } + + if ((source === 'igdb' && id) || search) + { + const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); + + const { data: games } = await this.queue.add(() => client.request('games') + .pipe(...(search ? [igdb.search(search)] : []), + igdb.fields(['id', 'name', 'summary', 'screenshots.image_id', 'slug', 'first_release_date', 'rating', 'genres.name', 'involved_companies.company.name', 'keywords.name', 'game_modes.name', 'cover.image_id', 'age_ratings.rating_category.rating', 'platforms.name', 'platforms.abbreviation', 'platforms.slug']), + ...(source === 'igdb' && id ? [igdb.where('id', '=', Number(id))] : []), + igdb.limit(10)).execute()); + + matches.set(desc.name, games.filter(g => !!g.name) + .map(g => + { + const lookup: GameLookup = { + source: 'igdb', + id: String(g.id), + coverUrl: g.cover ? `https://images.igdb.com/igdb/image/upload/t_720p/${g.cover.image_id}.webp` : undefined, + screenshotUrls: g.screenshots?.map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) ?? [], + name: g.name!, + summary: g.summary, + genres: g.genres?.map(g => g.name!) ?? [], + companies: g.involved_companies?.filter(c => c.company?.name).map(c => c.company?.name!) ?? [], + game_modes: g.game_modes?.map(m => m.name!) ?? [], + age_ratings: g.age_ratings?.map(r => r.rating_category?.rating!) ?? [], + player_count: undefined, + // UNIX date, needs to be converted + first_release_date: g.first_release_date ? g.first_release_date * 1000 : undefined, + average_rating: g.rating ?? undefined, + keywords: g.keywords?.map(k => k.name!) ?? [], + igdb_id: g.id ?? undefined, + platforms: g.platforms?.map(p => ({ id: p.id!, name: p.abbreviation, displayName: p.name!, slug: p.slug! })) ?? [], + slug: g.slug + }; + + return lookup; + })); + + return matches; + } + + return matches.set(desc.name, []); + }); + + ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => + { + let query: string | undefined = undefined; + if (source && id) + { + if (source !== 'igdb') return; + query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where id = ${id};`; + + } + else if (slug) + { + query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where slug = "${slug}";`; + } + + if (query) + { + const data = await this.apiCall<[any]>('/platforms', query); + if (!data || data.length <= 0) return; + return { + slug: data[0].slug, + url_logo: `https://images.igdb.com/igdb/image/upload/t_logo_med/${data[0].platform_logo.image_id}.png`, + name: data[0].name, + family_name: data[0].platform_family?.name + }; + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json new file mode 100644 index 0000000..55939bb --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.igdb", + "displayName": "IGDB Integration", + "version": "0.0.1", + "description": "IGDB Metadata Integration", + "main": "./igdb.ts", + "icon": "data:image/svg+xml,%3Csvg%20role%3D%22img%22%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EIGDB%3C%2Ftitle%3E%3Cpath%20d%3D%22M24%206.228c-8%20.002-16%200-24%200v11.543a88.875%2088.875%200%200%201%202.271-.333%2074.051%2074.051%200%200%201%2017.038-.28c1.57.153%203.134.363%204.69.614V6.228zm-.706.707v10.013a74.747%2074.747%200%200%200-22.588%200V6.934h22.588ZM7.729%208.84a2.624%202.624%200%200%200-1.857.72%202.55%202.55%200%200%200-.73%201.33c-.098.5-.063%201.03.112%201.51.177.488.515.917.954%201.196.547.354%201.224.472%201.865.401a3.242%203.242%200%200%200%201.786-.777c-.003-.724.002-1.449-.002-2.173-.725.004-1.45-.002-2.174.003.003.317%200%20.634.001.951h1.105c.002.236%200%20.473.002.71-.268.196-.603.286-.932.298-.32.02-.65-.05-.922-.225a1.464%201.464%200%200%201-.59-.744c-.18-.499-.134-1.085.163-1.53.23-.355.619-.61%201.043-.647a1.8%201.8%200%200%201%201.012.206c.152.082.286.192.424.295.228-.281.461-.559.692-.838a3.033%203.033%200%200%200-.595-.403c-.418-.212-.892-.285-1.357-.283Zm11.66.086c-.093%200-.187.002-.28%200-.68.002-1.359-.004-2.038.003.003%201.666%200%203.332.002%204.998h2.497c.239-.002.478-.034.709-.097.276-.076.546-.208.742-.422.194-.208.297-.492.304-.776.016-.278-.032-.572-.195-.804-.175-.252-.453-.408-.734-.514.211-.122.407-.285.521-.505.134-.246.149-.535.117-.807a1.156%201.156%200%200%200-.436-.73c-.264-.207-.599-.304-.93-.334a2.757%202.757%200%200%200-.279-.012Zm-16.715%200v5.002h1.102V8.927c-.368-.002-.735%200-1.102%200zm8.524%200v5.002h2.016a2.87%202.87%200%200%200%201.07-.211%202.445%202.445%200%200%200%201.174-.993c.34-.555.429-1.244.292-1.876a2.367%202.367%200%200%200-.828-1.338c-.478-.387-1.096-.577-1.707-.584h-2.017zm6.949.967c.392.002.784-.001%201.176.002.183.011.38.054.51.19.11.112.136.28.112.43a.436.436%200%200%201-.22.316%201.082%201.082%200%200%201-.483.116c-.365.002-.73-.001-1.094.001-.002-.351%200-.703-.001-1.054zm-5.031.026c.28%200%20.567.053.815.19.274.149.491.396.607.685.113.272.138.574.107.865a1.456%201.456%200%200%201-.335.786%201.425%201.425%200%200%201-.865.466c-.168.031-.34.022-.51.023h-.632V9.92h.813zm5.03%201.948h1.36c.174.006.354.035.505.127.11.066.191.18.212.308.025.15.004.32-.099.44-.102.12-.258.176-.409.2-.172.032-.348.02-.522.022-.35-.001-.698.002-1.047-.001v-1.096z%22%20%2F%3E%3C%2Fsvg%3E", + "category": "sources", + "keywords": [ + "integration", + "igdb" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json index 815ddb0..52c2376 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json @@ -5,6 +5,7 @@ "description": "ROMM Server Integration", "main": "./romm.ts", "icon": "https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg", + "category": "sources", "keywords": [ "integration", "romm" 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 654fb2a..2e63269 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 @@ -1,41 +1,74 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; -import { config } from "@/bun/api/app"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { config, events } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; -import { hashFile, isSteamDeckGameMode } from "@/bun/utils"; +import { hashFile, isArchive, isSteamDeckGameMode } from "@/bun/utils"; import { CACHE_KEYS, getOrCached } from "@/bun/api/cache"; import secrets from "@/bun/api/secrets"; import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { client } from "@/clients/romm/client.gen"; +import { validateGameSource } from "@/bun/api/games/services/statusService"; +import z from "zod"; +import { checkLoginAndRefreshRomm } from "@/bun/api/auth"; +import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared"; +import Conf from "conf"; -export default class RommIntegration implements PluginType +const SettingsSchema = z.object({ + savesSync: z.boolean().default(false).describe("Experimental save sync support"), + clientApiToken: z.string().optional().describe("Generate a long lived token from the ROMM server") +}); + +type SettingsType = z.infer; + +export default class RommIntegration implements PluginType { + settingsSchema = SettingsSchema; isSteamDeck = false; + orderByMap: Record = { + added: "created_at", + activity: "created_at", + name: "name", + release: "metadatum.first_release_date" + }; - async updateClient () + async checkRemote () + { + if (!config.has('rommAddress')) return false; + return true; + } + + async getAccessToken (config: Conf) + { + if (process.env.ROMM_CLIENT_TOKEN) return process.env.ROMM_CLIENT_TOKEN; + const client_token = await config.get('clientApiToken'); + if (client_token) return client_token; + return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined; + } + + async updateClient (pluginConfig: Conf) { client.setConfig({ baseUrl: config.get('rommAddress'), - async auth (auth) + auth: (auth) => { if (auth.scheme === 'bearer') { - return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined; + return this.getAccessToken(pluginConfig); } } }); } - async getAuthToken () + async getAuthToken (config: Conf) { return getAuthToken({ scheme: 'bearer', type: "http" - }, async (a) => (await secrets.get({ service: "gameflow", name: 'romm_access_token' })) ?? undefined); + }, async (a) => this.getAccessToken(config)); } async getAllRommPlatforms () @@ -47,9 +80,12 @@ export default class RommIntegration implements PluginType { const game: FrontEndGameType = { id: { id: String(rom.id), source: 'romm' }, - path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`, - last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null, + path_covers: [`/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`], + last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null, updated_at: new Date(rom.created_at), + metadata: { + first_release_date: rom.metadatum.first_release_date !== null ? new Date(rom.metadatum.first_release_date) : null, + }, slug: rom.slug, platform_id: rom.platform_id, platform_display_name: rom.platform_display_name, @@ -73,9 +109,17 @@ export default class RommIntegration implements PluginType fs_size_bytes: rom.fs_size_bytes, local: false, missing: rom.missing_from_fs, - genres: rom.metadatum.genres, - companies: rom.metadatum.companies, - release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined + igdb_id: rom.igdb_id, + ra_id: rom.ra_id, + metadata: { + age_ratings: rom.metadatum.age_ratings, + genres: rom.metadatum.genres, + companies: rom.metadatum.companies, + game_modes: rom.metadatum.game_modes, + player_count: rom.metadatum.player_count, + average_rating: rom.metadatum.average_rating, + first_release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : null + } }; const userData = await getCurrentUserApiUsersMeGet(); @@ -108,62 +152,75 @@ export default class RommIntegration implements PluginType return detailed; } - async setup () + async load (ctx: PluginLoadingContextType) { this.isSteamDeck = isSteamDeckGameMode(); - await this.updateClient(); - } + ctx.setProgress(0, "Logging Into Romm"); + await this.updateClient(ctx.config); + await checkLoginAndRefreshRomm(); + await this.updateClient(ctx.config); - load (ctx: PluginContextType) - { ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => { + if (!await this.checkRemote()) return; if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { - - const orderByMap: Record = { - added: "created_at", - activity: "created_at", - name: "name" - }; - const rommGames = await getRomsApiRomsGet({ query: { platform_ids: query.platform_id ? [query.platform_id] : undefined, collection_id: query.collection_id, limit: query.limit, offset: query.offset, - order_by: orderByMap[query.orderBy ?? ''] + order_by: this.orderByMap[query.orderBy ?? ''], + with_filter_values: false, + genres: query.genres, + genres_logic: "all", + age_ratings: query.age_ratings, + search_term: query.search, }, throwOnError: true }); + games.push(...rommGames.data.items.map(g => { - return this.convertRomToFrontend(g); + const game: FrontEndGameTypeWithIds = { + ...this.convertRomToFrontend(g), + igdb_id: g.igdb_id, + ra_id: g.ra_id + }; + return game; })); } }); - ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) => + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => { - if (service !== 'romm') return; - await this.updateClient(); + if (!await this.checkRemote()) return; + if (source && source !== 'romm') return; + + const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true }); + rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r)); + rommFilters.data.companies.forEach(r => filters.companies.add(r)); + rommFilters.data.languages.forEach(r => filters.languages.add(r)); + rommFilters.data.player_counts.forEach(r => filters.player_counts.add(r)); + rommFilters.data.genres.forEach(r => filters.genres.add(r)); }); - ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id, localGame }) => + ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) => { + if (!await this.checkRemote()) return; + if (service !== 'romm') return; + await this.updateClient(ctx.config); + }); + + ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => + { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } }); if (rom.data) { const romGame = await this.convertRomToFrontendDetailed(rom.data); - if (localGame) - { - return { - ...romGame, - ...localGame, - }; - } return romGame; } @@ -172,6 +229,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data; @@ -181,8 +239,9 @@ export default class RommIntegration implements PluginType const files = await Promise.all(rom.files.map(async f => { + getRomContentApiRomsIdContentFileNameGet; const file: DownloadFileEntry = { - url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`), + url: new URL(`${config.get('rommAddress')}/api/roms/${f.id}/files/content/${f.file_name}`), file_name: f.file_name, file_path: f.file_path, size: f.file_size_bytes, @@ -191,8 +250,21 @@ export default class RommIntegration implements PluginType return file; })); + let extract_path: string | undefined = undefined; + let path_fs = path.join(rom.fs_path, rom.fs_name); + if (files.length === 1) + { + if (isArchive(files[0].file_name)) + { + extract_path = '.'; + path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext); + } + } + const info: DownloadInfo = { platform: { + source: 'romm', + id: String(rommPlatform.id), slug: rommPlatform.slug, name: rommPlatform.name, family_name: rommPlatform.family_name ?? undefined @@ -204,21 +276,24 @@ export default class RommIntegration implements PluginType ra_id: rom.ra_id ?? undefined, summary: rom.summary ?? undefined, name: rom.name ?? "Unknown", - path_fs: path.join(rom.fs_path, rom.fs_name), + path_fs, source_id: String(rom.id), slug: rom.slug ?? undefined, system_slug: rommPlatform.slug, metadata: rom.metadatum, files, - auth: await this.getAuthToken() + auth: await this.getAuthToken(ctx.config), + extract_path, + id: "romm" }; - return info; + return [info]; }); ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) => { + if (!await this.checkRemote()) return; const files: DownloadFileEntry[] = []; const allRommPlatforms = await this.getAllRommPlatforms(); @@ -244,21 +319,22 @@ export default class RommIntegration implements PluginType } } - if (files.length > 0) return { files, auth: await this.getAuthToken() }; + if (files.length > 0) return { files, auth: await this.getAuthToken(ctx.config) }; }); ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) => { + if (!await this.checkRemote()) return; const rommPlatforms = await this.getAllRommPlatforms(); if (rommPlatforms) { const rommPlatform = rommPlatforms.find(p => p.slug === game.platform_slug); if (rommPlatform) { - const rommGames = await getRomsApiRomsGet({ query: { genres: game.genres, genres_logic: 'any' } }); + const rommGames = await getRomsApiRomsGet({ query: { genres: game.metadata.genres, genres_logic: 'any' } }); if (rommGames.data) { - games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g), metadata: g.metadatum }))); + games.push(...rommGames.data.items.map(g => ({ ...this.convertRomToFrontend(g) }))); } } } @@ -266,7 +342,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) => { - + if (!await this.checkRemote()) return; const rommPlatforms = await this.getAllRommPlatforms(); const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!)); if (rommPlatforms) @@ -296,6 +372,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } }); if (rommPlatform) @@ -318,7 +395,13 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchPlatforms.tapPromise(desc.name, async ({ platforms }) => { - const rommPlatforms = await this.getAllRommPlatforms(); + if (!await this.checkRemote()) return; + const rommPlatforms = await this.getAllRommPlatforms().catch(e => + { + console.error(e); + return undefined; + }); + if (rommPlatforms) { const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p => @@ -352,16 +435,139 @@ export default class RommIntegration implements PluginType } }); - ctx.hooks.games.updatePlayed.tapPromise(desc.name, async ({ source, id }) => + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) => { - if (source !== 'romm') return false; + if (!await this.checkRemote()) return; + if (source !== 'romm' || !ctx.config.get('savesSync')) return; + if (!saveFolderSlots) return; + + for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) + { + setProgress(0, "saves"); + + const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); + if (saveFiles.error) + { + console.error(saveFiles.error); + } else + { + const rommSlot = saveFiles.data.slots.find(s => s.slot === 'gameflow' && s.latest.file_name_no_tags === slot); + if (rommSlot) + { + const auth = await this.getAuthToken(ctx.config); + const headers: Record = {}; + if (auth) + headers['Authorization'] = auth; + + const saveResponse = await fetch(`${config.get('rommAddress')}${rommSlot.latest.download_path}`, { headers }); + if (!saveResponse.ok) + { + console.error("Error downloading save", saveResponse.statusText); + return; + } + + const saveArchive = new Bun.Archive(await saveResponse.blob()); + setProgress(50, "saves"); + const count = await saveArchive.extract(cwd); + setProgress(100, "saves"); + console.log("Loaded", count, "save files"); + } + } + + setProgress(100, "saves"); + await Bun.sleep(1000); + } + }); + + // Should run after emulators decide on saves + ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) => + { + if (!await this.checkRemote()) return; + if (source !== 'romm' || !ctx.config.get('savesSync')) return; + + const sourceValidation = await validateGameSource(source, id); + if (!sourceValidation.valid) + { + console.warn("Invalid Source", sourceValidation.reason, "Skipping updates"); + return; + } + + /*const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared && !f.isGlob).flatMap(s => Array.isArray(s.subPath) ? s.subPath.map(p => ({ cwd: s.cwd, subPath: p })) : [{ cwd: s.cwd, subPath: s.subPath }]); + + const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); + if (saveFiles.error) + { + console.error(saveFiles.error); + } else if (saveFolderPath) + { + for (let i = 0; i < saveFiles.data.slots.length; i++) + { + const slot = saveFiles.data.slots[i]; + const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (await fs.exists(savePath)) + { + const stat = await fs.stat(savePath); + if (stat.mtimeMs > new Date(slot.latest.updated_at).getTime()) + { + const subPath = path.join(slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); + if (!finalSavePaths.some(f => f.subPath === subPath)) + { + // Add newer files to the list, maybe they were changed offscreen. + finalSavePaths.push({ subPath, cwd: saveFolderPath }); + } + } + } + } + }*/ + + const finalSavePaths = Object.entries(validChangedSaveFiles).filter(([slot, change]) => !change.isGlob && !change.shared); + + if (finalSavePaths.length > 0) + { + console.log("Files Changed:", finalSavePaths.map(([slot, change]) => Array.isArray(change.subPath) ? change.subPath.join(',') : change.subPath)?.join(", ")); + + await Promise.all(finalSavePaths.map(async ([slot, change]) => + { + const savesArray = Array.isArray(change.subPath) ? change.subPath : [change.subPath]; + + // TODO: handle directories + const archive = new Bun.Archive(Object.fromEntries(savesArray.map(s => [s, Bun.file(path.join(change.cwd, s))]))); + const data: FormData = new FormData(); + data.append('saveFile', await archive.blob(), slot); + + const url = new URL(`${config.get('rommAddress')}/api/saves`); + url.searchParams.set('rom_id', id); + url.searchParams.set('slot', slot); + url.searchParams.set('autocleanup', "true"); + url.searchParams.set('autocleanup_limit', "2"); + if (command.emulator) + url.searchParams.set('emulator', command.emulator); + url.searchParams.set('overwrite', "true"); + + const auth = await this.getAuthToken(ctx.config); + const headers: Record = {}; + if (auth) + headers['Authorization'] = auth; + + const response = await fetch(url, { + body: data, + method: "POST", + headers + }); + if (!response.ok) console.error(response.statusText); + })); + + events.emit('notification', { message: "Saves Uploaded", icon: 'upload', type: "success" }); + } + const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } }); if (resp.error) console.error(resp.error); - return resp.response.ok; + events.emit('notification', { message: "Updated Played", type: "success", icon: "clock" }); }); ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) => { + if (!await this.checkRemote()) return; const rommCollections = await getCollectionsApiCollectionsGet(); if (rommCollections.response.ok && rommCollections.data) { @@ -382,6 +588,7 @@ export default class RommIntegration implements PluginType ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) => { + if (!await this.checkRemote()) return; if (source !== 'romm') return; const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } }); if (collection.data) @@ -398,11 +605,35 @@ export default class RommIntegration implements PluginType }); - ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id }) => + ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => { + if (!await this.checkRemote()) return; + let platform: PlatformSchema | undefined = undefined; + + if (id && source) + { + if (source !== 'romm') return; + const platforms = await this.getAllRommPlatforms(); + platform = platforms.find(p => p.id === Number(id)); + + } else if (slug) + { + const platforms = await this.getAllRommPlatforms(); + platform = platforms.find(p => p.slug === slug); + } + + if (!platform) return; + return { slug: platform?.slug, url_logo: platform.url_logo, name: platform.display_name, family_name: platform.family_name ?? undefined }; + }); + + ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) => + { + if (!await this.checkRemote()) return; if (source !== 'romm') return; - const platforms = await this.getAllRommPlatforms(); - return platforms.find(p => p.id === Number(id)); + const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } }); + if (roms.error) throw roms.error; + if (!roms.data) return; + return this.convertRomToFrontendDetailed(roms.data); }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json new file mode 100644 index 0000000..644c332 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.store", + "displayName": "Gameflow Store Integration", + "version": "0.0.1", + "description": "The internal gameflow store integration. This is the logic of the store that uses the data only store package", + "main": "./store.ts", + "category": "sources", + "canDisable": false, + "keywords": [ + "internal", + "store" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts new file mode 100644 index 0000000..4935dd7 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -0,0 +1,365 @@ +import { getStoreFolder } from "@/bun/api/store/services/gamesService"; +import os from 'node:os'; +import path from "node:path"; +import * as appSchema from '@schema/app'; +import * as emulatorSchema from '@schema/emulators'; +import { config, db, emulatorsDb, plugins } from "@/bun/api/app"; +import { and, eq } from "drizzle-orm"; +import { getOrCached } from "@/bun/api/cache"; +import { Glob } from "bun"; +import { shuffleInPlace } from "@/bun/utils"; +import mustache from "mustache"; +import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; +import fs from "node:fs/promises"; +import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange, EmulatorDownloadInfoType, StoreDownloadType, StoreGameType, EmulatorPackageType, EmulatorDownloadInfoSchema, StoreGameSchema } from "@simeonradivoev/gameflow-sdk/shared"; +import { isUrl } from "@/shared/utils"; + +export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) +{ + const offset = filter?.offset ?? 0; + const limit = Math.min(50, filter?.limit ?? 10); + + const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) => + { + return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, ""))))); + })); + + return games; +} + +export async function getStoreGame (id: string) +{ + const file = Bun.file(path.join(getStoreFolder(), 'buckets', 'games', `${id}.json`)); + if (!(await file.exists())) return undefined; + const game = file + .json() + .then(g => StoreGameSchema.parseAsync(g)) + .then(g => ({ ...g, id })); + return game; +} + +function convertStoreMediaToPath (c: string) +{ + if (isUrl(c)) + { + return `/api/romm/image?url=${encodeURIComponent(c)}`; + } else + { + return `/api/store/media/${c}`; + } +} + +export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise +{ + const validDownloads = getValidDownloads(storeGame); + + let platform_slug: string | null = null; + let platform_id: number | null = null; + let platform_display_name: string | null = null; + let path_platform_cover: string | null = null; + + if (validDownloads.length > 0 && validDownloads[0].system) + { + let system = validDownloads[0].system.split(':')[0]; + if (system === 'win32') system = 'win'; + + const localPlatform = await db.query.platforms.findFirst({ where: eq(appSchema.platforms.slug, system), columns: { id: true, slug: true, name: true } }); + if (localPlatform) + { + platform_id = localPlatform.id; + platform_slug = localPlatform.slug; + path_platform_cover = `/api/romm/platform/local/${localPlatform.id}/cover`; + platform_display_name = localPlatform.name; + } + + if (platform_slug === null) + { + const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.sourceSlug, system), eq(emulatorSchema.systemMappings.source, 'romm')) + }); + + if (rommSystem?.system) + { + const platformDef = await emulatorsDb.query.systems.findFirst({ + where: eq(emulatorSchema.systems.name, rommSystem?.system), + columns: { fullname: true } + }); + + platform_slug = rommSystem.system; + platform_display_name = platformDef?.fullname ?? null; + path_platform_cover = `/api/romm/image/romm/assets/platforms/${rommSystem.sourceSlug}.svg`; + + } else + { + const platformDef = await emulatorsDb.query.systems.findFirst({ + where: eq(emulatorSchema.systems.name, system), + columns: { fullname: true } + }); + + platform_slug = system; + platform_display_name = platformDef?.fullname ?? null; + } + + platform_slug ??= system; + } + } + + + const game: FrontEndGameType = { + platform_display_name, + path_platform_cover, + id: { source: 'store', id: id }, + source: null, + source_id: null, + path_fs: null, + path_covers: storeGame.covers?.map(convertStoreMediaToPath) ?? [], + last_played: null, + updated_at: new Date(), + slug: id, + name: storeGame.name, + platform_id, + platform_slug, + paths_screenshots: storeGame.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], + metadata: { + first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null + } + }; + + return game; +} + + +export async function convertStoreToFrontendDetailed (id: string, storeGame: StoreGameType): Promise +{ + const validDownloads = getValidDownloads(storeGame); + let size: number | null = null; + if (validDownloads.length > 0 && validDownloads[0].url) + { + try + { + const fileResponse = await fetch(validDownloads[0]?.url, { method: 'HEAD' }); + size = Number(fileResponse.headers.get('content-length')); + } catch (error) + { + console.error(error); + } + } + + const detailed: FrontEndGameTypeDetailed = { + ...await convertStoreToFrontend(id, storeGame), + summary: storeGame.description, + fs_size_bytes: size, + missing: false, + local: false, + version: storeGame.version, + igdb_id: storeGame.igdb_id ?? null, + ra_id: storeGame.ra_id ?? null, + metadata: { + genres: storeGame.genres ?? [], + companies: storeGame.companies ?? [], + game_modes: [], + age_ratings: [], + player_count: storeGame.player_count ?? null, + average_rating: null, + first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null + } + }; + + return detailed; +} + +export function getValidDownloads (game: StoreGameType, downloadId?: string) +{ + const downloads = Object.entries(game.downloads).map(([k, d]) => ({ id: k, ...d })); + const supportedDownloads = downloads.filter(d => d.type === 'direct'); + + if (downloadId) + { + return supportedDownloads.filter(d => d.id === downloadId); + } else + { + return supportedDownloads.filter(d => + { + if (d.system === `${process.platform}:${process.arch}`) return true; + + // TODO: Add linux proton support + //if (process.platform === 'linux' && d.system === `win32:${process.arch}`) return true; + + // emulator fallback + return !d.system.includes(':'); + }).toSorted((a, b) => + { + const bScore = b.system.includes(':') ? 0 : 1; + const aScore = a.system.includes(':') ? 0 : 1; + + return bScore - aScore; + }); + } +} + +export async function getShuffledStoreGames () +{ + return getOrCached('shuffled-store-games', async () => + { + const files = new Glob(path.join(getStoreFolder(), 'buckets', 'games', '*.json')).scan(); + const allGamePaths = await Array.fromAsync(files); + const allStoreGames = await Promise.all(allGamePaths.map(p => Bun.file(p).json().then(g => StoreGameSchema.parseAsync(g)).then(g => ({ ...g, id: path.basename(p, '.json') })))); + shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60)); + return allStoreGames; + }, { expireMs: 1000 / 60 / 60 }); +} + +export async function buildFilters (filters: FrontEndFilterSets) +{ + const filtersFile = Bun.file(path.join(getStoreFolder(), 'manifests', 'filters.json')); + if (!await filtersFile.exists()) return; + const storeFilters = await filtersFile.json(); + + storeFilters.genres?.forEach((g: string) => filters.genres.add(g)); + storeFilters.age_ratings?.forEach((g: string) => filters.age_ratings.add(g)); + if (storeFilters.player_count) + filters.player_counts.add(storeFilters.player_count); + storeFilters.companies?.forEach((g: string) => filters.companies.add(g)); +} + +function getAppData () +{ + if (process.platform === "win32") return process.env.APPDATA!; + if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support"); + // linux + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); +} + +function getLocalAppData () +{ + if (process.platform === "win32") return process.env.LOCALAPPDATA!; + if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Caches"); + // Linux / Unix + return process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); +} + +export function buildSaves (command: CommandEntry, storeGame: StoreGameType, download?: StoreDownloadType) +{ + let saveFileGlobs: Record | undefined = undefined; + if (download && download.saves) + { + saveFileGlobs = download.saves; + + } else if (storeGame.saves) + { + const platformSaves = storeGame.saves[`${process.platform}:${process.arch}`]; + if (platformSaves) + { + saveFileGlobs = platformSaves; + } + } + + const view = { + GAMEDIR: command.startDir, + HOMEDIR: os.homedir(), + TMPDIR: os.tmpdir(), + APPDATA: getAppData(), + LOCALAPPDATA: getLocalAppData(), + }; + + if (!saveFileGlobs) return; + + return Object.entries(saveFileGlobs).map(([slot, save]) => + { + const cwd = mustache.render(save.cwd, view); + const change: SaveFileChange = { + cwd, + shared: false, + isGlob: true, + subPath: save.globs + }; + return [slot, change] as [string, SaveFileChange]; + }); +} + +export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, systems: EmulatorSystem[]) +{ + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: emulator.name, sources: execPaths }); + + const em: FrontEndEmulator = { + name: emulator.name, + logo: emulator.logo, + systems, + gameCount: 0, + validSources: execPaths, + integrations: [], + source: "store" + }; + + return em; +} + +export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined> +{ + const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`; + if (await fs.exists(existingPackagePath)) + { + const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json()); + const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined); + if (!download) return { ...existingPackage, hasUpdate: false }; + if (download.info.version) + { + if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true }; + } else if (existingPackage.id !== download.info.id) + { + return { ...existingPackage, hasUpdate: true }; + } + + return { ...existingPackage, hasUpdate: false }; + } + + // this should only happen if download info is missing maybe manually deleted or wasn't saved. + return undefined; +} + +export async function buildLaunchCommand (ctx: { gamePath: string; systemSlug: string; mainGlob?: string | null; }): Promise +{ + if (ctx.systemSlug !== 'win' && ctx.systemSlug !== 'linux' && ctx.systemSlug !== 'mac') return; + const downloadPath = config.get('downloadPath'); + const gamePathAbsolute = path.join(downloadPath, ctx.gamePath); + if (!(await fs.exists(gamePathAbsolute))) return; + const gamePathStat = await fs.stat(gamePathAbsolute); + + if (gamePathStat.isDirectory()) + { + let mainGlob = ctx.mainGlob; + if (!mainGlob && ctx.systemSlug === 'win') mainGlob = '**/*.exe'; + if (!mainGlob) return; + const fileGlob = new Glob(mainGlob); + for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, ctx.gamePath) })) + { + return { + startDir: path.join(downloadPath, ctx.gamePath, path.dirname(file)), + command: [`./${path.basename(file)}`], + id: `store-${process.platform}`, + shell: false, + valid: true, + metadata: { + romPath: path.join(downloadPath, ctx.gamePath, file) + } + }; + } + + } else + { + return { + startDir: path.join(downloadPath, path.dirname(ctx.gamePath)), + command: [`./${path.basename(ctx.gamePath)}`], + id: `store-${process.platform}`, + valid: true, + shell: false, + metadata: { + romPath: path.join(downloadPath, ctx.gamePath), + } + }; + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts new file mode 100644 index 0000000..90d1cbc --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -0,0 +1,459 @@ +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import desc from './package.json'; +import path, { } from 'node:path'; +import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; +import { Glob, pathToFileURL, sleep, which } from "bun"; +import { and, eq } from "drizzle-orm"; +import * as emulatorSchema from '@schema/emulators'; + +import { config, emulatorsDb, taskQueue } from "@/bun/api/app"; +import fs from "node:fs/promises"; +import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; +import EnsureStore from "@/bun/api/jobs/ensure-store"; +import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; +import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; +import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared"; +import { isUrl } from "@/shared/utils"; +import { Downloader } from "@/bun/utils/downloader"; +import { ensureDir, move } from "fs-extra"; +import StreamZip from "node-stream-zip"; +import { path7za } from "7zip-bin"; +import Seven from 'node-7z'; + +export default class StoreIntegration implements PluginType +{ + eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }]; + + async onEvent (e: string) + { + switch (e) + { + case 'updateStore': + await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); + return { reload: true }; + } + } + + async setup (ctx: PluginLoadingContextType) + { + console.log("Store Directory is ", getStoreFolder()); + ctx.setProgress(0, "Updating Store"); + await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); + } + + async load (ctx: PluginLoadingContextType) + { + await this.setup(ctx); + + ctx.hooks.store.fetchDownload.tapPromise(desc.name, async ({ id }) => + { + const emulatorPackage = await getStoreEmulatorPackage(id); + if (!emulatorPackage) return; + const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); + return downloadInfo; + }); + + ctx.hooks.store.fetchEmulator.tapPromise(desc.name, async ({ id }) => + { + const emulatorPackage = await getStoreEmulatorPackage(id); + if (!emulatorPackage) return undefined; + + const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage); + + const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id); + const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; + const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); + const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; + const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); + + const emulator: FrontEndEmulatorDetailed = { + name: emulatorPackage.name, + description: emulatorPackage.description, + source: "store", + systems, + validSources: [], + screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), + gameCount: 0, + homepage: emulatorPackage.homepage, + downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => + { + const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined); + return download?.info; + }) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })), + logo: emulatorPackage.logo, + biosRequirement: emulatorPackage.bios, + bios: biosFiles, + integrations: [], + storeDownloadInfo: storeDownloadInfo + }; + + return emulator; + }); + + ctx.hooks.store.fetchEmulators.tapPromise(desc.name, async ({ emulators, search }) => + { + const emulatesParsed = await getAllStoreEmulatorPackages(); + emulators.push(...await Promise.all(emulatesParsed + .filter(e => + { + if (!e.os.includes(process.platform as any)) return false; + if (search) + { + if (e.name.toLocaleLowerCase().includes(search) || e.systems.some(s => s.toLocaleLowerCase().includes(search)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(search))) + { + return true; + } + + return false; + } + return true; + }) + .map(async (emulator) => + { + const systems = await buildStoreFrontendEmulatorSystems(emulator); + return convertStoreEmulatorToFrontend(emulator, systems); + }))); + }); + + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, command }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + const localGame = await getSourceGameDetailed(source, id); + + if (!localGame || !storeGame) return; + if (!localGame.version_source) return; + + const download = storeGame.downloads[localGame.version_source]; + const saves = buildSaves(command, storeGame, download); + + saves?.forEach(([slot, save]) => saveFolderSlots[slot] = { cwd: save.cwd }); + }); + + ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ validChangedSaveFiles, source, id, command }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + const localGame = await getSourceGameDetailed(source, id); + + if (!localGame || !storeGame) return; + if (!localGame.version_source) return; + + const download = storeGame.downloads[localGame.version_source]; + + const saves = buildSaves(command, storeGame, download); + saves?.forEach(([key, val]) => validChangedSaveFiles[key] = val); + }); + + ctx.hooks.emulators.findEmulatorSource.tapPromise(desc.name, async ({ emulator, sources }) => + { + const emulatorPackage = await getStoreEmulatorPackage(emulator); + if (!emulatorPackage) return undefined; + const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); + if (!storeDownloadInfo) return; + const emulatorPath = getEmulatorPath(emulator); + if (!await fs.exists(emulatorPath)) return; + const validDownload = emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].find(d => d.type === storeDownloadInfo?.type); + if (!validDownload || !validDownload.bin) return; + const glob = new Glob(validDownload.bin); + const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath })); + // es-de also searches for store executables so there might be duplicates, check first. + if (files.length > 0 && !sources.find(s => s.type === 'store')) + { + sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' }); + } + }); + + ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: 'UMU' }, async ({ path: emulatorPath }) => + { + const pathStat = await fs.stat(emulatorPath); + if (pathStat.isFile()) + { + await fs.chmod(emulatorPath, 0o755); + } + }); + + ctx.hooks.games.postInstall.tapPromise(desc.name, async ({ source, id, files, info }) => + { + if (source !== 'store') return; + if (files.length === 1) + { + const command = await buildLaunchCommand({ gamePath: files[0], systemSlug: info.system_slug, mainGlob: info.main_glob }); + if (command && command.metadata.romPath) + { + await fs.chmod(command.metadata.romPath, 0o755); + } + } + }); + + ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) => + { + if (source !== 'store' || !gamePath) return; + const command = await buildLaunchCommand({ gamePath, systemSlug, mainGlob }); + if (!command) return; + return [command]; + }); + + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => + { + if (!source || source !== 'store') return; + await buildFilters(filters); + }); + + ctx.hooks.store.fetchFeaturedGames.tapPromise(desc.name, async ({ games }) => + { + const allGames = await getShuffledStoreGames(); + const convertedGames = await Promise.all(allGames.slice(0, 3).map(async g => + { + return convertStoreToFrontendDetailed(g.id, g); + })); + games.push(...convertedGames); + }); + + ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => + { + if (!query.source || query.source !== 'store') return; + if (query.collection_source || query.collection_id) return; + + const shuffledGames = await getShuffledStoreGames(); + const storeGames = await Promise.all(shuffledGames.filter(g => + { + if (query.search) + return path.basename(g.name).toLocaleLowerCase().includes(query.search.toLocaleLowerCase()); + return true; + }) + .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) + .map(async (e) => + { + const game: FrontEndGameTypeWithIds = { + ...await convertStoreToFrontend(e.id, e), + igdb_id: e.igdb_id ?? null, + ra_id: e.ra_id ?? null + }; + return game; + })); + games.push(...storeGames.filter(g => g !== undefined)); + }); + + ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) => + { + const esSystem = game.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, game.platform_slug)), columns: { system: true } }) : undefined; + + const shuffledGames = await getShuffledStoreGames(); + const storeGames = await Promise.all(shuffledGames + .filter(g => + { + if (esSystem) + { + if (Object.values(g.downloads).some(d => d.system === esSystem.system)) return true; + } + + return false; + }) + .map(async (e) => + { + return convertStoreToFrontend(e.id, e); + })); + + if (storeGames) + { + games.push(...storeGames.slice(0, 3)); + } + }); + + ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) => + { + const systemsIdSet = new Set(systems.map(s => s.id)); + const gamesManifest = await getShuffledStoreGames(); + const storeGames = await Promise.all(gamesManifest + .filter(g => Object.values(g.downloads).some(d => systemsIdSet.has(d.system))) + .map(async (e) => + { + + return convertStoreToFrontend(e.id, e); + })); + + games.push(...storeGames.filter(g => g !== undefined).slice(0, 3)); + }); + + ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + if (storeGame) + { + return convertStoreToFrontendDetailed(id, storeGame); + } + }); + + ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id, downloadId }) => + { + if (source !== 'store') return; + const game = await getStoreGame(id); + if (!game) throw new Error("Missing Store Game"); + + const validDownloads = getValidDownloads(game, downloadId); + + return validDownloads.map(validDownload => + { + let system = validDownload.system.split(":")[0]; + if (system === 'win32') system = 'win'; + + const info: DownloadInfo = { + id: validDownload.id, + coverUrl: game.covers?.[0] ? isUrl(game.covers[0]) ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", + screenshotUrls: game.screenshots ?? [], + files: [{ + url: new URL(validDownload.url), + file_path: `roms/${system}`, + file_name: path.basename(decodeURI(validDownload.url)), + size: 0 + }], + slug: id, + source_id: id, + name: game.name, + summary: game.description, + system_slug: system, + path_fs: path.join('roms', system, game.id), + extract_path: '.', + main_glob: validDownload.main, + version: game.version, + version_system: validDownload.system, + version_source: validDownload.id, + platform: { + source: 'store', + id: system, + slug: system, + name: system + } + }; + + return info; + }); + }); + + ctx.hooks.downloadFiles.tapPromise(desc.name, async ({ id, files, downloadPath, abortSignal, auth, updateProgress }) => + { + const headers: Record = {}; + if (auth) + headers['Authorization'] = auth; + const downloader = new Downloader(id, + files, + downloadPath, + { + signal: abortSignal, + headers, + onProgress: updateProgress, + }); + + const downloadedFiles = await downloader.start(); + if (downloadedFiles) + { + return { source: desc.name, files: downloadedFiles }; + } + }); + + ctx.hooks.postDownloadFiles.tapPromise(desc.name, async ({ files, extract_path, source, downloadPath, path_fs }) => + { + if (extract_path && files && source === desc.name) + { + let progress = 0; + const progressDelta = 1 / files.length; + const extractPath = path.join(downloadPath, path_fs ?? '', extract_path); + + for (const filePath of files) + { + await new Promise(async (resolve, reject) => + { + let sevenZipPath = process.env.ZIP7_PATH ?? path7za; + + if (filePath.endsWith('.rar')) + { + let newPath: string | undefined; + if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe")) + { + newPath = "C:\\Program Files\\7-Zip\\7z.exe"; + } else + { + newPath = which('7z') ?? undefined; + } + + if (!newPath) + { + await fs.rm(filePath); + reject(new Error("No RAR Support")); + return; + } + + sevenZipPath = newPath; + } + + let rejected = false; + const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true }); + seven.on('progress', p => + { + ctx.setProgress?.(progress + p.percent * progressDelta, "extract", { + speed: 0, + total: 0, + downloaded: 0 + }); + }); + seven.on('error', e => + { + reject(e); + rejected = true; + }); + seven.on('end', async () => + { + if (rejected) return; + await fs.rm(filePath); + resolve(true); + }); + }).catch(async e => + { + if (filePath.endsWith('.zip')) + { + ctx.setProgress?.(0, "extract", {}); + console.error(e); + console.warn("Could not extract", filePath, "with 7zip trying zip extractor"); + await ensureDir(extractPath); + const zip = new StreamZip.async({ file: filePath }); + let entryCount = await zip.entriesCount; + let entryCounter = entryCount; + zip.on('extract', (entry, outPath) => + { + entryCounter--; + ctx.setProgress?.(progress + (1 - (entryCounter / entryCount)) * 100 * progressDelta, "extract", {}); + }); + const count = await zip.extract(null, extractPath); + console.log(`Extracted ${count} entries`); + await zip.close(); + await fs.rm(filePath); + } else + { + throw e; + } + }); + + progress += progressDelta * 100; + } + + // check if 1 root folder we need to get rid of + const contents = await fs.readdir(extractPath); + if (contents.length === 1) + { + const stat = await fs.stat(path.join(extractPath, contents[0])); + if (stat.isDirectory()) + { + console.log("Found 1 root folder, using that instead"); + const tmpGameFolder = `${extractPath} (1)`; + await move(path.join(extractPath, contents[0]), tmpGameFolder, { overwrite: true }); + await move(tmpGameFolder, extractPath, { overwrite: true }); + } + } + + return [extractPath]; + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts index 90392f1..1fab907 100644 --- a/src/bun/api/plugins/plugin-manager.ts +++ b/src/bun/api/plugins/plugin-manager.ts @@ -1,6 +1,18 @@ -import { GameflowHooks } from "../hooks/app"; -import { PluginContextType, PluginDescriptionType, PluginType } from "../../types/typesc.schema"; -import { config } from "../app"; +import { GameflowHooks } from "@simeonradivoev/gameflow-sdk"; +import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import { config, events, taskQueue } from "../app"; +import Conf from "conf"; +import projectPackage from '~/package.json'; +import z from "zod"; +import { PluginSourceType, PluginUpdateCheck } from "@simeonradivoev/gameflow-sdk/shared"; +import { getUpdates } from "./services"; +import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json'; +import { semver } from "bun"; + +export const pluginZodRegistry = z.registry<{ + requiresRestart?: boolean; + readOnly?: boolean; +}>(); export class PluginManager { @@ -11,10 +23,21 @@ export class PluginManager plugin: PluginType; description: PluginDescriptionType, source: PluginSourceType; + config?: Conf; + update?: PluginUpdateCheck; + incompatible?: boolean; }> = {}; - async register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) + unregister (id: string) + { + if (!this.plugins[id]) return false; + delete this.plugins[id]; + console.log("Plugin", id, "unregistered"); + return true; + } + + register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) { try { @@ -24,15 +47,29 @@ export class PluginManager } else { - if (plugin.setup) await plugin.setup(); + let pluginConfig: Conf | undefined = undefined; + if (plugin.settingsSchema) + { + pluginConfig = new Conf({ + projectName: projectPackage.name, + configName: description.name, + projectSuffix: 'bun', + cwd: process.env.CONFIG_CWD, + schema: Object.fromEntries(Object.entries(plugin.settingsSchema.shape).map(([key, schema]) => [key, (schema as z.ZodObject).toJSONSchema() as any])) as any, + defaults: plugin.settingsSchema.parse({}), + migrations: plugin.settingsMigrations as any, + projectVersion: description.version + }); + } + this.plugins[description.name] = { enabled: !config.get('disabledPlugins').includes(description.name), loaded: false, plugin: plugin, source: source, - description: description + description: description, + config: pluginConfig }; - this.reload(description.name); console.log("Plugin", description.name, "registered"); } @@ -44,24 +81,55 @@ export class PluginManager }; } - private reload (name: string) + checkValidity (plugin: PluginDescriptionType) + { + const sdkDep = plugin.peerDependencies?.[sdkPkg.name]; + if (sdkDep) + { + return semver.satisfies(sdkPkg.version, sdkDep); + } + return true; + } + + private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }, update: string | undefined) { const plugin = this.plugins[name]; if (plugin) { - const ctx: PluginContextType = { hooks: this.hooks }; + plugin.update = update && !semver.satisfies(plugin.description.version, update) ? { current: plugin.description.version, new: update } : undefined; + + const ctx: PluginLoadingContextType = { + hooks: this.hooks, + setProgress: reloadCtx.setProgress.bind(reloadCtx), + config: plugin.config as any, + zodRegistry: pluginZodRegistry, + app: { + config, + events, + taskQueue + } + }; if (plugin.loaded) { - plugin.plugin.onBeforeReload?.(ctx); + await plugin.plugin.cleanup?.(); plugin.loaded = false; } try { - if (plugin.enabled) + plugin.incompatible = !this.checkValidity(plugin.description); + if (plugin.incompatible) { - plugin.plugin.load(ctx); + console.error(plugin.description.name, "Incompatible sdk verison"); + return; + } + + if (plugin.enabled || plugin.description.canDisable === false || plugin.description.name === '@simeonradivoev/gameflow-store') + { + console.log("Loading Plugin", plugin.description.name); + await plugin.plugin.load(ctx); + console.log("Loaded Plugin", plugin.description.name); plugin.loaded = true; } } catch (error) @@ -72,10 +140,17 @@ export class PluginManager } } - reloadAll () + async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; }) { this.hooks = new GameflowHooks(); - Object.keys(this.plugins).forEach(id => this.reload(id)); + + const outdated = await getUpdates(); + + for await (const id of Object.keys(this.plugins)) + { + ctx.setProgress(0, `Loading ${id}`); + await this.reload(id, ctx, outdated?.[id]); + } } async cleanup () @@ -84,10 +159,15 @@ export class PluginManager { try { - await p.plugin.cleanup!(); + 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/plugins/plugins.ts b/src/bun/api/plugins/plugins.ts index e276f92..ddfad06 100644 --- a/src/bun/api/plugins/plugins.ts +++ b/src/bun/api/plugins/plugins.ts @@ -1,7 +1,11 @@ import Elysia, { status } from "elysia"; -import { plugins } from "../app"; +import { plugins, taskQueue } from "../app"; import z from "zod"; import { toggleElementInConfig } from "@/bun/utils"; +import ReloadPluginsJob from "../jobs/reload-plugins-job"; +import { FrontendPlugin } from "@simeonradivoev/gameflow-sdk/shared"; +import { canDisable, canUninstall } from "./services"; +import PluginOperationJob from "../jobs/plugin-operation-job"; export default new Elysia({ prefix: '/plugins' }) .get('/', async () => @@ -15,23 +19,59 @@ export default new Elysia({ prefix: '/plugins' }) description: p.description.description, source: p.source, version: p.description.version, - icon: p.description.icon + canDisable: canDisable(p.description), + icon: p.description.icon, + category: p.description.category, + hasSettings: !!p.config || !!p.plugin.eventsNames, + canUninstall: canUninstall(p.description, p.source), + update: p.update }; return plugin; }); }) + .get('/:id', async ({ params: { id } }) => + { + const plugin = plugins.plugins[decodeURIComponent(id)]; + return { ...plugin.description, update: plugin.update }; + }) .post('/:id', async ({ params: { id }, body: { enabled } }) => { - const plugin = plugins.plugins[id]; + const plugin = plugins.plugins[decodeURIComponent(id)]; if (plugin) { + if (!canDisable(plugin.description)) + { + return status("Forbidden"); + } plugin.enabled = enabled; toggleElementInConfig('disabledPlugins', plugin.description.name, enabled); - plugins.reloadAll(); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); } else { return status("Not Found"); } }, { body: z.object({ enabled: z.boolean() }) + }).post('/install', async ({ body: { id } }) => + { + if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return; + await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("add", id)); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + }, { + body: z.object({ id: z.string() }) + }).post('/update', async ({ body: { id } }) => + { + if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return; + await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("update", id)); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + }, { + body: z.object({ id: z.string() }) + }) + .post('/uninstall', async ({ body: { id } }) => + { + if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return; + await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("remove", id)); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + }, { + body: z.object({ id: z.string() }) }); \ No newline at end of file diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 9f223b2..a4b5666 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -2,27 +2,147 @@ import { PluginManager } from "./plugin-manager"; import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json'; import ppsspp from './builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json'; +import dolphin from './builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json'; +import cemu from './builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json'; +import xenia from './builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json'; +import xemu from './builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json'; import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json'; -import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; +import igdb from './builtin/sources/com.simeonradivoev.gameflow.igdb/package.json'; +import store from './builtin/sources/com.simeonradivoev.gameflow.store/package.json'; +import es from './builtin/launchers/com.simeonradivoev.gameflow.es/package.json'; +import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.json'; +import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk"; +import path from 'node:path'; +import { getStoreRootFolder } from "../store/services/gamesService"; +import { getUpdates, runBunPackageCommand } from "./services"; +import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared"; +import { taskQueue } from "../app"; +import EnsureStore from "../jobs/ensure-store"; +import { PluginRegistry } from "@/shared/constants"; +import { IsPluginAllowed } from "@/bun/utils"; + +type PluginEntry = PluginDescriptionType & { load: () => Promise; }; + +const blacklist = new Set(['@simeonradivoev/gameflow-sdk']); + +export async function getPlugin (id: string, pluginManager: PluginManager) +{ + const pluginPath = path.join(getStoreRootFolder(), 'node_modules', id); + const pluginPackageFile = Bun.file(path.join(pluginPath, 'package.json')); + if (await pluginPackageFile.exists()) + { + const pluginPackage = await PluginDescriptionSchema.safeParseAsync(await pluginPackageFile.json()); + if (pluginPackage.success) + { + const mainPath = path.join(pluginPath, pluginPackage.data.main); + if (await Bun.file(mainPath).exists()) + { + const entry: PluginEntry = { ...pluginPackage.data, load: () => import(mainPath) }; + return entry; + } else + { + console.error("Main file for", id, "does not exist"); + } + } else + { + console.error("Invalid Package for", id, pluginPackage.error.message); + } + } else + { + console.error("Package for", id, "does not exist"); + } +} + +export async function unregisterPlugin (id: string, pluginManager: PluginManager) +{ + return pluginManager.unregister(id); +} + +export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager) +{ + if (!IsPluginAllowed(plugin.name)) + { + console.log("Skipping", plugin.name, "plugin not allowed"); + return; + } + + const file = await plugin.load(); + if (file.default && typeof file.default === 'function') + { + const pluginInstance = new file.default(); + await PluginSchema.parseAsync(pluginInstance); + const description = await PluginDescriptionSchema.parseAsync(plugin); + pluginManager.register(pluginInstance, description, source); + } else + { + console.log("Skipping", plugin.name, "invalid main. Has to be class with load method"); + } +} export default async function register (pluginManager: PluginManager) { - - const plugins: (PluginDescriptionType & { main: string; load: () => Promise; })[] = [ + const plugins: PluginEntry[] = [ { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') }, { ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') }, + { ...dolphin, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin') }, + { ...cemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu') }, + { ...xenia, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia') }, + { ...xemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu') }, { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') }, + { ...igdb, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.igdb/igdb') }, + { ...es, load: () => import('./builtin/launchers/com.simeonradivoev.gameflow.es/es-de') }, + { ...store, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.store/store') }, + { ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') }, ]; - await Promise.all(plugins.map(async (pluginPackage) => + await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager))); + + if (IsPluginAllowed('@simeonradivoev/gameflow-store')) { - const file = await pluginPackage.load(); - if (file.default && typeof file.default === 'function') + const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json'); + if (!await Bun.file(storePackageFilePath).exists()) { - const pluginInstance = new file.default(); - await PluginSchema.parseAsync(pluginInstance); - const description = await PluginDescriptionSchema.parseAsync(pluginPackage); - pluginManager.register(pluginInstance, description, 'builtin'); + console.log("Store is missing. Updating it."); + await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); + console.log("Store Updated"); } - })); + const storePackage = await Bun.file(storePackageFilePath).json(); + + if (storePackage?.dependencies) + { + const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).filter(p => !blacklist.has(p)).map(async p => + { + return getPlugin(p, pluginManager); + })); + + console.log("Checking for outdated packages"); + const outdated = await getUpdates(); + + const validPlugins = storePlugins.filter(p => !!p); + + if (outdated) + { + for await (const plugin of validPlugins) + { + const newVersion = outdated[plugin.name]; + if (newVersion) + { + console.log("Plugin", plugin.name, "has update", plugin.version, "=>", newVersion); + } + + if (plugin.autoUpdate) + { + console.log("Auto Updating Plugin", plugin.name); + let response = await runBunPackageCommand(["add", `${plugin.name}@${newVersion}`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } + } + } + + await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager))); + } + } else + { + console.log('Skipping Store Packages'); + } } \ No newline at end of file diff --git a/src/bun/api/plugins/services.ts b/src/bun/api/plugins/services.ts new file mode 100644 index 0000000..0ba40b3 --- /dev/null +++ b/src/bun/api/plugins/services.ts @@ -0,0 +1,64 @@ +import path from 'node:path'; +import os from 'node:os'; +import { getStoreRootFolder } from '../store/services/gamesService'; +import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk'; +import { run } from 'npm-check-updates'; +import { existsSync } from 'node:fs'; + +export function canDisable (description: PluginDescriptionType) +{ + if (description.name === '@simeonradivoev/gameflow-store') + { + return false; + } + return description.canDisable ?? true; +} + +export async function getUpdates () +{ + if (!existsSync(getStoreRootFolder())) return {}; + const updated = await run({ packageManager: 'bun', peer: true, cwd: getStoreRootFolder(), jsonUpgraded: true, reject: ['@simeonradivoev/gameflow-sdk'] }); + return updated as Record; +} + +export function canUninstall (description: PluginDescriptionType, source: string) +{ + if (description.name === '@simeonradivoev/gameflow-store') + { + return false; + } + return source !== 'builtin'; +} + +export async function runBunPackageCommand (commands: string[]) +{ + const tempCache = path.join(os.tmpdir(), "gameflow-bun-cache"); + const storeFolder = getStoreRootFolder(); + + let proc = Bun.spawn([process.execPath, ...commands, '--json'], { + cwd: storeFolder, + stdout: 'pipe', + stderr: 'pipe', + env: { + BUN_BE_BUN: "1", + BUN_INSTALL_CACHE_DIR: tempCache + } + }); + + let stdout = await new Response(proc.stdout).text(); + let stderr = await new Response(proc.stderr).text(); + if (stderr) + console.error(stderr); + await proc.exited; + return stdout; +} + +export async function hasPackage (id: string) +{ + const storeFolder = getStoreRootFolder(); + const packagePath = path.join(storeFolder, 'package.json'); + const packageFile = Bun.file(packagePath); + if (!await packageFile.exists()) return false; + const pkg = await packageFile.json(); + return !!pkg.dependencies?.[id]; +} \ No newline at end of file diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index fafa32a..7db68ba 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -1,3 +1,5 @@ + +import { LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared"; import { sql, relations } from "drizzle-orm"; import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core"; @@ -9,14 +11,18 @@ export const games = sqliteTable('games', { name: text("name"), ra_id: integer('ra_id').unique(), path_fs: text("path_fs"), + main_glob: text("main_glob"), last_played: integer("last_played", { mode: 'timestamp' }), created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(), - metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`), + metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type().notNull(), slug: text("slug").unique(), platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(), cover: blob("cover", { mode: 'buffer' }), cover_type: text('type'), - summary: text("summary") + summary: text("summary"), + version: text('version'), + version_source: text("version_source"), + version_system: text("version_system"), }); export const gamesRelations = relations(games, ({ many, one }) => ({ diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index afaa5fe..e0897ea 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -2,11 +2,12 @@ import * as appSchema from '@schema/app'; import * as emulatorSchema from "@schema/emulators"; import { eq, inArray } from 'drizzle-orm'; -import { db, emulatorsDb } from '../app'; +import { db, emulatorsDb, plugins } from '../app'; import { cores } from '../emulatorjs/emulatorjs'; import { SERVER_URL } from '@/shared/constants'; -import { findExecsByName } from '../games/services/launchGameService'; import { host } from '@/bun/utils/host'; +import { findEmulatorPluginIntegration } from '../store/services/emulatorsService'; +import { EmulatorSourceEntryType, FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared'; /** * Get emulators based on local games. Only the ones we probably need. @@ -53,7 +54,9 @@ export async function getRelevantEmulators () const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator); const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) => { - const execPaths = await findExecsByName(emulator); + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(emulator, execPaths); let platform: number | null | undefined = null; const validSystemSlug = system_slug.find(s => s.system); @@ -67,26 +70,39 @@ export async function getRelevantEmulators () systems.forEach(s => platformViability.set(s, true)); } + const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator }); + + if (storeEmulator) + { + storeEmulator.validSources = execPaths; + storeEmulator.integrations = integrations; + return { ...storeEmulator, isCritical: false }; + } + const em: FrontEndEmulator & { isCritical: boolean; } = { + source: 'local', name: emulator, logo: platform ? `/api/romm/platform/local/${platform}/cover` : '', systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ iconUrl: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })), gameCount: 0, isCritical: false, - validSources: execPaths + validSources: execPaths, + integrations }; return em; })); finalEmulators.push({ + source: 'local', name: 'EMULATORJS', validSources: [{ binPath: `${SERVER_URL(host)}`, type: 'embedded', exists: true }], logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, systems: [], gameCount: 0, isCritical: false, - description: "Embedded Emulator. Uses Retroarch Cores" + description: "Embedded Emulator. Uses Retroarch Cores", + integrations: [] }); return finalEmulators.map(e => diff --git a/src/bun/api/settings/settings.ts b/src/bun/api/settings/settings.ts index dda53b9..ebd5b91 100644 --- a/src/bun/api/settings/settings.ts +++ b/src/bun/api/settings/settings.ts @@ -1,12 +1,17 @@ import z from "zod"; -import { SettingsSchema } from "@shared/constants"; +import { SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import Elysia, { status } from "elysia"; -import { config, customEmulators, taskQueue } from "../app"; +import { config, customEmulators, plugins, taskQueue } from "../app"; import fs from 'node:fs/promises'; import { existsSync } from "node:fs"; import { InstallJob } from "../jobs/install-job"; import { move } from "fs-extra"; import { getRelevantEmulators } from "./services"; +import type { JSONSchema7 } from "json-schema"; +import ReloadPluginsJob from "../jobs/reload-plugins-job"; +import { pluginZodRegistry } from "../plugins/plugin-manager"; +import { TestDownloadJob } from "../jobs/test-download-job"; +import { randomUUIDv7 } from "bun"; export const settings = new Elysia({ prefix: '/api/settings' }) .get('/emulators/automatic', async () => @@ -77,18 +82,63 @@ export const settings = new Elysia({ prefix: '/api/settings' }) drive: z.string().optional() }) }) - .get("/:id", async ({ params: { id } }) => + .get("local/:id", async ({ params: { id } }) => { const value = config.get(id); return { value: value }; }, { params: z.object({ id: z.keyof(SettingsSchema) }), - }).post('/:id', + }).post('local/:id', async ({ params: { id }, body: { value }, }) => { config.set(id, value); }, { params: z.object({ id: z.keyof(SettingsSchema) }), body: z.object({ value: z.any() }), - }); + }) + .get('/definitions/:source', async ({ params: { source } }) => + { + return plugins.plugins[decodeURIComponent(source)].plugin.settingsSchema?.toJSONSchema() as JSONSchema7; + }) + .get('/actions/:source', async ({ params: { source } }) => + { + const plugin = plugins.plugins[decodeURIComponent(source)]?.plugin; + if (!plugin.eventsNames) return []; + return plugin.eventsNames; + }) + .post('/actions/:source/:id', async ({ params: { source, id } }) => + { + return await plugins.plugins[decodeURIComponent(source)]?.plugin.onEvent?.(decodeURIComponent(id)); + }) + .get('/:source/:id', async ({ params: { source, id } }) => + { + return { value: plugins.plugins[decodeURIComponent(source)].config?.get(decodeURIComponent(id)) }; + }) + .post('/test/download', async () => + { + taskQueue.enqueue(randomUUIDv7(), new TestDownloadJob()); + }) + .put('/:source/:id', async ({ params: { source, id }, body: { value } }) => + { + const plugin = plugins.plugins[decodeURIComponent(source)]; + if (!plugin.config) return status("Not Found", "Plugin has no config"); + const settingSchema = plugin.plugin.settingsSchema?.shape[decodeURIComponent(id)] as z.ZodObject; + if (!settingSchema) return status("Not Found", "Could not find setting"); + const meta = pluginZodRegistry.get(settingSchema); + + if (meta?.readOnly) + { + return; + } + + plugin.config?.set(id, value); + + if (meta?.requiresRestart) + { + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + } + }, + { + body: z.object({ value: z.any() }) + }); diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index 1340731..6dfcde1 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,36 +1,114 @@ -import { EmulatorPackageType } from "@/shared/constants"; -import { emulatorsDb, plugins } from "../../app"; -import * as emulatorSchema from '@schema/emulators'; -import { findExecs } from "../../games/services/launchGameService"; -import { eq } from "drizzle-orm"; +import { config, plugins } from "../../app"; +import { getOrCached, getOrCachedGithubRelease } from "../../cache"; +import path from "node:path"; +import { EmulatorSourceEntryType, EmulatorSupport, ScoopPackageSchema, EmulatorPackageType, EmulatorDownloadInfoType } from "@simeonradivoev/gameflow-sdk/shared"; -export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[]) +export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[] { - const execPaths: EmulatorSourceEntryType[] = []; - const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) }); - - if (esEmulator) + const hasSupport = validSources.concat(undefined).map(s => { - const allExecs = await findExecs(emulator.name, esEmulator); - execPaths.push(...allExecs); - } + const support = plugins.hooks.games.emulatorLaunchSupport.call({ emulator: name, source: s }); + if (support) + { + return { ...support, source: s }; + } - const em: FrontEndEmulator = { - name: emulator.name, - logo: emulator.logo, - systems, - gameCount, - validSources: execPaths, - integration: findEmulatorPluginIntegration(emulator.name) - }; + return undefined; + }).filter(s => !!s); - return em; + if (hasSupport.length <= 0) return []; + return hasSupport; } -export function findEmulatorPluginIntegration (name: string) +export function getEmulatorPath (emulator: string) { - const lowerCaseName = name.toLowerCase(); - const integration = Object.entries(plugins.plugins).find(p => p[1].description.keywords?.includes(lowerCaseName)); - if (!integration) return undefined; - return { name: integration[0], version: integration[1].description.version }; + return path.join(config.get('downloadPath'), "emulators", emulator); +} + +export async function getEmulatorDownload (emulator: EmulatorPackageType, source: string) +{ + if (!emulator.downloads) throw new Error("Emulator has no downloads"); + + const validDownloads = emulator.downloads[`${process.platform}:${process.arch}`]; + if (!validDownloads) throw new Error(`Now downloads in ${emulator.name} for platform ${process.platform}:${process.arch}`); + + const validDownload = validDownloads.find(d => d.type === source); + if (!validDownload) throw new Error(`Download type ${source} not found`); + + let downloadUrl: URL; + let versionInfo: EmulatorDownloadInfoType = { + id: "", + downloadDate: new Date(), + type: validDownload.type + }; + if (validDownload.type === 'github') + { + const latestRelease = await getOrCachedGithubRelease(validDownload.path); + const glob = new Bun.Glob(validDownload.pattern); + const validAsset = latestRelease.assets.find(a => glob.match(a.name)); + if (!validAsset) throw new Error("Could Not Find Valid Asset"); + downloadUrl = new URL(validAsset.browser_download_url); + versionInfo.version = latestRelease.tag_name; + versionInfo.url = latestRelease.url; + versionInfo.id = String(latestRelease.id); + versionInfo.description = latestRelease.body; + + } else if (validDownload.type === 'direct') + { + downloadUrl = new URL(validDownload.url); + versionInfo.id = validDownload.url; + versionInfo.url = validDownload.url; + } else if (validDownload.type === 'scoop') + { + const data = await getOrCachedScoopPackage(emulator.name, validDownload.url); + let scoopDownload: URL | undefined; + if (data) + { + if (data.url) + { + scoopDownload = new URL(data.url); + } else if (data.architecture) + { + if (process.arch === 'x64' && data.architecture["64bit"]) + { + scoopDownload = new URL(data.architecture["64bit"].url); + } else if (process.arch === "arm64" && data.architecture["arm64"]) + { + scoopDownload = new URL(data.architecture["arm64"].url); + } + } + } + + if (scoopDownload) + { + downloadUrl = scoopDownload; + versionInfo.version = data?.version; + versionInfo.url = data?.url; + versionInfo.description = data?.description; + } else + { + throw new Error("Could not find scoop download"); + } + } else + { + throw new Error("Download Type Unsupported"); + } + + return { url: downloadUrl, info: versionInfo }; +} + +export async function getOrCachedScoopPackage (id: string, url: string) +{ + const data = await getOrCached(`scoop-dl-${id}`, async () => + { + const res = await fetch(url); + if (res.ok) + { + return ScoopPackageSchema.parseAsync(await res.json()); + } + console.error(res.statusText); + return undefined; + }); + + return data; } \ No newline at end of file diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index ae0181f..b475b89 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -1,79 +1,9 @@ -import { EmulatorPackageSchema, EmulatorPackageType, GithubManifestSchema, StoreGameSchema } from "@/shared/constants"; -import { CACHE_KEYS, getOrCached } from "../../cache"; -import { and, eq } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { config, emulatorsDb } from '../../app'; import path from "node:path"; import fs from 'node:fs/promises'; import * as emulatorSchema from '@schema/emulators'; -import { shuffleInPlace } from "@/bun/utils"; - -export async function getShuffledStoreGames () -{ - return getOrCached('shuffled-store-games', async () => - { - const gamesManifest = await getStoreGameManifest(); - const allStoreGames = gamesManifest.filter(g => g.type === 'blob'); - shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60)); - return allStoreGames; - }, { expireMs: 1000 / 60 / 60 }); -} - -export async function getStoreGameManifest () -{ - return getOrCached(CACHE_KEYS.STORE_GAME_MANIFEST, async () => - { - const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json()).then(data => GithubManifestSchema.parseAsync(data)); - - return store.tree.filter((e: any) => - { - if (e.type === 'blob' && e.path !== "featured.json") - { - return true; - } - return false; - }); - }); -} - -export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) -{ - const offset = filter?.offset ?? 0; - const limit = Math.min(50, filter?.limit ?? 10); - - const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) => - { - return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, ""))))); - })); - - return games; -} - -export function extractStoreGameSourceId (id: string) -{ - const gameId = id.split('@'); - if (gameId.length !== 2) - throw new Error("Store ID should include platform and name with @ separator"); - return { system: gameId[0], id: gameId[1] }; -} - -export function getStoreGameFromId (id: string) -{ - const data = extractStoreGameSourceId(id); - return getStoreGame(data.system, data.id); -} - -export async function getStoreGame (system: string, id: string) -{ - return getStoreGameFromPath(`${system}/${encodeURIComponent(id)}.json`); -} - -export async function getStoreGameFromPath (path: string) -{ - const game = await getOrCached(CACHE_KEYS.STORE_GAME(path), () => fetch(`https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/${path}`) - .then(e => e.json()) - .then(g => StoreGameSchema.parseAsync(g))); - return game; -} +import { EmulatorSystem, EmulatorPackageType, EmulatorPackageSchema } from "@simeonradivoev/gameflow-sdk/shared"; export function getStoreRootFolder () { @@ -101,14 +31,7 @@ export async function getAllStoreEmulatorPackages () const emulators = await fs.readdir(emulatorsBucket); const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8'))); - const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e => - { - if (e.error) - { - console.error(e.error); - } - return e.data; - }).map(e => e.data!); + const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.parse(JSON.parse(d))); return emulatesParsed; } @@ -118,10 +41,10 @@ export async function buildStoreFrontendEmulatorSystems (emulator: EmulatorPacka const systems = await Promise.all(emulator.systems.map(async system => { const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ - where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system)) + where: or(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system)), and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, system))) }); - const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } }); + const esSystem = await emulatorsDb.query.systems.findFirst({ where: or(eq(emulatorSchema.emulators.name, system), eq(emulatorSchema.emulators.name, rommSystem?.system ?? '')), columns: { fullname: true } }); let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`; diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index d29746d..7706699 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -1,20 +1,28 @@ import Elysia, { status } from "elysia"; -import { config, db, taskQueue } from "../app"; +import { config, db, plugins, taskQueue } from "../app"; import path from "node:path"; import fs from 'node:fs/promises'; -import { StoreGameSchema } from "@/shared/constants"; -import { findExecsByName } from "../games/services/launchGameService"; import * as appSchema from '@schema/app'; import z from "zod"; -import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; +import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { getPlatformsApiPlatformsGet } from "@/clients/romm"; -import { CACHE_KEYS, getOrCached, getOrCachedGithubRelease } from "../cache"; -import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService"; +import { CACHE_KEYS, getOrCached } from "../cache"; +import { getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; -import { Glob } from "bun"; -import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration } from "./services/emulatorsService"; import { BiosDownloadJob } from "../jobs/bios-download-job"; +import { findEmulatorPluginIntegration, getEmulatorPath } from "./services/emulatorsService"; +import { EmulatorSourceEntryType, FrontEndEmulator, FrontEndGameTypeDetailed, PluginBunDetailsSchema, PluginEntrySchema, EmulatorDownloadInfoSchema } from "@simeonradivoev/gameflow-sdk/shared"; +import PQueue from "p-queue"; +import { hasPackage, runBunPackageCommand } from "../plugins/services"; +import { semver } from "bun"; + +const npmQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60, strict: true }); +const pluginsResponseSchema = z.object({ + objects: z.array(PluginEntrySchema), + total: z.number(), + time: z.coerce.date() +}); export const store = new Elysia({ prefix: '/api/store' }) .get('/emulators', async ({ query }) => @@ -24,27 +32,32 @@ export const store = new Elysia({ prefix: '/api/store' }) console.error(e); return undefined; }); - const emulatesParsed = await getAllStoreEmulatorPackages(); - let frontEndEmulators = await Promise.all(emulatesParsed - .filter(e => e.os.includes(process.platform as any)) - .map(async (emulator) => + + + let frontEndEmulators: FrontEndEmulator[] = []; + await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators, search: query.search }); + + await Promise.all(frontEndEmulators.map(async e => + { + const gameCounts = e.systems.map((s) => { - const systems = await buildStoreFrontendEmulatorSystems(emulator); - const gameCounts = await Promise.all(systems.map(async (s) => + const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id)); + if (romPlatform) { - const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id)); - if (romPlatform) - { - return romPlatform.rom_count; - } + return romPlatform.rom_count; + } - return 0; + return 0; - })); + }); - const gameCount = gameCounts.reduce((a, c) => a + c); - return convertStoreEmulatorToFrontend(emulator, gameCount, systems); - })); + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: e.name, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(e.name, execPaths); + + e.gameCount = gameCounts.reduce((a, c) => a + c); + e.integrations = integrations; + })); if (query.missing) { @@ -78,99 +91,132 @@ export const store = new Elysia({ prefix: '/api/store' }) limit: z.coerce.number().optional(), missing: z.stringbool().optional().describe("Show Only Non Installed emulators"), orderBy: z.enum(['name', 'recently_updated', 'importance']).optional(), - related: z.string().optional() + related: z.string().optional(), + search: z.string().optional() }) }) .get('/games/featured', async () => { - const response = await fetch('https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/featured.json'); - const games = await z.object({ featured: z.array(StoreGameSchema) }).parseAsync(await response.json()); - return Promise.all(games.featured.map(async g => + const games: FrontEndGameTypeDetailed[] = []; + await plugins.hooks.store.fetchFeaturedGames.promise({ games }); + + return Promise.all(games.map(async g => { - const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(`${g.system}@${g.title}`, 'store') }); + const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(g.id.id, g.id.source) }); if (localGame) return convertLocalToFrontendDetailed(localGame); - return convertStoreToFrontendDetailed(g.system, g.title, g); + return g; })); }) .get('/stats', async () => { - const emulatesParsed = await getAllStoreEmulatorPackages(); - const storeEmulatorCount = emulatesParsed.filter(e => e.os.includes(process.platform as any)).length; + let frontEndEmulators: FrontEndEmulator[] = []; + await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators }); + const storeEmulatorCount = frontEndEmulators.length; const gameCount = await db.$count(appSchema.games); return { storeEmulatorCount, gameCount }; }) + .get('/plugin', async ({ query: { plugin } }) => + { + const pluginsRes = await runBunPackageCommand(['info', plugin]); + const pluginData = await PluginBunDetailsSchema.parseAsync(JSON.parse(pluginsRes)); + const existingVersion = plugins.plugins[plugin]?.description.version; + + return { + ...pluginData, + installed: !!plugins.plugins[plugin] || await hasPackage(plugin), + update: existingVersion && semver.order(pluginData.version, existingVersion) > 0 ? { from: existingVersion } : undefined + }; + }, + { + query: z.object({ plugin: z.string() }) + }) + .get('/plugins', async ({ query: { search } }) => + { + //TODO: Find a better way to search keywords and a search term at the same time + const pluginsRes = await npmQueue.add(() => fetch(`https://registry.npmjs.com/-/v1/search?text=keywords:gameflow-plugin`)); + if (!pluginsRes.ok) return status(pluginsRes.status, pluginsRes.statusText); + const data: z.infer = await pluginsRes.json(); + if (search) + { + data.objects = data.objects.filter(o => + { + if (o.package.description && o.package.description.includes(search)) return true; + if (o.package.name.includes(search)) return true; + if (o.package.keywords.includes(search)) return true; + return false; + }); + data.total = data.objects.length; + } + await Promise.all(data.objects.map(async o => + { + const existingVersion = plugins.plugins[o.package.name]?.description.version; + o.installed = !!plugins.plugins[o.package.name] || await hasPackage(o.package.name); + o.update = existingVersion && semver.order(o.package.version, existingVersion) > 0 ? { from: existingVersion } : undefined; + })); + return data as any; + }, { + query: z.object({ search: z.string().optional() }), + response: pluginsResponseSchema + }) + .get('/media/*', async ({ params }) => + { + return Bun.file(path.join(getStoreFolder(), params["*"])); + }) .get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) => { return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name)); }, { params: z.object({ id: z.string(), name: z.string() }) }) + .get('/emulator/:id/update', async ({ params: { id } }) => + { + return plugins.hooks.store.fetchDownload.promise({ id }); + }, + { + response: z.union([z.intersection(EmulatorDownloadInfoSchema, z.object({ hasUpdate: z.boolean() })), z.undefined()]) + }) .get('/emulator/:id', async ({ params: { id } }) => { - const emulatorPackage = await getStoreEmulatorPackage(id); - if (!emulatorPackage) return status("Not Found"); - - const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage); - - const execPaths = await findExecsByName(emulatorPackage.name); - - const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id); - const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; - const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); - const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; - - const emulator: FrontEndEmulatorDetailed = { - name: emulatorPackage.name, - description: emulatorPackage.description, - systems, - validSources: execPaths, - screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), - gameCount: 0, - homepage: emulatorPackage.homepage, - downloads: await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => - { - if (d.type === 'github' && d.path) - { - const release = await getOrCachedGithubRelease(d.path); - const glob = new Glob(d.pattern); - const download: FrontEndEmulatorDetailedDownload = { - name: d.type, - type: release.assets.find(a => glob.match(a.name))?.content_type - }; - return download; - }; - - return { name: d.type, type: "Unknown" }; - }) ?? []), - logo: emulatorPackage.logo, - sources: execPaths, - biosRequirement: emulatorPackage.bios, - bios: biosFiles, - integration: findEmulatorPluginIntegration(emulatorPackage.name) - }; - + const emulator = await plugins.hooks.store.fetchEmulator.promise({ id }); + if (!emulator) return status("Not Found"); + const sources: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: emulator.name, sources }); + const integrations = findEmulatorPluginIntegration(emulator.name, sources); + emulator.validSources = sources; + emulator.integrations = integrations; return emulator; }, { params: z.object({ id: z.string() }) }) - .post('/install/emulator/:id/:source', async ({ params: { source, id } }) => + .post('/install/emulator/:id/:source', async ({ params: { source, id }, body }) => { if (taskQueue.hasActiveOfType(EmulatorDownloadJob)) { return status("Conflict", "Installation already running"); } - const job = new EmulatorDownloadJob(id, source); + const job = new EmulatorDownloadJob(id, source, body); return taskQueue.enqueue(EmulatorDownloadJob.id, job); + }, { + body: z.object({ isUpdate: z.boolean().optional() }).optional() }) .delete('/emulator/:id', async ({ params: { id } }) => { - const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); + const storeEmulatorFolder = getEmulatorPath(id); + const existingPackagePath = `${storeEmulatorFolder}.json`; + let hadDelete = false; + if (await fs.exists(existingPackagePath)) + { + await fs.rm(existingPackagePath); + hadDelete = true; + } + if (await fs.exists(storeEmulatorFolder)) { fs.rm(storeEmulatorFolder, { recursive: true }); - return status("OK"); + hadDelete = true; } - return status("Not Found"); + + return hadDelete ? status("OK") : status("Not Found"); }) .post('/download/bios/:id', async ({ params: { id } }) => { diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index aa9207e..2124144 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -2,16 +2,31 @@ import Elysia from "elysia"; import open from 'open'; import z from "zod"; import os from 'node:os'; -import { cachePath, config, events } from "./app"; -import { isSteamDeck, openExternal } from "../utils"; +import { 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"; -import { DirSchema, SystemInfoSchema } from "@/shared/constants"; +import { SystemInfoSchema, DirSchema, DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared'; import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; -import si, { battery } from 'systeminformation'; +import si from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; +import ReloadPluginsJob from "./jobs/reload-plugins-job"; +import { semver } from "bun"; +import { getOrCachedGithubRelease } from "./cache"; +import SelfUpdateJob from "./jobs/self-update-job"; + +async function checkUpdate (force?: boolean) +{ + 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, info: latest.body }; +} export const system = new Elysia({ prefix: '/api/system' }) .post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) => @@ -51,7 +66,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 }) => @@ -60,29 +76,80 @@ export const system = new Elysia({ prefix: '/api/system' }) set.headers["cache-control"] = 'no-cache'; set.headers['connection'] = 'keep-alive'; return new Response(buildNotificationsStream()); + }) + .get('/notifications/all', ({ }) => + { + }) .ws('/info/system', { response: z.discriminatedUnion('type', [ z.object({ type: z.literal('info'), data: SystemInfoSchema }), - z.object({ type: z.literal('focus') }) + z.object({ type: z.literal('focus') }), + z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }), + z.object({ type: z.literal('activeTask'), progress: z.number().nullable() }), + z.object({ type: z.literal('loaded') }), ]), async open (ws) { - const battery = await si.battery(); - const wifi = await si.wifiConnections(); - const bluetooth = await si.bluetoothDevices(); - ws.send({ - type: 'info', - data: { - battery: battery, - wifiConnections: wifi, - bluetoothDevices: bluetooth - } - }, true); + const existingLoading = taskQueue.findJob(ReloadPluginsJob.id, ReloadPluginsJob); + if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state }); + else ws.send({ type: 'loaded' }); + + ws.send({ type: 'activeTask', progress: taskQueue.getActiveJobs()[0]?.progress }); + + const startInfo = async () => + { + const battery = await si.battery(); + const wifi = await si.wifiConnections(); + const bluetooth = await si.bluetoothDevices(); + ws.send({ + type: 'info', + data: { + battery: battery, + wifiConnections: wifi, + bluetoothDevices: bluetooth + } + }, true); + }; + startInfo(); const handleFocus = () => ws.send({ type: 'focus' }); events.on('focus', handleFocus); - (ws.data as any).dispose = [() => events.removeListener('focus', handleFocus)]; + const dispose: (() => void)[] = []; + + dispose.push(taskQueue.on('progress', e => + { + ws.send({ type: 'activeTask', progress: e.progress }); + + 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 => + { + ws.send({ type: 'activeTask', 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 => + { + ws.send({ type: 'activeTask', progress: null }); + if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return; + ws.send({ type: "loaded" }); + })); + + (ws.data as any).dispose = [...dispose, () => + { + events.removeListener('focus', handleFocus); + }]; (ws.data as any).observer = setInterval(async () => { const battery = await si.battery(); @@ -180,6 +247,10 @@ export const system = new Elysia({ prefix: '/api/system' }) { currentPath = path.resolve(process.cwd(), currentPath); } + const currentPathExists = await fs.exists(currentPath); + if (!currentPathExists) currentPath = dirname(process.cwd()); + const currentPathStat = await fs.stat(currentPath); + if (!currentPathStat.isDirectory()) currentPath = dirname(currentPath); const paths = await fs.readdir(currentPath, { withFileTypes: true }); return { name: path.basename(currentPath), @@ -209,4 +280,16 @@ export const system = new Elysia({ prefix: '/api/system' }) await openExternal(url); }, { body: z.object({ url: z.string() }) + }) + .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/browser.ts b/src/bun/browser.ts index 79c01b8..8d71427 100644 --- a/src/bun/browser.ts +++ b/src/bun/browser.ts @@ -3,22 +3,45 @@ import { BrowserParams, BuildParams } from './utils/browser-params'; import os from 'node:os'; import { EventEmitter } from 'node:stream'; 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 runNW(events, params); + return; + } + + if (params.forceBrowser) { await runBrowser(events, params); - } else - { - try - { - await runWebview(events, params); - } catch (error) - { - 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) @@ -44,8 +67,61 @@ function focusWindow (id: Pointer) } } +async function runNW (events: EventEmitter, params: BrowserParams) +{ + let nwPath = process.platform === 'win32' ? './bin/nw/nw.exe' : './bin/nw/nw'; + if (process.env.FLATPAK_BUILD) + { + 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 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; +} + async function runWebview (events: EventEmitter, params: BrowserParams) { + if (process.platform !== 'win32') + { + throw new Error("Webview only supported on windows"); + } const webviewPath = process.env.IS_BINARY ? `./webview/${os.platform()}` : new URL(`./webview/${os.platform()}`, import.meta.url).href; console.log("Launching Webview Worker at: ", webviewPath); const config: Record = {}; @@ -106,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..7ad5803 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -5,14 +5,27 @@ 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)); + if (process.env.HEADLESS) { const rl = createInterface({ input: process.stdin }); @@ -22,7 +35,7 @@ if (process.env.HEADLESS) if (line.trim() === "shutdown") { console.log("Graceful Shutdown"); - await cleanup(); + await cleanup(0); } }); @@ -30,23 +43,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/helpers.d.ts b/src/bun/types/helpers.d.ts new file mode 100644 index 0000000..afd8ea5 --- /dev/null +++ b/src/bun/types/helpers.d.ts @@ -0,0 +1,19 @@ +declare module '*.bat' { + const content: string; + export default content; +} + +declare module '*.sh' { + const content: string; + export default content; +} + +declare module '*.ini' { + const content: string; + export default content; +} + +declare module '*.bin' { + const content: string; + export default content; +} \ No newline at end of file diff --git a/src/bun/types/types.d.ts b/src/bun/types/types.d.ts deleted file mode 100644 index 1bc7a22..0000000 --- a/src/bun/types/types.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -declare interface ObjectConstructor -{ - /** - * Groups members of an iterable according to the return value of the passed callback. - * @param items An iterable. - * @param keySelector A callback which will be invoked for each item in items. - */ - groupBy ( - items: Iterable, - keySelector: (item: T, index: number) => K, - ): Partial>; -} - -declare interface MapConstructor -{ - /** - * Groups members of an iterable according to the return value of the passed callback. - * @param items An iterable. - * @param keySelector A callback which will be invoked for each item in items. - */ - groupBy ( - items: Iterable, - keySelector: (item: T, index: number) => K, - ): Map; -} - -declare interface AppEventMap -{ - exitapp: []; - notification: [FrontendNotification]; - focus: []; -} \ No newline at end of file diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts deleted file mode 100644 index fba1435..0000000 --- a/src/bun/types/typesc.schema.ts +++ /dev/null @@ -1,35 +0,0 @@ -import z from "zod"; -import { GameflowHooks } from "../api/hooks/app"; -import { ChildProcess } from "node:child_process"; - -export const PluginContextSchema = z.object({ - hooks: z.instanceof(GameflowHooks) -}); - -export const PluginDescriptionSchema = z.object({ - name: z.string(), - displayName: z.string(), - version: z.string(), - description: z.string(), - icon: z.url().optional(), - keywords: z.array(z.string()).optional() -}); - -export const PluginSchema = z.object({ - setup: z.function().output(z.promise(z.void())).optional(), - load: z.function().input([PluginContextSchema]).output(z.void()), - onBeforeReload: z.function().input([PluginContextSchema]).output(z.void()).optional(), - cleanup: z.function().output(z.promise(z.void())).optional() -}); - -export type PluginType = z.infer; -export type PluginContextType = z.infer; -export type PluginDescriptionType = z.infer; - -export const ActiveGameSchema = z.object({ - process: z.any().optional(), - gameId: z.number(), - name: z.string(), - command: z.object({ command: z.string(), startDir: z.string().optional() }) -}); -export type ActiveGameType = z.infer; \ No newline at end of file diff --git a/src/bun/utils.ts b/src/bun/utils.ts index c21a78b..6fbc630 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -1,8 +1,11 @@ import { $, sleep } from 'bun'; import path from 'node:path'; -import { SettingsType } from '@/shared/constants'; +import { SettingsType, KeysWithValueAssignableTo } from '@simeonradivoev/gameflow-sdk/shared'; import { config } from './api/app'; import fs from 'node:fs/promises'; +import packageDef from '~/package.json'; + +const archiveRegex = /.(zip|rar|7zip|7z|tar|tar.gz)$/i; export function checkRunning (pid: number) { @@ -172,4 +175,29 @@ export async function moveAllFiles (srcDir: string, destDir: string) }); } } +} + +export function getAppVersion () +{ + return process.env.VERSION_OVERRIDE ?? packageDef.version; +} + +export function isArchive (path: string) +{ + return archiveRegex.test(path); +} + +export function IsPluginAllowed (id: string) +{ + if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(id)) + { + return false; + } + + if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(id)) + { + return false; + } + + return true; } \ 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 2a014b9..920e7c8 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -1,15 +1,11 @@ -import { ensureDir, move } from "fs-extra"; +import { ensureDir } from "fs-extra"; import path from 'node:path'; import fs from 'node:fs/promises'; import { createWriteStream } from "node:fs"; import { config, jar } from "../api/app"; import { moveAllFiles } from "../utils"; - -export interface ProgressStats -{ - progress: number; -} +import { DownloadFileEntry, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared"; interface TmpDownloadMetadata { @@ -27,15 +23,23 @@ export class Downloader onProgress?: (stats: ProgressStats) => void; signal?: AbortSignal; activeFile?: DownloadFileEntry; - downloadPath: string; + downloadPath: string | undefined; id: string; tmpPath: string; tmpPathMeta: string; + downloadSpeed: number = 0; + /** + * + * @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; @@ -155,10 +159,7 @@ export class Downloader }); const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0; - if (totalSize <= 0) - bytesReceived = 0; - else - bytesReceived += start; + bytesReceived += start; const reader = res.body!.getReader(); @@ -173,10 +174,11 @@ export class Downloader if (totalBytes > 0 && this.onProgress) { const percent = (bytesReceived / totalBytes) * 100; - - if (Date.now() - lastUpdate > 100) + const timeDelta = Date.now() - lastUpdate; + if (timeDelta > 100) { - this.onProgress({ progress: percent }); + this.downloadSpeed = this.downloadSpeed * 0.8 + Math.round(value.length / (timeDelta / 1000)) * 0.2; + this.onProgress({ progress: percent, downloaded: bytesReceived, total: totalBytes, speed: this.downloadSpeed }); lastUpdate = Date.now(); } } @@ -186,7 +188,7 @@ export class Downloader if (this.signal.reason === 'cancel') { console.log("Canceling Download and cleaning up files"); - await fs.rm(this.tmpPath, { recursive: true }); + await fs.rm(this.tmpPath, { recursive: true, maxRetries: 3, retryDelay: 3 }); await fs.rm(this.tmpPathMeta); return; } @@ -210,11 +212,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/get-browser.ts b/src/bun/utils/get-browser.ts index ffcf07c..7ba788e 100644 --- a/src/bun/utils/get-browser.ts +++ b/src/bun/utils/get-browser.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "bun"; +import { Glob, spawnSync } from "bun"; import { platform } from "node:os"; import { RunBrowserType } from "./browser-spawner"; import path from 'node:path'; @@ -35,25 +35,18 @@ interface BrowserResult source: GetBrowserSource; } -const PLATFORM_MAP: Record = { - linux: "linux", - win32: "windows", - darwin: 'macos' -}; - -const ARCH_MAP: Record> = { - linux: { x64: "x86_64", arm64: "arm64" }, - darwin: { x64: "x86_64", arm64: "arm64" }, - win32: { x64: "x64", arm64: "arm64" }, -}; - /** The expected binary path per platform after extraction */ -function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): string +async function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): Promise { - const subFolder = `ungoogled-chromium_${version}_${PLATFORM_MAP[platform]}_${ARCH_MAP[platform][arch]}`; - if (platform === "linux") return path.join(outDir, subFolder, "chrome"); - if (platform === "darwin") return path.join(outDir, "Chromium.app"); - return path.join(outDir, subFolder, "chrome.exe"); + let glob: Glob | undefined = undefined; + if (platform === "linux") glob = new Glob(`**/chrome`); + else if (platform === "darwin") glob = new Glob(`**/Chromium.app`); + else glob = new Glob(`**/chrome.exe`); + + for await (const bin of glob.scan({ cwd: outDir })) + { + return path.join(outDir, bin); + } } /** @@ -101,10 +94,14 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promisenul +echo Cleaning up... +rmdir /S /Q "{{{extractDir}}}" +del "{{{tempFile}}}" +echo Done! Restarting... +start "" /D "{{{installDir}}}" "{{{exePath}}}" +del "%~f0" \ No newline at end of file diff --git a/src/bun/webview/linux.ts b/src/bun/webview/linux.ts index c53ccca..6683249 100644 --- a/src/bun/webview/linux.ts +++ b/src/bun/webview/linux.ts @@ -1,36 +1,9 @@ import { Size, SizeHint, Webview } from 'webview-bun'; import webviewWorkerBase from "./base"; -if (process.env.FLATPAK_BUILD === "true") -{ - let webview: Bun.Subprocess | undefined = undefined; - let hostUrl: string | undefined = undefined; - webviewWorkerBase({ - navigate: (url) => - { - hostUrl = url; - - }, destroy: () => webview?.kill(), run: () => - { - webview = Bun.spawn(["webview", hostUrl ?? ''], { - stdout: "inherit", - stderr: "inherit", - env: { - ...process.env, - }, - onExit () - { - postMessage({ data: 'destroyed' }); - } - }); - } - }); -} else -{ - console.log("Launching Webview"); - let size: Size | undefined = undefined; - if (process.env.WINDOW_WIDTH && process.env.WINDOW_HEIGHT) - size = { width: Number(process.env.WINDOW_WIDTH), height: Number(process.env.WINDOW_HEIGHT), hint: SizeHint.NONE }; - const webview = new Webview(process.env.NODE_ENV === 'development', size); - webviewWorkerBase(webview); -} \ No newline at end of file +console.log("Launching Webview"); +let size: Size | undefined = undefined; +if (process.env.WINDOW_WIDTH && process.env.WINDOW_HEIGHT) + size = { width: Number(process.env.WINDOW_WIDTH), height: Number(process.env.WINDOW_HEIGHT), hint: SizeHint.NONE }; +const webview = new Webview(process.env.NODE_ENV === 'development', size); +webviewWorkerBase(webview); \ No newline at end of file diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx new file mode 100644 index 0000000..605f15d --- /dev/null +++ b/src/mainview/App.tsx @@ -0,0 +1,47 @@ +import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; +import { Router } from "."; +import { useEffect } from "react"; +import audioCallbacks from "./scripts/feedbackCallbacks"; +import { client as rommClient } from "../clients/romm/client.gen"; +import { RPC_URL } from "@/shared/constants"; + +export const focusQueue: string[] = []; + +export default function App (data: { children: any; }) +{ + useEffect(() => + { + const focusMap = new Map(); + rommClient.setConfig({ + baseUrl: `${RPC_URL(__HOST__)}/api/romm`, + credentials: "include", + mode: "cors", + }); + + const unsub = Router.history.subscribe((op) => + { + if (op.action.type === 'PUSH') + { + focusMap.set(op.location.state.__TSR_index - 1, getCurrentFocusKey()); + } else if (op.action.type === 'BACK') + { + if (focusMap.has(op.location.state.__TSR_index)) + { + focusQueue.pop(); + focusQueue.push(focusMap.get(op.location.state.__TSR_index)!); + focusMap.delete(op.location.state.__TSR_index); + } + } + }); + + const audio = audioCallbacks(); + + return () => + { + unsub(); + audio.cleanup(); + }; + }, []); + + return <>{data.children}; +} \ No newline at end of file diff --git a/src/mainview/assets/intro.ogg b/src/mainview/assets/intro.ogg new file mode 100644 index 0000000..e1505f9 --- /dev/null +++ b/src/mainview/assets/intro.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:231ac69f71f4a0a770ae4bbfd42db9ea136dad6813ddae68a211c74a16e21778 +size 74296 diff --git a/src/mainview/assets/sounds.json b/src/mainview/assets/sounds.json new file mode 100644 index 0000000..309705d --- /dev/null +++ b/src/mainview/assets/sounds.json @@ -0,0 +1,88 @@ +{ + "sprite": { + "Classic UI SFX - Chords #2": [ + 0, + 4005.215419501134 + ], + "Classic UI SFX - Short - Low #2": [ + 6000, + 2583.333333333334 + ], + "Classic UI SFX - Short - Low #5": [ + 10000, + 3161.473922902495 + ], + "Classic UI SFX - Short - High #9": [ + 15000, + 2250 + ], + "UI_TwoNote Up_Set 11_01": [ + 21000, + 129.16099773242706 + ], + "UI_TwoNote Up_Set 11_02": [ + 23000, + 250 + ], + "Classic UI SFX - Short - High #3": [ + 25000, + 2864.6031746031754 + ], + "Classic UI SFX - Short - High #19": [ + 29000, + 3052.0861678004535 + ], + "Classic UI SFX - Short - High #22": [ + 34000, + 2489.5918367346967 + ], + "Classic UI SFX - Short - High #25": [ + 38000, + 2005.215419501134 + ], + "Classic UI SFX - Chords #16": [ + 42000, + 4005.215419501134 + ], + "Classic UI SFX - Short - High #8": [ + 48000, + 2916.6666666666642 + ], + "UI_Single_Set 16_03": [ + 52000, + 309.5918367346968 + ], + "UI_Single_Set 16_01": [ + 54000, + 309.5918367346968 + ], + "UI_Single_Set 5_02": [ + 56000, + 875.0113378684787 + ], + "UI_Single_Set 5_04": [ + 58000, + 531.247165532882 + ], + "UI_Single_Set 5_03": [ + 60000, + 531.247165532882 + ], + "UI_Single_Set 5_01": [ + 62000, + 875.0113378684787 + ], + "UI_Single_Set 11_02": [ + 64000, + 93.74149659863917 + ], + "Classic UI SFX - Short - Low #6": [ + 66000, + 2333.3333333333285 + ], + "UI SFX_InGameMenu_Open": [ + 70000, + 2614.104308390026 + ] + } +} \ No newline at end of file diff --git a/src/mainview/assets/sounds.ogg b/src/mainview/assets/sounds.ogg new file mode 100644 index 0000000..835432f --- /dev/null +++ b/src/mainview/assets/sounds.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38721ebc90eb07ef7e00c0a1a64bd363e61dbbd08aa32b12a33da5ead0597948 +size 408079 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 ? (); + const [appContext, setAppContext] = useState({} as AppInfoContext); + const [loadingInfo, setLoadingInfo] = useState(undefined); + const [loading, setLoading] = useState(true); + const loadingProgressBarRef = useRef(null); + useEffect(() => { const sub = systemApi.api.system.info.system.subscribe(); @@ -20,14 +26,42 @@ export default function AppCommunication (data: { children: any; }) case "focus": window.focus(); break; + case "activeTask": + setAppContext(c => ({ ...c, activeTaskProgress: data.progress })); + break; + case "loading": + setLoadingInfo(data.state); + if (loadingProgressBarRef.current) + loadingProgressBarRef.current.value = data.progress; + setLoading(true); + break; + case "loaded": + setLoading(false); + break; } - }); document.documentElement.dataset.loaded = "true"; + return () => + { + sub.close(); + }; }, []); return - {data.children} + + {loading ? + +
+
+ + {loadingInfo} +
+ +
+
+ : data.children} + +
; } \ No newline at end of file diff --git a/src/mainview/components/AutoFocus.tsx b/src/mainview/components/AutoFocus.tsx index 2744e8e..7bed71b 100644 --- a/src/mainview/components/AutoFocus.tsx +++ b/src/mainview/components/AutoFocus.tsx @@ -12,7 +12,11 @@ export function AutoFocus (data: { { 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/CardElement.tsx b/src/mainview/components/CardElement.tsx index 7f5f2dc..6e27664 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -1,8 +1,10 @@ -import { FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import useActiveControl from "../scripts/gamepads"; +import { oneShot } from "../scripts/audio/audio"; +import ImageWithFallbacks from "./ImageWithFallbacks"; export function GameCardSkeleton () { @@ -17,20 +19,17 @@ export function GameCardSkeleton () ); } -export type GameCardFocusHandler = (id: string, node: HTMLElement, details: FocusDetails) => void; - -export interface GameCardParams +export interface GameCardParams extends FocusParams { title: string; - subtitle: string | JSX.Element; - preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element); + subtitle?: string | JSX.Element; + preview?: string | JSX.Element | URL[] | ((p: { focused: boolean; }) => JSX.Element); srcset?: string; focusKey: string; index: number; id: string; badges?: JSX.Element[]; className?: string; - onFocus?: GameCardFocusHandler; onBlur?: (id: string) => void; clickFocuses?: boolean; previewClassName?: string; @@ -38,14 +37,34 @@ export interface GameCardParams export default function CardElement (data: GameCardParams & InteractParams) { - const { ref, focused, focusSelf } = useFocusable({ + const handleAction = (event?: Event) => + { + data.onAction?.({ event, focusKey }); + oneShot('click'); + }; + const { ref, focused, focusSelf, focusKey } = useFocusable({ focusKey: data.focusKey, - onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details), - onEnterPress: () => data.onAction?.(), + onFocus: (l, p, details) => data.onFocus?.(focusKey, ref.current as any, details), + onEnterPress: handleAction, onBlur: () => data.onBlur?.(data.id), }); const { isPointer } = useActiveControl(); + let preview: any = undefined; + if (typeof data.preview === "string") + { + preview = ; + } else if (Array.isArray(data.preview)) + { + preview = ; + } else if (typeof data.preview === 'function') + { + preview = data.preview({ focused }); + } else + { + preview = data.preview; + } + return (
  • data.onAction?.(e.nativeEvent)} - onClick={() => + onClick={(e) => { focusSelf(); - data.onAction?.(); + handleAction(e.nativeEvent); }} className={twMerge( "relative game-card light:bg-base-100 dark:bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-lg focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none", @@ -74,11 +92,7 @@ export default function CardElement (data: GameCardParams & InteractParams) focused ? "md:mt-2 md:mx-2" : "md:mt-2 md:mx-2", classNames({ "h-full": typeof data.preview === "string" }) )}> - {typeof data.preview === "string" ? ( - - ) : ( - typeof data.preview === 'function' ? data.preview({ focused }) : data.preview - )} + {preview}
  • diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 0518d2c..8511374 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -3,11 +3,11 @@ import FocusContext, useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; -import { GameMeta } from "../../shared/constants"; -import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement"; +import CardElement, { GameCardParams } from "./CardElement"; import { JSX } from "react"; import { twMerge } from "tailwind-merge"; -import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; +import { oneShot } from "../scripts/audio/audio"; export interface GameMetaExtra extends GameMeta { @@ -16,20 +16,43 @@ export interface GameMetaExtra extends GameMeta focusKey: string; } -function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams) +function LocalCardElement (data: { game: GameMetaExtra, i: number; onQuickAction?: (ctx: InteractParamsArgs) => void; } & FocusParams & InteractParams) { let preview: GameCardParams['preview'] = data.game.preview; - if (!preview && data.game.previewUrl) + if (!preview && data.game.previewUrls) { - preview = data.game.previewUrl; + preview = data.game.previewUrls; } - const handleAction = () => + const handleAction = (ctx: InteractParamsArgs) => { data.game.onSelect?.(); - data.onAction?.(); + data.onAction?.({ event, focusKey: data.game.focusKey }); + oneShot('click'); }; - useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]); + + const handleAltAction = (ctx: InteractParamsArgs) => + { + data.game.onQuickAction?.(); + data.onQuickAction?.({ event, focusKey: data.game.focusKey }); + oneShot('click'); + }; + + useShortcuts(data.game.focusKey, () => + { + const options: Shortcut[] = [{ + label: "Details", + button: GamePadButtonCode.A, + action: event => handleAction({ event, focusKey: data.game.focusKey }) + }]; + + if (data.onQuickAction || data.game.onQuickAction) + { + options.push({ label: "Play", button: GamePadButtonCode.X, action: event => handleAltAction({ event, focusKey: data.game.focusKey }) }); + } + + return options; + }, [data.onQuickAction, data.game.onQuickAction, data.game.focusKey]); return ( + onFocus={(focusKey, node, details) => { - data.game.onFocus?.(details); - data.onFocus?.(id, node, details); + data.game.onFocus?.(focusKey, node, details); + data.onFocus?.(focusKey, node, { ...details, id: data.game.id }); }} onAction={handleAction} preview={preview} @@ -58,16 +81,16 @@ export function CardList (data: { games: GameMetaExtra[]; grid?: boolean; onSelectGame?: (id: string) => void; - onGameFocus?: GameCardFocusHandler; + focus?: string; className?: string; - finalElement?: JSX.Element; + finalElement?: JSX.Element | JSX.Element[]; saveChildFocus?: 'session' | 'local'; -}) +} & FocusParams) { const { ref, focusKey } = useFocusable({ focusKey: data.id, - forceFocus: true, - autoRestoreFocus: true + focusable: data.games.length > 0 || (!!data.finalElement && (Array.isArray(data.finalElement) ? data.finalElement.length > 0 : !!data.finalElement)), + preferredChildFocusKey: data.focus }); return ( @@ -89,7 +112,12 @@ export function CardList (data: { > {data.games.map((g, i) => data.onSelectGame?.(g.id)} i={i} />)} + key={g.id} + onFocus={data.onFocus} + game={g} + onAction={() => data.onSelectGame?.(g.id)} + i={i} + />)} {data.finalElement} diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 8bb02a4..ac30c57 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -1,25 +1,24 @@ import { RPC_URL } from "@/shared/constants"; import { useSuspenseQuery } from "@tanstack/react-query"; import { CardList, GameMetaExtra } from "./CardList"; -import { GameCardFocusHandler } from "./CardElement"; import { getCollectionsQuery } from "@queries/romm"; -import { Router } from ".."; +import { useRouter } from "@tanstack/react-router"; export default function CollectionList (data: { id: string, setBackground: (url: string) => void; className?: string; - onFocus?: GameCardFocusHandler; onSelect?: (id: string) => void; saveChildFocus?: 'session' | 'local'; -}) +} & FocusParams) { + const router = useRouter(); const { data: collections } = useSuspenseQuery(getCollectionsQuery); const handleDefaultSelect = (gameId: string) => { const [source, id] = gameId.split('@'); - Router.navigate({ + router.navigate({ to: `/collection/$source/$id`, params: { source, id }, search: { countHint: collections.find(c => c.id.id === id && c.id.source === source)?.game_count } @@ -37,8 +36,7 @@ export default function CollectionList (data: { id: `${g.id.source}@${g.id.id}`, title: g.name, focusKey: `collection-${g.id}`, - subtitle: "", - previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, + previewUrls: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, badges: [ {g.game_count} @@ -46,7 +44,7 @@ export default function CollectionList (data: { ], } satisfies GameMetaExtra))} onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect} - onGameFocus={(id, node, details) => + onFocus={(id, node, details) => { data.setBackground( `https://picsum.photos/id/${10 + (id ?? 0)}/100/100.webp?blur=10`, diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 35aae6f..72c391a 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -1,53 +1,53 @@ -import { AnimatedBackground } from './AnimatedBackground'; -import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { HeaderUI, StickyHeaderUI } from './Header'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { HeaderButton, StickyHeaderUI } from './Header'; import { GameList } from './GameList'; -import { Search, Settings2 } from 'lucide-react'; -import { JSX, Suspense, useEffect } from 'react'; -import Shortcuts from './Shortcuts'; +import { JSX, Suspense } from 'react'; +import { FloatingShortcuts } from './Shortcuts'; import { AutoFocus } from './AutoFocus'; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; -import { GameListFilterType } from '@/shared/constants'; -import { GameCardFocusHandler } from './CardElement'; -import { HandleGoBack, useStickyDataAttr } from '../scripts/utils'; +import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; +import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { gameQuery } from '../scripts/queries/romm'; +import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm'; +import { useRouter } from '@tanstack/react-router'; +import SelectMenu from './SelectMenu'; +import SideFilters from './SideFilters'; export interface CollectionsDetailParams { id?: string; setBackground?: (url: string) => void; filters?: GameListFilterType; - builder?: () => Promise<{ filter?: GameListFilterType, title?: JSX.Element; }>; + setLocalFilter: (filter: GameListFilterType) => void, + localFilter: GameListFilterType, headerTitle?: JSX.Element; + headerChildren?: any; title?: JSX.Element; footer?: JSX.Element; focus?: string; - countHit?: number; + countHint?: number; + headerButtons?: HeaderButton[]; + headerButtonElements?: JSX.Element | JSX.Element[]; } export function CollectionsDetail (data: CollectionsDetailParams) { - const builtData = useQuery({ - queryKey: ['filter', data.id], queryFn: async () => - { - return data.builder?.() ?? { filter: data.filters, title: data.title }; - } - }); + const router = useRouter(); const queryClient = useQueryClient(); - const focusKey = `game-list-${data.id}-${data?.filters ? Object.values(data?.filters).map(f => String(f)).join(",") : ''}`; + const finalFilter = { ...data.localFilter, ...data.filters }; + const focusKey = `game-list-${data.id}`; const { ref, focusSelf } = useFocusable({ focusKey, preferredChildFocusKey: `${focusKey}-list` }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { shortcuts } = useShortcutContext(); + const { data: filterValues } = useQuery(gameFiltersQuery({ source: data.filters?.source })); - const handleScroll: GameCardFocusHandler = (cardId, node, details) => + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); + + const handleScroll: FocusParams['onFocus'] = (cardId, node, details) => { - const [source, id] = cardId.split('@'); queryClient.prefetchQuery(gameQuery(source, id)); @@ -60,31 +60,39 @@ export function CollectionsDetail (data: CollectionsDetailParams) return (
    - }, { id: "filter", icon: }]} ref={ref} /> -
    -
    - {builtData.data?.filter && data.title} - {(builtData.data?.filter || (!data.filters && !data.builder)) && }> + + {data.headerChildren} + +
    +
    +
    +
    +
    + {!!finalFilter && data.title} + {}> - + } -
    -
    -
    {data.footer}
    - +
    +
    + +
    + ); } \ No newline at end of file diff --git a/src/mainview/components/Constants.tsx b/src/mainview/components/Constants.tsx new file mode 100644 index 0000000..5286dfb --- /dev/null +++ b/src/mainview/components/Constants.tsx @@ -0,0 +1,21 @@ +import { CloudSync, Gamepad2, HardDrive, MonitorPlay, Store, Terminal } from "lucide-react"; + +export const sourceIconMap: Record = { + store: , + local: , + romm: +}; + +export const pluginCategoryIcons: Record = { + saves: , + sources: , + launchers: , + emulators: +}; + +export const pluginCategoryPriorities: Record = { + saves: 100, + sources: 90, + launchers: 80, + emulators: 60 +}; \ No newline at end of file diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index bf9b757..54babea 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -6,6 +6,8 @@ import { X } from "lucide-react"; import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; import { ContextDialogContext } from "../scripts/contexts"; import { FOCUS_KEYS } from "../scripts/types"; +import { oneShot } from "../scripts/audio/audio"; +import { oneShotRumble } from "../scripts/gamepads"; export function ContextList (data: { options?: DialogEntry[]; @@ -16,8 +18,8 @@ export function ContextList (data: { { const context = useContext(ContextDialogContext); return
      - {data.options?.map(o => )} -
      + {data.options?.map((o, i) => )} + {data.showCloseButton !== false &&
      } {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />}
    ; } @@ -33,11 +35,12 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class const handleAction = () => { if (data.disabled === true) return; - data.action?.({ close: context.close, focus: focusSelf }); + data.action?.({ close: context.close, focus: focusSelf, selected: data.selected }); + oneShot('click'); }; const { ref, focusSelf, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id), - onEnterPress: data.shortcuts ? undefined : handleAction, + onEnterPress: handleAction, onFocus: handleFocus, trackChildren: typeof data.content !== 'string' }); @@ -57,10 +60,11 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class onClick={handleAction} data-selected={data.selected} aria-disabled={data.disabled} + data-sound-category={"menu"} className={ twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}> -
    @@ -78,13 +82,13 @@ export interface DialogEntry icon?: string | JSX.Element; type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error'; selected?: boolean; - action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void; + action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; selected?: boolean; }) => void; shortcuts?: Shortcut[]; } -export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; }) +export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; defaultOpen?: boolean; backdropClassName?: string; }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(data.defaultOpen ?? false); const [sourceFocusKey, setSourceFocusKey] = useState(undefined); const handleClose = (value: boolean, newSourceFocusKey?: string) => { @@ -98,23 +102,29 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla { setOpen(false); data.onClose?.(); + oneShot('closeContext'); if (newSourceFocusKey) { - setFocus(newSourceFocusKey); + setFocus(newSourceFocusKey, { instant: true }); } else if (sourceFocusKey) { - setFocus(sourceFocusKey); + setFocus(sourceFocusKey, { instant: true }); } } }; - const dialog = + const dialog = {data.content} ; return { dialog, open, - setOpen: handleClose + setOpen: handleClose, + setToggle: (focNewSourceFocusKey?: string | undefined) => + { + if (open) handleClose(false, focNewSourceFocusKey); + else handleClose(true, focNewSourceFocusKey); + } }; } @@ -124,12 +134,13 @@ export function ContextDialog (data: { open: boolean, close: (open: boolean) => void; className?: string; + backdropClassName?: string; preferredChildFocusKey?: string; }) { const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, - focusKey: `${data.id}-context-dialog`, + focusKey: FOCUS_KEYS.CONTEXT_DIALOG(data.id), isFocusBoundary: true, saveLastFocusedChild: !data.preferredChildFocusKey, preferredChildFocusKey: data.preferredChildFocusKey @@ -142,7 +153,9 @@ export function ContextDialog (data: { { if (data.open) { - focusSelf(); + focusSelf({ instant: true }); + oneShot('openContext'); + oneShotRumble('openContext', { all: true }); } }, [data.open]); @@ -153,15 +166,15 @@ export function ContextDialog (data: { }] : [], [data.open]); return
    Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); + const router = useRouter(); + const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]); - const { shortcuts } = useShortcutContext(); - useEffect(() => { focusSelf(); }, []); + useEffect(() => { focusSelf({ instant: true }); }, []); return
    @@ -30,7 +29,7 @@ export default function Error (data: ErrorComponentProps)
    -
    +
    ; } \ No newline at end of file diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index 689900d..aefa842 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -1,11 +1,10 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry } from "./ContextDialog"; -import { systemApi } from "../scripts/clientApi"; -import { useContext, useRef, useState } from "react"; +import { FocusEventHandler, useContext, useRef, useState } from "react"; import path from "pathe"; -import { Check, File, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; +import { Check, File, FileInput, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { DirType } from "@/shared/constants"; +import { DirType } from '@simeonradivoev/gameflow-sdk/shared'; import classNames from "classnames"; import { twMerge } from "tailwind-merge"; import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; @@ -47,7 +46,7 @@ function List (data: { let icon = ; if (isDefaultPath) { - icon = ; + icon = f.isDirectory ? : ; } else if (!f.isDirectory) { icon = ; @@ -92,10 +91,9 @@ function NewFolderInput (data: { id: string, name: string | undefined, setName: onEnterPress: () => inputRef.current?.focus(), onBlur: () => inputRef.current?.blur(), }); - const handleFocus = () => + const handleFocus: FocusEventHandler = (e) => { focusSelf(); - systemApi.api.system.show_keyboard.post(); }; return
    data.onFocus?.(data.id, ref.current, details), + onFocus: (l, p, details) => + { + data.onFocus?.(data.id, ref.current, details); + }, onEnterPress: data.onAction }); @@ -27,7 +31,8 @@ function FilterCat (
  • focusSelf({ event: e.nativeEvent })} + data-sound-category={data.active ? undefined : "filter"} className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none gap-1"} > {data.icon ? <>
    {data.icon}
    {data.children ?? data.label}
    :
    {data.children ?? data.label}
    } @@ -68,6 +73,10 @@ export function FilterUI (data: { if (!data.options[newFilter].selected) { data.setSelected(newFilter); + oneShot('selectFilter'); + } else + { + oneShot('invalidNavigation'); } }, button: GamePadButtonCode.R1 @@ -80,7 +89,13 @@ export function FilterUI (data: { const selectedFilterIndex = Math.max(0, filterIndex - 1,); const newFilter = filterKeys[selectedFilterIndex]; if (!data.options[newFilter].selected) + { data.setSelected(newFilter); + oneShot('selectFilter'); + } else + { + oneShot('invalidNavigation'); + } }, button: GamePadButtonCode.L1 }], [data.options]); @@ -90,7 +105,7 @@ export function FilterUI (data: { { if (hasFocusedChild) { - setFocus(`${data.id}-${defaultFocus}`); + setFocus(`${data.id}-${defaultFocus}`, { instant: true }); } }, [hasFocusedChild, defaultFocus, data.id]); @@ -101,7 +116,7 @@ export function FilterUI (data: { style={{ viewTransitionName: `filter-${data.id}` }} > -
      +
        {!!data.rootFocusKey && (data.showShortcuts ?? true) &&
      • } diff --git a/src/mainview/components/FocusDots.tsx b/src/mainview/components/FocusDots.tsx index 0fc2af1..192e388 100644 --- a/src/mainview/components/FocusDots.tsx +++ b/src/mainview/components/FocusDots.tsx @@ -51,7 +51,7 @@ export default function FocusDots (data: { { const focused = em === focusedKey; return ; }); @@ -69,7 +69,7 @@ export default function FocusDots (data: { } }, [data.elements, data.scrollElement?.current]); - return
        + return
        {elements}
        ; } \ No newline at end of file diff --git a/src/mainview/components/FocusTooltip.tsx b/src/mainview/components/FocusTooltip.tsx index 5a165b1..28f4cf1 100644 --- a/src/mainview/components/FocusTooltip.tsx +++ b/src/mainview/components/FocusTooltip.tsx @@ -1,4 +1,4 @@ -import { Ref, RefObject, useEffect, useState } from "react"; +import { RefObject, useState } from "react"; import { useFocusEventListener } from "../scripts/spatialNavigation"; import useActiveControl from "../scripts/gamepads"; import { twMerge } from "tailwind-merge"; @@ -12,7 +12,7 @@ export default function FocusTooltip (data: { parentRef: RefObject; visible { const dataTooltip = e.getAttribute('data-tooltip'); setHoverText(dataTooltip ?? undefined); - setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent'); + setHoverTextType(e.getAttribute('data-tooltip-type') ?? 'accent'); }; const { isPointer } = useActiveControl(); @@ -29,7 +29,10 @@ export default function FocusTooltip (data: { parentRef: RefObject; visible const tooltipStyles = { base: 'bg-base-100 text-base-content', accent: 'bg-accent text-accent-content', - error: 'bg-error text-error-content' + error: 'bg-error text-error-content', + warning: 'bg-warning text-warning-content', + info: 'bg-info text-info-content', + success: 'bg-success text-success-content' }; return !!hoverText && (data.visible ?? true) && !isPointer &&

        {hoverText}

        ; diff --git a/src/mainview/components/FrontEndGameCard.tsx b/src/mainview/components/FrontEndGameCard.tsx index 533eb29..093be25 100644 --- a/src/mainview/components/FrontEndGameCard.tsx +++ b/src/mainview/components/FrontEndGameCard.tsx @@ -1,27 +1,37 @@ import { RPC_URL } from "@/shared/constants"; import CardElement from "./CardElement"; -import { Router } from ".."; import { FileQuestion, HardDrive, Store } from "lucide-react"; import { JSX } from "react"; import { FOCUS_KEYS } from "../scripts/types"; +import { useRouter } from "@tanstack/react-router"; +import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; showSource?: boolean; } & FocusParams & InteractParams) { + const router = useRouter(); function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null) { - Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); + router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; - const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`); - platformUrl.searchParams.set('width', "64"); - const subtitle =
        - {!!data.game.path_platform_cover && } -

        {data.game.platform_display_name}

        -
        ; + let subtitle: any = undefined; + if (data.game.path_platform_cover) + { + const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + subtitle =
        + {!!data.game.path_platform_cover && } +

        {data.game.platform_display_name}

        +
        ; + } - const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_cover}`); - previewUrl.searchParams.delete('ts'); - previewUrl.searchParams.set('width', "640"); + const previewUrls = data.game.path_covers.map(c => + { + const url = new URL(`${RPC_URL(__HOST__)}${c}`); + url.searchParams.delete('ts'); + url.searchParams.set('width', "640"); + return url; + }); const badges: JSX.Element[] = []; @@ -52,7 +62,7 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG badges={badges} onFocus={data.onFocus} onAction={(e) => data.onAction ? data.onAction(e) : handleDefaultSelect(data.game.id, data.game.source, data.game.source_id)} - preview={previewUrl.href} + preview={previewUrls} title={data.game.name ?? ""} subtitle={subtitle} focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)} diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index d998f80..1075a9f 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,30 +1,35 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; -import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants"; -import { useNavigate } from "@tanstack/react-router"; +import { DefaultRommStaleTime, RPC_URL } from "@shared/constants"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; +import { useNavigate, useRouter } from "@tanstack/react-router"; import { HardDrive } from "lucide-react"; import { JSX, useContext } from "react"; -import { GameCardFocusHandler } from "./CardElement"; import { useLocalSetting } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import { allGamesQuery } from "@queries/romm"; +import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; +import { isUrl } from "@/shared/utils"; +import { FOCUS_KEYS } from "../scripts/types"; -export interface GameListParams +export interface GameListParams extends FocusParams { id: string, filters?: GameListFilterType, grid?: boolean, setBackground?: (url: string) => void; onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; - onFocus?: GameCardFocusHandler; + onQuickAction?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; + focus?: string; className?: string; - finalElement?: JSX.Element; + finalElement?: JSX.Element | JSX.Element[]; + emptyElement?: JSX.Element | JSX.Element[]; saveChildFocus?: "session" | "local"; } export function GameList (data: GameListParams) { - const games = useSuspenseQuery({ ...allGamesQuery(data.filters), staleTime: DefaultRommStaleTime }); + const games = useSuspenseQuery({ ...allGamesQuery(data.filters), queryKey: ['games', data.filters ?? 'all'], staleTime: DefaultRommStaleTime }); const navigator = useNavigate(); const blur = useLocalSetting('backgroundBlur'); const backgroundContext = useContext(AnimatedBackgroundContext); @@ -37,7 +42,7 @@ export function GameList (data: GameListParams) try { const screenshotUrl = game.paths_screenshots && game.paths_screenshots.length > 0 ? new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`) : undefined; - const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`); + const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_covers[0]}`); const previewUrl = blur ? coverUrl : (screenshotUrl ?? coverUrl); previewUrl.searchParams.delete('ts'); data.setBackground?.(previewUrl.href) ?? backgroundContext.setBackground(previewUrl.href); @@ -53,6 +58,25 @@ export function GameList (data: GameListParams) navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } }); }; + const finalElement: JSX.Element[] = []; + if (!games.isFetching && !!games.data && games.data.games.length <= 0) + { + if (Array.isArray(data.emptyElement)) + { + finalElement.push(...data.emptyElement); + } else if (data.emptyElement) + { + finalElement.push(data.emptyElement); + } + } + if (Array.isArray(data.finalElement)) + { + finalElement.push(...data.finalElement); + } else if (data.finalElement) + { + finalElement.push(data.finalElement); + } + return ( <> @@ -73,25 +98,34 @@ export function GameList (data: GameListParams) badges.push(); } - const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); - previewUrl.searchParams.delete('ts'); + const previewUrls = g.path_covers.map(c => + { + const url = isUrl(c) ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`); + url.searchParams.delete('ts'); + return url; + }); - const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); - platformUrl.searchParams.set('width', "64"); + let platformUrl: URL | undefined = undefined; + if (g.path_platform_cover) + { + platformUrl = isUrl(g.path_platform_cover) ? new URL(g.path_platform_cover) : new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + } return { id: `${g.id.source}@${g.id.id}`, - focusKey: g.slug ?? `game-${g.id}`, + focusKey: FOCUS_KEYS.GAME_LIST_CARD(data.id, g.id), title: g.name ?? "", subtitle: (
        - {!!g.path_platform_cover && } +

        {g.platform_display_name}

        ), - previewUrl: previewUrl.href, + previewUrls: previewUrls, badges: badges, onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g), + onQuickAction: data.onQuickAction ? () => data.onQuickAction?.(g.id, g.source, g.source_id) : undefined, onFocus: () => handleFocus(g.id, g.source, g.source_id) } satisfies GameMetaExtra; }, diff --git a/src/mainview/components/GamepadKeyboard.tsx b/src/mainview/components/GamepadKeyboard.tsx new file mode 100644 index 0000000..75005a1 --- /dev/null +++ b/src/mainview/components/GamepadKeyboard.tsx @@ -0,0 +1,520 @@ +import { createRef, JSX, RefObject, useEffect, useRef, useState } from "react"; +import useActiveControl, { GamepadButtonEvent } from "../scripts/gamepads"; +import { oneShot } from "../scripts/audio/audio"; +import { ArrowLeft, ArrowRight, CornerDownLeft, Delete, Space } from "lucide-react"; +import { GamePadButtonCode } from "../scripts/shortcuts"; +import { GamepadIconMap } from "./Shortcuts"; +import ShortcutPrompt from "./ShortcutPrompt"; +import { getLocalSetting, showKeyboardHandler } from "../scripts/utils"; + +const Keys = [ + ['E', 'R', 'T', 'F', 'D', 'G', 'V', 'C', 'S', 'X', 'Z', 'B', 'A', 'Q', 'W'], + ['I', '⌫', 'O', '⏎', 'P', 'L', 'N', '␣', 'M', 'J', 'K', 'H', 'Y', 'U'] +]; +const Characters = [ + ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "%", "$", "#", "@", "+"], + [",", '⌫', ".", '⏎', "/", "[", "]", '␣', "(", ")", ":", "!", "?", "&"] +]; +function GetKeys (characters: boolean) +{ + return characters ? Characters : Keys; +} +const KeyColors: Record = { + '⌫': { bg: "var(--color-accent)", color: "var(--color-accent-content)" }, + '⏎': { bg: "var(--color-secondary)", color: "var(--color-secondary-content)" }, + '␣': { bg: "var(--color-info)", color: "var(--color-info-content)" }, +}; +const Shortcuts: Record = { + '⌫': GamePadButtonCode.X, + '␣': GamePadButtonCode.Y, + '⏎': GamePadButtonCode.A, + '←': GamePadButtonCode.Left, + '→': GamePadButtonCode.Right, + '⇧': GamePadButtonCode.RJoy, + '⌥': GamePadButtonCode.LJoy +}; +const KeyElements: Record = { + '⌫': , + '␣': , + '⏎': , + '←': , + '→': , +}; +const DZ = 0.22; + +function ang (x: number, y: number) +{ + if (Math.sqrt(x * x + y * y) < DZ) return null; + let a = Math.atan2(x, -y); + if (a < 0) a += Math.PI * 2; + return a; +} + +function gidx (a: number | null, n: number) +{ + return a === null ? -1 : Math.floor(a / (Math.PI * 2) * n) % n; +} + +function buildWheel (side: 0 | 1, shift: boolean, characters: boolean) +{ + const elements: JSX.Element[] = []; + const refs: RefObject[] = []; + const positions: { left: string; top: string; }[] = []; + const n = GetKeys(characters)[side].length, GAP = 0.028; + + for (let i = 0; i < n; i++) + { + const a0 = i / n * Math.PI * 2 - Math.PI / 2 + GAP; + const a1 = (i + 1) / n * Math.PI * 2 - Math.PI / 2 - GAP; + const am = (a0 + a1) / 2; + const ref = createRef(); + const x = Math.cos(am); + const y = Math.sin(am); + refs.push(ref); + + const tr = 66; + positions.push({ left: `50% + ${tr * x}% - 16px`, top: `50% + ${tr * y}% - 16px` }); + + elements.push(<> + + {KeyElements[GetKeys(characters)[side][i]] ?? shift ? GetKeys(characters)[side][i].toUpperCase() : GetKeys(characters)[side][i].toLocaleLowerCase()} + + ); + } + + return { elements, refs, positions }; +} + +export type EditableInput = HTMLInputElement | HTMLTextAreaElement; + +export function typeKey (el: EditableInput, key: string): void +{ + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + el.value = + el.value.slice(0, start) + + key + + el.value.slice(end); + + const pos = start + key.length; + el.setSelectionRange(pos, pos); +} + +export function backspace (el: EditableInput): void +{ + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + // selection delete + if (start !== end) + { + el.value = + el.value.slice(0, start) + + el.value.slice(end); + + el.setSelectionRange(start, start); + return; + } + + // nothing to delete + if (start === 0) return; + + el.value = + el.value.slice(0, start - 1) + + el.value.slice(end); + + el.setSelectionRange(start - 1, start - 1); +} + +export function deleteForward (el: EditableInput): void +{ + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + if (start !== end) + { + el.value = + el.value.slice(0, start) + + el.value.slice(end); + + el.setSelectionRange(start, start); + return; + } + + if (start >= el.value.length) return; + + el.value = + el.value.slice(0, start) + + el.value.slice(start + 1); + + el.setSelectionRange(start, start); +} + +export function enter (el: EditableInput): void +{ + if (el instanceof HTMLTextAreaElement) + { + + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + + const insert = "\n"; + + el.value = + el.value.slice(0, start) + + insert + + el.value.slice(end); + + const pos = start + 1; + el.setSelectionRange(pos, pos); + + } else + { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true })); + } + +} + +export function arrowLeft (el: EditableInput): void +{ + const pos = el.selectionStart ?? 0; + const newPos = Math.max(0, pos - 1); + + el.setSelectionRange(newPos, newPos); +} + +export function arrowRight (el: EditableInput): void +{ + const pos = el.selectionStart ?? 0; + const newPos = Math.min(el.value.length, pos + 1); + + el.setSelectionRange(newPos, newPos); +} + +export function GamepadKeyboard () +{ + const triggerThreshold = 0.85; + const [focusedInput, setFocusedInput] = useState(null); + const circleRefs = [useRef(null), useRef(null)]; + const sideRefs = [useRef(null), useRef(null)]; + const keyIndicatorRefs = [useRef(null), useRef(null)]; + const activeControl = useActiveControl(); + const hidden = !focusedInput || activeControl.control !== 'gamepad'; + const keyboardRef = useRef(null); + const [shift, setShift] = useState(false); + const [characters, setCharacters] = useState(false); + + useEffect(() => + { + if (!hidden) + { + oneShot('openKeyboard'); + } + }, [hidden]); + + const elements = [buildWheel(0, shift, characters), buildWheel(1, shift, characters)]; + + useEffect(() => + { + let disposed = false; + const lockedIds: [number | undefined, number | undefined] = [undefined, undefined]; + const actionRepeatTimeout: [NodeJS.Timeout | undefined, NodeJS.Timeout | undefined] = [undefined, undefined]; + const actionRepeatCount = [0, 0]; + const prevTriggerValues = [0, 0]; + const buttonValues: Record = {}; + const buttonRepeatTimeout: Record = {}; + const buttonRepeatCounts: Record = {}; + const lastIndexes = [-1, -1]; + + function update () + { + const gps = navigator.getGamepads ? navigator.getGamepads() : []; + const gp = [...gps].find(g => g); + + if (keyboardRef.current && focusedInput && !hidden) + { + const targetRect = focusedInput.getBoundingClientRect(); + const el = keyboardRef.current; + + // First, measure the element itself + const elRect = el.getBoundingClientRect(); + + const margin = 64; // keep some space from edges + + let left = targetRect.left; + let top = targetRect.bottom + 128; + + // Clamp horizontally + if (left + elRect.width > window.innerWidth - margin) + { + left = window.innerWidth - elRect.width - margin; + } + + if (left < margin) + { + left = margin; + } + + // Clamp vertically + if (top + elRect.height > window.innerHeight - margin) + { + // flip above the input if it doesn't fit below + top = targetRect.top - elRect.height - 128; + } + + if (top < margin) + { + top = margin; + } + + el.style.position = "fixed"; + el.style.left = `${left}px`; + el.style.top = `${top}px`; + } + + if (gp && !hidden) + { + function pressKey (el: EditableInput, key: string, repeatCount: number): void + { + const hapticIntensity = 1 / Math.max(repeatCount, 1); + const soundIntensity = 1 / Math.min(2, Math.max(repeatCount * 0.2, 1)); + gp?.vibrationActuator.playEffect('dual-rumble', { duration: 60, strongMagnitude: hapticIntensity, weakMagnitude: hapticIntensity }); + + switch (key) + { + case "⌫": + oneShot('keyPressBackspace', { volume: soundIntensity }); + return backspace(el); + case "Delete": + oneShot('keyPressBackspace', { volume: soundIntensity }); + return deleteForward(el); + case "←": + oneShot('keyPress', { volume: soundIntensity }); + return arrowLeft(el); + case "→": + oneShot('keyPress', { volume: soundIntensity }); + return arrowRight(el); + case "⏎": + oneShot('keyPress', { volume: soundIntensity }); + return enter(el); + case "␣": + oneShot('keyPressSpace', { volume: soundIntensity }); + return typeKey(el, ' '); + case "⇧": + setShift(v => !v); + return; + case "⌥": + setCharacters(v => !v); + return; + default: + oneShot('keyPress', { volume: soundIntensity }); + return typeKey(el, shift ? key.toUpperCase() : key.toLocaleLowerCase()); + } + } + + for (let side = 0; side < 2; side++) + { + const x = gp.axes[side * 2] ?? 0; + const y = gp.axes[side * 2 + 1] ?? 0; + const triggerValue = Math.max(gp.buttons[6 + side]?.value ?? 0, gp.buttons[4 + side]?.value ?? 0); + const angle = ang(x, y); + const keyIndex = lockedIds[side] !== undefined ? lockedIds[side]! : gidx(angle, GetKeys(characters)[side].length); + + elements[side].refs.filter(e => e.current).forEach((e, i) => + { + const active = keyIndex === i; + const key = GetKeys(characters)[side][i]; + const elem = e.current!; + elem.style.backgroundColor = active ? 'var(--color-primary)' : KeyColors[key]?.bg ?? ''; + elem.style.color = active ? 'var(--color-primary-content)' : KeyColors[key]?.color ?? ''; + elem.style.scale = `${active ? 150 : 100}%`; + elem.style.fontStyle = active ? 'bold' : 'normal'; + }); + + const circle = circleRefs[side].current!; + + // Update actions + if (keyIndex >= 0) + { + if (focusedInput) + { + if (triggerValue >= triggerThreshold && prevTriggerValues[side] < triggerThreshold) + { + const timeoutCalc = () => 400 / Math.min(4, Math.max(1, 1 + (actionRepeatCount[side] ?? 0))); + const handleRepeat = () => + { + elements[side].refs[keyIndex].current!.animate([ + { boxShadow: "0 0 0 0 var(--color-base-content)" }, + { boxShadow: "0 0 0 10px transparent" } + ], + { duration: 300, easing: 'ease-out', fill: 'none' } + ); + pressKey(focusedInput, GetKeys(characters)[side][keyIndex], actionRepeatCount[side]); + actionRepeatCount[side]++; + actionRepeatTimeout[side] = setTimeout(handleRepeat, timeoutCalc()); + }; + handleRepeat(); + } + else if (triggerValue < triggerThreshold && prevTriggerValues[side] >= triggerThreshold) + { + clearTimeout(actionRepeatTimeout[side]); + actionRepeatCount[side] = -1; + } + + if (lockedIds[side] === undefined && triggerValue > 0.1) + { + lockedIds[side] = keyIndex; + } else if (lockedIds[side] !== undefined && triggerValue <= 0.1) + { + lockedIds[side] = undefined; + } + } + + keyIndicatorRefs[side].current!.textContent = shift ? GetKeys(characters)[side][keyIndex].toUpperCase() : GetKeys(characters)[side][keyIndex].toLowerCase(); + } else + { + keyIndicatorRefs[side].current!.textContent = ""; + } + + // Update cirlce + const magnitudeSqr = (x * x) + (y * y); + const magnitude = Math.sqrt(magnitudeSqr); + + circle.style.left = `calc(50% + ${50 * x}% - 16px)`; + circle.style.top = `calc(50% + ${50 * y}% - 16px)`; + circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`; + circle.style.backgroundColor = `color-mix(in srgb, var(--color-base-content), 'var(--color-primary)'} ${magnitude * 100}%)`; + + if (sideRefs[side].current) + { + sideRefs[side].current!.style.background = `radial-gradient( + circle at calc(50% + ${100 * x}px) calc(50% + ${100 * y}px), + color-mix(in srgb, var(--color-primary) 20%, transparent), + transparent + )`; + } + + + if (lastIndexes[side] !== keyIndex) + { + gp.vibrationActuator.playEffect('dual-rumble', { duration: 30, strongMagnitude: 0, weakMagnitude: 0.2 }); + oneShot('keyHover'); + } + + prevTriggerValues[side] = triggerValue; + lastIndexes[side] = keyIndex; + } + + const shortcutKeys = Object.entries(Shortcuts); + function handleButton (key: number, repeatCount: number) + { + if (!focusedInput) return; + const entry = shortcutKeys.find(([n, value]) => value === key); + if (key === GamePadButtonCode.A) return; + if (entry) + { + pressKey(focusedInput, entry[0], repeatCount); + } + } + + for (let i = 0; i < gp.buttons.length; i++) + { + const btn = gp.buttons[i]; + if (btn.value >= 0.85 && buttonValues[i] < 0.85) + { + const timeoutCalc = () => 400 / Math.min(8, Math.max(1, 1 + (buttonRepeatCounts[i] ?? 0))); + const handleRepeat = () => + { + handleButton(i, buttonRepeatCounts[i]); + buttonRepeatCounts[i] = (buttonRepeatCounts[i] ?? -1) + 1; + buttonRepeatTimeout[i] = setTimeout(handleRepeat, timeoutCalc()); + }; + handleRepeat(); + } + else if (btn.value < 0.85 && buttonValues[i] >= 0.85) + { + clearTimeout(buttonRepeatTimeout[i]); + buttonRepeatCounts[i] = -1; + } + + buttonValues[i] = btn.value; + } + } + + if (!disposed && !hidden) requestAnimationFrame(update); + } + + if (!disposed && !hidden) requestAnimationFrame(update); + + const gamepadButtonHandler = (e: Event) => + { + if (!(e instanceof GamepadButtonEvent) || disposed || hidden) return; + if (e.button === GamePadButtonCode.L1 || e.button === GamePadButtonCode.R1 || e.button === GamePadButtonCode.L2 || e.button === GamePadButtonCode.R2) + { + e.preventDefault(); + e.stopImmediatePropagation(); + } + + }; + window.addEventListener('gamepadbuttondown', gamepadButtonHandler); + window.addEventListener('gamepadbuttonup', gamepadButtonHandler); + + return () => + { + disposed = true; + Object.values(buttonRepeatTimeout).forEach(v => clearTimeout(v)); + Object.values(actionRepeatTimeout).forEach(v => clearTimeout(v)); + window.removeEventListener('gamepadbuttondown', gamepadButtonHandler); + window.removeEventListener('gamepadbuttonup', gamepadButtonHandler); + }; + }, [focusedInput, elements, shift, characters, hidden]); + + useEffect(() => + { + + const handleFocus = (e: FocusEvent) => + { + if (e.target instanceof HTMLInputElement && (e.target.type === 'text' || e.target.type === 'search')) + { + if (!getLocalSetting('autoKeybaord')) return; + if (getLocalSetting('useGameflowKeyboard')) + { + setFocusedInput(e.target); + } else + { + showKeyboardHandler(activeControl.control, e.target); + } + } + }; + + const handleBlur = (e: FocusEvent) => + { + setFocusedInput(null); + }; + + document.addEventListener('focusin', handleFocus); + document.addEventListener('focusout', handleBlur); + + return () => + { + document.removeEventListener('focusin', handleFocus); + document.removeEventListener('focusout', handleBlur); + }; + }, []); + + return ; +} \ No newline at end of file diff --git a/src/mainview/components/GlobalContextDialog.tsx b/src/mainview/components/GlobalContextDialog.tsx new file mode 100644 index 0000000..0fcc23d --- /dev/null +++ b/src/mainview/components/GlobalContextDialog.tsx @@ -0,0 +1,28 @@ +import { useState } from "react"; +import { GlobalDialogContext } from "../scripts/contexts"; +import { useContextDialog } from "./ContextDialog"; + +export default function GlobalContextDialog (data: { children: any; }) +{ + const [currentContext, setCurrentContext] = useState(undefined); + const [preferredChildFocusKey, setPreferredChildFocusKey] = useState(undefined); + const [onCloseCallback, setOnCloseCallback] = useState<(() => void) | undefined>(undefined); + + const { dialog, setOpen } = useContextDialog('global-context-dialog', { + content: currentContext, + onClose: onCloseCallback, + preferredChildFocusKey: preferredChildFocusKey + }); + return + {data.children} + {dialog} + ; +} \ No newline at end of file diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 1dad439..d38ef5b 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -13,8 +13,8 @@ import BatteryWarning, Bell, Bluetooth, + CircleFadingArrowUp, Clock, - Plug, Settings, Wifi, WifiHigh, @@ -23,17 +23,17 @@ import } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { RPC_URL, SystemInfoType } from "../../shared/constants"; import { JSX, RefObject, useContext, useEffect, useRef, useState } from "react"; -import { systemApi } from "../scripts/clientApi"; -import { Router } from ".."; import { useStickyDataAttr } from "../scripts/utils"; import { twMerge } from "tailwind-merge"; import { TwitchIcon } from "../scripts/brandIcons"; -import { rommLoggedInQuery, rommUserQuery } from "../scripts/queries/romm"; +import { rommLoggedInQuery } from "../scripts/queries/romm"; import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; -import { da } from "zod/v4/locales"; -import { SystemInfoContext } from "../scripts/contexts"; +import { AppContext, SystemInfoContext } from "../scripts/contexts"; +import { useNavigate, useRouter } from "@tanstack/react-router"; +import { oneShot } from "../scripts/audio/audio"; +import { hasUpdateQuery } from "../scripts/queries/system"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; function HeaderAvatar (data: { id: string; @@ -73,6 +73,8 @@ export interface HeaderButton icon: JSX.Element; external?: boolean; action?: () => void; + className?: string; + shortcutLabel?: string; } export interface HeaderAccount @@ -85,24 +87,48 @@ export interface HeaderAccount action?: () => void; } +function UpdateStatus () +{ + const handleSelect = () => + { + navigate({ to: '/settings/update' }); + }; + const hasUnread = false; + const navigate = useNavigate(); + const { ref } = useFocusable({ + focusKey: 'update-bt', onEnterPress: handleSelect + }); + return
        + +
        ; +} + function NotificationStatus () { const hasUnread = false; - return
        + return
        ; } function ClockStatus () { - const ref = useRef(null); + const navigate = useNavigate(); + const app = useContext(AppContext); + const refClock = useRef(null); + const activeTaskProgress = app.activeTaskProgress; + const handleTaskClick = () => + { + navigate({ to: '/settings/tasks' }); + }; + const { ref, focusKey } = useFocusable({ focusKey: 'tasks-indicator', focusable: !!activeTaskProgress, onEnterPress: handleTaskClick }); useEffect(() => { function update () { - if (ref.current) + if (refClock.current) { - ref.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + refClock.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } } @@ -126,7 +152,16 @@ function ClockStatus () return () => clearTimeout(timeout); }, []); - return
        ; + useShortcuts(focusKey, () => [{ + label: "Downloads", button: GamePadButtonCode.A, action (e) + { + handleTaskClick(); + }, + }]); + + return
        + + {activeTaskProgress ?
        : }
        ; } function BluetoothStatus () @@ -145,7 +180,7 @@ function WiFiStatus () return systemContext && systemContext.wifiConnections.length > 0 ?
        {systemContext.wifiConnections.map(w => { - const className = "w-6 h-6"; + const className = "w-10 h-10"; let icon = ; if (w.signalLevel >= -60) icon = ; @@ -156,7 +191,7 @@ function WiFiStatus () else if (w.signalLevel >= -90) icon = ; - return
        + return
        {icon}
        ; })} @@ -206,19 +241,18 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) placeholderData: keepPreviousData }); - const { ref } = useFocusable({ focusKey: 'accounts' }); + const handleSelect = () => + { + router.navigate({ to: '/settings/accounts' }); + oneShot('click'); + }; const accounts: HeaderAccount[] = []; if (data.accounts) accounts.push(...data.accounts); - if (rommUser.data?.hasLogin || rommUser.isError) { accounts.push({ id: 'romm', preview: `https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg`, - action: () => - { - Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } }); - }, className: rommUser.data?.hasLogin && !rommUser.isError ? undefined : "border-error", type: 'secondary' }); @@ -228,15 +262,21 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) { accounts.push({ id: 'twitch', preview: TwitchIcon, - action: () => - { - Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } }); - }, type: 'secondary' }); } + const hasAccounts = accounts.length > 0; + const router = useRouter(); - return
        + const { ref } = useFocusable({ + focusKey: 'accounts', + onEnterPress: handleSelect, + focusable: hasAccounts + }); + + + + return
        {accounts?.map(a => -
        - - - - - -
        - {!!data.buttons &&
        } -
        - {data.buttonElements ?? data.buttons?.map(b => {b.icon})} -
        + const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' }); + const { data: update } = useQuery(hasUpdateQuery); + return
        + +
        + + + + + {!!update && update.hasUpdate >= 1 && } + +
        + {!!data.buttons &&
        } +
        + {data.buttonElements} + {data.buttons?.map(b => {b.icon})} +
        +
        ; } @@ -285,26 +332,41 @@ interface HeaderUIParams export function HeaderUI (data: HeaderUIParams) { const { ref, focusKey } = useFocusable({ focusKey: "header-elements", focusable: data.focusable, preferredChildFocusKey: data.preferredChildFocusKey }); + const router = useRouter(); const goToSettings = () => { - Router.navigate({ to: '/settings/accounts' }); + router.navigate({ to: '/settings/accounts' }); }; return ( - -
        - + +
        + + {data.title} - , id: "settings", action: goToSettings, external: true }]} /> -
        - + , + id: "header-settings-btn", + action: goToSettings, + external: true, + shortcutLabel: "Settings" + } + ]} /> + + +
        ); } -export function StickyHeaderUI (data: { ref: RefObject; } & HeaderUIParams) +export function StickyHeaderUI (data: { ref: RefObject; className?: string; children?: any; } & HeaderUIParams) { const [isStuck, setIsStuck] = useState(false); const headerRef = useRef(null); @@ -313,8 +375,9 @@ export function StickyHeaderUI (data: { ref: RefObject; } & HeaderUIParams) return <>
        -
        +
        + {data.children}
        ; } \ No newline at end of file diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx new file mode 100644 index 0000000..198c552 --- /dev/null +++ b/src/mainview/components/HeaderSearchField.tsx @@ -0,0 +1,105 @@ +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { useEffect, useRef, useState } from "react"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { oneShot } from "../scripts/audio/audio"; +import { Search } from "lucide-react"; +import { RoundButton } from "./RoundButton"; +import { useEventListener } from "usehooks-ts"; +import { twMerge } from "tailwind-merge"; + +function SearchInput (data: { + id: string; + autoSearch?: boolean; + search: string | undefined; + compact: boolean | undefined; + onInputFocus: () => void; + setShowInput: (show: boolean) => void; + className?: string; + onSubmit: (search: string | undefined) => void; +} & FocusParams) +{ + const { ref, focusKey } = useFocusable({ + onBlur: () => inputRef.current?.blur(), + onFocus: (l, p, d) => + { + data.onFocus?.(focusKey, ref.current, { ...d, inputRef }); + if (data.autoSearch) inputRef.current?.focus(); + }, + focusKey: data.id, + onEnterPress: () => + { + if (document.activeElement === inputRef.current) + { + if (inputRef.current) + data.onSubmit?.(inputRef.current.value); + } else + { + inputRef.current?.focus(); + } + } + }); + + const inputRef = useRef(null); + const [localSearch, setLocalSearch] = useState(data.search); + + useEffect(() => + { + setLocalSearch(data.search ?? ""); + }, [data.search]); + + useShortcuts(focusKey, () => document.activeElement === inputRef.current ? [{ + label: "Cancel", + button: GamePadButtonCode.B, action (e) + { + inputRef.current?.blur(); + oneShot('returnGeneric'); + }, + }] : [], [inputRef.current, document.activeElement]); + + useEventListener('search' as any, e => + { + data.onSubmit?.(undefined); + }, inputRef as any); + + return ; +} + +export default function HeaderSearchField (data: { + id: string; + autoSearch?: boolean; + search: string | undefined, + onSubmit: (search: string | undefined) => void; + className?: string; + compact?: boolean; +} & FocusParams) +{ + const [showInput, setShowInput] = useState(false); + + const { ref, focusKey, focusSelf } = useFocusable({ + focusKey: data.id, + focusBoundaryDirections: ['left', "right"], + isFocusBoundary: data.compact && showInput + }); + + return
        + + {(!data.compact || showInput) && } + {data.compact && !showInput && setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} >} + +
        ; +} \ No newline at end of file diff --git a/src/mainview/components/ImageWithFallbacks.tsx b/src/mainview/components/ImageWithFallbacks.tsx new file mode 100644 index 0000000..6779b26 --- /dev/null +++ b/src/mainview/components/ImageWithFallbacks.tsx @@ -0,0 +1,32 @@ +export default function ImageWithFallbacks (data: { + src: URL[]; + draggable?: boolean; + className?: string; +}) +{ + const handleError = (e: React.SyntheticEvent) => + { + const img = e.currentTarget; + const nextIndex = Number(img.dataset.index) + 1; + + if (nextIndex < data.src.length) + { + img.dataset.index = String(nextIndex); + img.src = data.src[nextIndex].href; + + } + }; + return + { + e.currentTarget.dataset.loaded = "true"; + }} + > + + ; +} \ No newline at end of file diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index 5d2cb03..d52e4e0 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -1,25 +1,24 @@ import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { FOCUS_KEYS } from "../scripts/types"; import { useIntersectionObserver } from "usehooks-ts"; -import { useEffect } from "react"; +import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; -export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams) +export default function LoadMoreButton (data: { isFetching: boolean; hidden?: boolean, lastId?: FrontEndId; } & FocusParams & InteractParams) { - const handleAction = (e?: Event) => + const handleAction = (event?: Event) => { - data.onAction?.(e); + data.onAction?.({ event, focusKey }); if (data.lastId && focused) setFocus(FOCUS_KEYS.GAME_CARD(data.lastId)); }; const { ref, focusKey, focused } = useFocusable({ + focusable: !data.isFetching && data.hidden !== true, focusKey: 'load-more-btn', onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details), onEnterPress: handleAction }); - - const { ref: intersct } = useIntersectionObserver({ initialIsIntersecting: true, rootMargin: "20%", @@ -36,5 +35,5 @@ export default function LoadMoreButton (data: { isFetching: boolean; lastId?: Fr { ref.current = r; intersct(r); - }} className='flex bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' onClick={e => handleAction(e.nativeEvent)} id='load-more-btn'>{data.isFetching ? : "Load More"}
        ; + }} className='flex data-[hidden=true]:invisible bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' data-hidden={data.hidden} onClick={e => handleAction(e.nativeEvent)} id='load-more-btn'>{data.isFetching ? : "Load More"}
        ; } \ No newline at end of file diff --git a/src/mainview/components/LoadingCardList.tsx b/src/mainview/components/LoadingCardList.tsx index e25dc26..d811d55 100644 --- a/src/mainview/components/LoadingCardList.tsx +++ b/src/mainview/components/LoadingCardList.tsx @@ -17,7 +17,6 @@ export default function LoadingCardList (data: { id: string, placeholderCount: n ref={ref} title="Games" id={`card-list-placeholder`} - save-child-focus="session" className={twMerge("items-center justify-center-safe h-full", data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-min grid-cols-[repeat(auto-fill,var(--game-card-width))]" : 'landscape:grid landscape:grid-flow-col landscape:auto-cols-min auto-rows-[1fr] sm:gap-2 md:gap-4 portrait:grid portrait:auto-rows-min portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))] *:portrait:aspect-8/10 *:landscape:aspect-8/12 sm:landscape:max-h-84 md:max-h-128!', diff --git a/src/mainview/components/LoadingScreen.tsx b/src/mainview/components/LoadingScreen.tsx new file mode 100644 index 0000000..f92cc98 --- /dev/null +++ b/src/mainview/components/LoadingScreen.tsx @@ -0,0 +1,9 @@ +export default function LoadingScreen (data: { children?: any; }) +{ + return
        +
        +
        +
        + {data.children} +
        ; +} \ No newline at end of file diff --git a/src/mainview/components/NotFound.tsx b/src/mainview/components/NotFound.tsx index ea70534..2774240 100644 --- a/src/mainview/components/NotFound.tsx +++ b/src/mainview/components/NotFound.tsx @@ -1,19 +1,19 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Home, TriangleAlert } from "lucide-react"; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; -import { Router } from ".."; -import Shortcuts from "./Shortcuts"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { Button } from "./options/Button"; import { useEffect } from "react"; +import { useRouter } from "@tanstack/react-router"; +import { FloatingShortcuts } from "./Shortcuts"; export default function NotFound () { const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" }); - const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); + const router = useRouter(); + const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]); - const { shortcuts } = useShortcutContext(); - useEffect(() => { focusSelf(); }, []); + useEffect(() => { focusSelf({ instant: true }); }, []); return
        @@ -26,7 +26,7 @@ export default function NotFound ()
        -
        +
        ; } \ No newline at end of file diff --git a/src/mainview/components/Notifications.tsx b/src/mainview/components/Notifications.tsx index 37edb26..c13c4b6 100644 --- a/src/mainview/components/Notifications.tsx +++ b/src/mainview/components/Notifications.tsx @@ -1,7 +1,16 @@ import { RPC_URL } from "@/shared/constants"; +import { FrontendNotification } from "@simeonradivoev/gameflow-sdk/shared"; +import { Clock, CloudUpload, Save } from "lucide-react"; import { useEffect } from "react"; import toast, { ToastOptions } from "react-hot-toast"; + +const customIconMap = { + save: , + upload: , + clock: +}; + export default function Notifications (data: {}) { useEffect(() => @@ -10,7 +19,13 @@ export default function Notifications (data: {}) es.addEventListener('notification', (e) => { const notification = JSON.parse(e.data) as FrontendNotification; - const options: ToastOptions = { removeDelay: notification.duration }; + const options: ToastOptions = { + removeDelay: notification.duration, + style: { + borderRadius: "64px" + } + }; + if (notification.icon) options.icon = customIconMap[notification.icon]; if (notification.type === 'error') { toast.error(notification.message, options); diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 48af4a3..790a33d 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -5,21 +5,45 @@ import { CardList, GameMetaExtra } from "./CardList"; import { rommApi } from "../scripts/clientApi"; import { JSX, useMemo } from "react"; import { HardDrive } from "lucide-react"; -import { GameCardFocusHandler } from "./CardElement"; import { mobileCheck } from "../scripts/utils"; import { twMerge } from "tailwind-merge"; +import placeholder from '../assets/256x256.png?url'; + +function Preview (data: { index: number, pathCover: string | null; }) +{ + const coverUrl = new URL(`${RPC_URL(__HOST__)}${data.pathCover}`); + coverUrl.searchParams.set('width', "320"); + const isMobile = mobileCheck(); + return
        + e.currentTarget.src = placeholder} + src={coverUrl.href} + > + +
        ; +} export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; - onFocus?: GameCardFocusHandler; grid?: boolean; onSelect?: (source: string, id: string) => void; saveChildFocus?: "session" | "local"; -}) +} & FocusParams) { - const isMobile = mobileCheck(); + const navigate = useNavigate(); const { data: platforms } = useSuspenseQuery( { @@ -46,37 +70,19 @@ export function PlatformsList (data: { badges.push({g.game_count}); if (g.hasLocal) badges.push(); - const coverUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); - coverUrl.searchParams.set('width', "320"); + const entry: GameMetaExtra = { id: g.slug, focusKey: g.slug, title: g.name, - subtitle: g.family_name ?? "", - previewUrl: "", + subtitle: g.family_name ?? undefined, + previewUrls: "", badges, onFocus: () => data.setBackground( g.paths_screenshots.length > 0 ? `${RPC_URL(__HOST__)}${g.paths_screenshots[new Date().getMinutes() % g.paths_screenshots.length]}` : `${RPC_URL(__HOST__)}/api/romm/image?url=https://picsum.photos/id/${10 + i}/1280/720.webp`, ), onSelect: () => data.onSelect ? data.onSelect(g.id.source, g.id.id) : handleDefaultSelect(g.id.source, g.id.id), - preview: - () =>
        - -
        - , + preview: () => }; return entry; }), [platforms]); @@ -88,7 +94,7 @@ export function PlatformsList (data: { id={data.id} grid={data.grid} className={twMerge('*:aspect-8/10! md:py-12', data.className)} - onGameFocus={data.onFocus} + onFocus={data.onFocus} games={platformsMapped} onSelectGame={(id) => { diff --git a/src/mainview/components/RoundButton.tsx b/src/mainview/components/RoundButton.tsx index 386723b..01f9017 100644 --- a/src/mainview/components/RoundButton.tsx +++ b/src/mainview/components/RoundButton.tsx @@ -9,10 +9,11 @@ export function RoundButton (data: { external?: boolean; style?: ButtonStyle; cssStyle?: CSSProperties; + shortcutLabel?: string; } & InteractParams & FocusParams) { return ( - diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx index 3689b61..e65a965 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -8,22 +8,24 @@ import Carousel from "./Carousel"; import { ContextDialog } from "./ContextDialog"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { twMerge } from "tailwind-merge"; +import { isUrl } from "@/shared/utils"; function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams) { const imageRef = useRef(null); - const { ref, focusSelf } = useFocusable({ + const { ref, focusSelf, focusKey } = useFocusable({ focusKey: `screenshot-${data.index}`, - onEnterPress: () => data.onAction?.(), + onEnterPress: () => data.onAction?.({ focusKey }), onFocus: (e, p, details) => { data.setFocused?.(data.index); scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' }); } }); 4096; + const url = isUrl(data.path) ? data.path : `${RPC_URL(__HOST__)}${data.path}`; return
        - focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" /> -
        data.onAction?.(e.nativeEvent)}>
        + focusSelf({ nativeEvent: e.nativeEvent })} src={url} loading="lazy" decoding="async" /> +
        data.onAction?.({ event: e.nativeEvent, focusKey })}>
        ; } @@ -59,8 +61,9 @@ function Preview (data: { id: string; screenshots?: string[]; preview: number; s } } ], [data.preview, focusKey, data.screenshots?.length ?? 0]); + const url = isUrl(data.screenshots?.[data.preview]) ? data.screenshots?.[data.preview] : `${RPC_URL(__HOST__)}${data.screenshots?.[data.preview]}`; - return ; + return ; } export default function Screenshots (data: { screenshots?: string[]; className?: string; } & FocusParams) @@ -83,7 +86,7 @@ export default function Screenshots (data: { screenshots?: string[]; className?: const closest = findClosestElementToCenter(scrollRef.current); if (!closest) return; const closestIndex = Array.from(scrollRef.current.children).indexOf(closest); - setFocus(`screenshot-${closestIndex}`); + setFocus(`screenshot-${closestIndex}`, { instant: true }); } }, [focused, hasFocusedChild, scrollRef.current]); diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx new file mode 100644 index 0000000..42d21c8 --- /dev/null +++ b/src/mainview/components/SelectMenu.tsx @@ -0,0 +1,118 @@ +import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router"; +import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; +import { DoorOpen, Gamepad2, Home, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; +import { systemApi } from "../scripts/clientApi"; +import { FOCUS_KEYS } from "../scripts/types"; + +export default function SelectMenu (data: { rootFocusKey: string; }) +{ + const navigate = useNavigate(); + const matchRoute = useMatchRoute(); + const router = useRouter(); + + const options: DialogEntry[] = [ + { + content: "Home", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/" }); + }, + selected: !!matchRoute({ to: '/' }), + type: "primary", + id: "home-m" + }, + { + content: "Library", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/games" }); + }, + selected: !!matchRoute({ to: '/games' }), + type: "secondary", + id: "library-m" + }, + { + content: "Store", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/store/tab" }); + }, + selected: !!matchRoute({ to: '/store/tab' }), + type: "info", + id: "store-m" + }, + { + content: "Settings", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/settings/interface" }); + }, + selected: !!matchRoute({ to: '/settings' }) && !matchRoute({ to: '/settings/plugins' }) && !matchRoute({ to: '/settings/plugin/$source' }), + type: "accent", + id: "settings-m" + }, + { + content: "Plugins", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/settings/plugins" }); + }, + selected: !!matchRoute({ to: '/settings/plugins' }) && !matchRoute({ to: '/settings/plugin/$source' }), + type: "accent", + id: "plugins-m" + }, + { + content: "Reload", + icon: , + action (ctx) + { + setOpen(false); + navigation.reload(); + }, + type: "accent", + id: "reload-m" + }, + { + content: "Quit", + icon: , + action (ctx) + { + systemApi.api.system.exit.post(); + }, + type: 'error', + id: "quit-m" + } + ]; + const { dialog, setOpen, open } = useContextDialog('select-menu', { + content: <>
        {router.history.location.pathname}
        , + className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none max-h-screen', + preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '') + }); + useShortcuts(data.rootFocusKey, () => [{ + label: "Menu", side: 'left', button: GamePadButtonCode.Select, action (e) + { + if (open) + { + setOpen(false); + } else + { + setOpen(true, getCurrentFocusKey()); + } + + }, + }], [open]); + + return <>{dialog}; +} \ No newline at end of file diff --git a/src/mainview/components/ShortcutPrompt.tsx b/src/mainview/components/ShortcutPrompt.tsx index 63a4ffd..4cb8353 100644 --- a/src/mainview/components/ShortcutPrompt.tsx +++ b/src/mainview/components/ShortcutPrompt.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler } from "react"; +import { JSX, MouseEventHandler } from "react"; import SvgIcon, { IconType } from "./SvgIcon"; import classNames from "classnames"; import { twMerge } from "tailwind-merge"; @@ -6,8 +6,9 @@ import { twMerge } from "tailwind-merge"; export default function ShortcutPrompt (data: { id: string; icon?: IconType; - label?: string; + label?: string | JSX.Element; className?: string; + iconClassName?: string; onClick?: MouseEventHandler; }) { @@ -23,7 +24,7 @@ export default function ShortcutPrompt (data: { }) )} > - {data.icon && } + {data.icon && } {data.label}
        ); diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index d8fc94c..a71eeca 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -1,29 +1,36 @@ import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads'; -import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts'; +import { GamePadButtonCode, useShortcutContext } from '../scripts/shortcuts'; import ShortcutPrompt from './ShortcutPrompt'; import { IconType } from './SvgIcon'; -export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) +export function FloatingShortcuts () { - const iconMap: Record = { - [GamePadButtonCode.A]: 'steamdeck_button_a', - [GamePadButtonCode.B]: 'steamdeck_button_b', - [GamePadButtonCode.X]: 'steamdeck_button_x', - [GamePadButtonCode.Y]: 'steamdeck_button_y', - [GamePadButtonCode.L1]: 'steamdeck_button_l1', - [GamePadButtonCode.R1]: 'steamdeck_button_r1', - [GamePadButtonCode.L2]: 'steamdeck_button_l2', - [GamePadButtonCode.R2]: 'steamdeck_button_r2', - [GamePadButtonCode.Select]: 'steamdeck_button_guide', - [GamePadButtonCode.Start]: 'steamdeck_button_options', - [GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press', - [GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press', - [GamePadButtonCode.Up]: 'steamdeck_dpad_up', - [GamePadButtonCode.Down]: 'steamdeck_dpad_down', - [GamePadButtonCode.Left]: 'steamdeck_dpad_left', - [GamePadButtonCode.Right]: 'steamdeck_dpad_right', - [GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess' - }; + return
        ; +} + +export const GamepadIconMap: Record = { + [GamePadButtonCode.A]: 'steamdeck_button_a', + [GamePadButtonCode.B]: 'steamdeck_button_b', + [GamePadButtonCode.X]: 'steamdeck_button_x', + [GamePadButtonCode.Y]: 'steamdeck_button_y', + [GamePadButtonCode.L1]: 'steamdeck_button_l1', + [GamePadButtonCode.R1]: 'steamdeck_button_r1', + [GamePadButtonCode.L2]: 'steamdeck_button_l2', + [GamePadButtonCode.R2]: 'steamdeck_button_r2', + [GamePadButtonCode.Select]: 'steamdeck_button_guide', + [GamePadButtonCode.Start]: 'steamdeck_button_options', + [GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press', + [GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press', + [GamePadButtonCode.Up]: 'steamdeck_dpad_up', + [GamePadButtonCode.Down]: 'steamdeck_dpad_down', + [GamePadButtonCode.Left]: 'steamdeck_dpad_left', + [GamePadButtonCode.Right]: 'steamdeck_dpad_right', + [GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess' +}; + +export default function Shortcuts (data: { centerElement?: any; }) +{ + const keyboardMap: Record = { [GamePadButtonCode.A]: 'ENTER', @@ -47,15 +54,28 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) const { control } = useActiveControl(); const showKeyboard = control === 'keyboard' || control === 'mouse'; + const { shortcuts } = useShortcutContext(); return ( -
        - {data.shortcuts?.filter(s => !!s.label).map((s, i) => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} - icon={showKeyboard ? undefined : iconMap[s.button]} - label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> - )} -
        + <> +
        + {shortcuts?.filter(s => !!s.label && s.side === 'left').map((s, i) => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} + icon={showKeyboard ? undefined : GamepadIconMap[s.button]} + label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> + )} +
        + {data.centerElement} +
        + {shortcuts?.filter(s => !!s.label && s.side !== 'left').map((s, i) => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} + icon={showKeyboard ? undefined : GamepadIconMap[s.button]} + label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} /> + )} +
        + ); } diff --git a/src/mainview/components/SideFilters.tsx b/src/mainview/components/SideFilters.tsx new file mode 100644 index 0000000..930bf2b --- /dev/null +++ b/src/mainview/components/SideFilters.tsx @@ -0,0 +1,238 @@ +import { DownloadsLookupFilter, DownloadsLookupFilterValues, GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; +import { RoundButton } from "./RoundButton"; +import classNames from "classnames"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-navigation"; +import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; +import { sourceIconMap } from "./Constants"; +import { ContextList, DialogEntry } from "./ContextDialog"; +import { FrontEndFilterLists } from "@simeonradivoev/gameflow-sdk/shared"; +import { useContext } from 'react'; +import { GlobalDialogContext } from '../scripts/contexts'; + +function FilterButton (data: { + id: string, + filters?: GameListFilterType, + tooltip: string, + icon: any; + dialog: (focNewSourceFocusKey: string) => void; + isActive: boolean; +}) +{ + const handleAction = () => data.dialog(data.id); + useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]); + return
        + + {data.icon} + +
        ; +} + +export function SideDownloadFilters (data: { + id: string, + filters?: DownloadsLookupFilter; + setLocalFilter: (filter: DownloadsLookupFilter) => void, + localFilter: DownloadsLookupFilter, + filterValues: DownloadsLookupFilterValues | undefined; +}) +{ + + const { ref, focusKey } = useFocusable({ focusKey: data.id }); + const globalDialog = useContext(GlobalDialogContext); + const orderByDialog = (focusKey: string) => globalDialog.openContext({ + content: ({ + content: o, + selected: data.localFilter.orderBy === o, + id: `sort-by-${o}`, + type: 'primary', + action (ctx) + { + data.setLocalFilter({ ...data.localFilter, orderBy: o }); + ctx.close(); + }, + }))} />, + preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}` + }, focusKey); + + const orderDirectionDialog = (focusKey: string) => globalDialog.openContext({ + content: }, { label: 'desc', icon: }] + .map(o => ({ + content: o.label, + selected: data.localFilter.sortDirection === o.label, + icon: o.icon, + id: `sort-direction-${o.label}`, + type: 'primary', + action (ctx) + { + data.setLocalFilter({ ...data.localFilter, sortDirection: o.label as any }); + ctx.close(); + }, + })) + } />, + preferredChildFocusKey: `sort-direction-${data.localFilter.orderBy}` + }, focusKey); + + const sourceFilterDialog = (focusKey: string) => globalDialog.openContext({ + content: (o => ({ + content: o, + icon: sourceIconMap[o], + selected: data.localFilter.source === o, + id: `source-filter-${o}`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined }); + else data.setLocalFilter({ ...data.localFilter, source: o }); + ctx.close(); + }, + }))} />, + preferredChildFocusKey: `source-filter-${data.localFilter.source}` + }, focusKey); + + return
        + + } /> + } /> + + {!data.filters?.source && + } /> + } + + {Object.values(data.localFilter).some(v => v !== undefined) && + <> +
        + data.setLocalFilter({})} className='p-3 drop-shadow-md!' > + + } +
        +
        ; +} + +export default function SideFilters (data: { + id: string, + filters?: GameListFilterType; + setLocalFilter: (filter: GameListFilterType) => void, + localFilter: GameListFilterType, + filterValues: FrontEndFilterLists | undefined; +}) +{ + + const { ref, focusKey } = useFocusable({ focusKey: data.id }); + const globalDialog = useContext(GlobalDialogContext); + + const openSourceDialog = (focusKey: string) => + { + globalDialog.openContext({ + content: (o => ({ + content: o, + icon: sourceIconMap[o], + selected: data.localFilter.source === o, + id: `source-filter-${o}`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined }); + else data.setLocalFilter({ ...data.localFilter, source: o }); + ctx.close(); + }, + })).concat({ + content: "Local Only", + icon: , + selected: data.localFilter.localOnly === true, + id: `source-filter-local`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, localOnly: undefined }); + else data.setLocalFilter({ ...data.localFilter, localOnly: true }); + ctx.close(); + }, + })} />, preferredChildFocusKey: `source-filter-${data.localFilter.source}` + }, focusKey); + }; + + const openGenreDialog = (focusKey: string) => + { + globalDialog.openContext({ + content: ({ + content: g, + selected: data.localFilter.genres?.includes(g), + id: `genre-filter-${g}`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres?.filter(genre => genre !== g) ?? []] }); + else data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres ?? [], g] }); + ctx.close(); + }, + }))} /> + }, focusKey); + }; + + const openSortingDialog = (focusKey: string) => + { + globalDialog.openContext({ + content: }, + { stat: "activity", icon: }, + { stat: "added", icon: }, + { stat: "release", icon: }, + ] satisfies { stat: GameListFilterType['orderBy'], icon?: any; }[]) + .map(o => ({ + content: o.stat, + icon: o.icon, + selected: data.localFilter.orderBy === o.stat, + id: `sort-by-${o.stat}`, + type: 'primary', + action (ctx) + { + data.setLocalFilter({ ...data.localFilter, orderBy: o.stat }); + ctx.close(); + }, + }))} />, preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}` + }, focusKey); + }; + + const openAgeRatingDialog = (focusKey: string) => + { + globalDialog.openContext({ + content: ({ + content: a, + selected: data.localFilter.age_ratings?.includes(a), + id: `age-rating-filter-${a}`, + type: 'primary', + action (ctx) + { + if (ctx.selected) data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings?.filter(age => age !== a) ?? []] }); + else data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings ?? [], a] }); + ctx.close(); + }, + }))} /> + }, focusKey); + }; + + return
        + + } /> + 0} icon={} /> + 0} icon={} /> + {!data.filters?.source && + } /> + } + {Object.values(data.localFilter).some(v => v !== undefined) && + <> +
        + data.setLocalFilter({})} className='p-3 drop-shadow-md!' > + + } +
        +
        ; +} \ No newline at end of file diff --git a/src/mainview/components/StatList.tsx b/src/mainview/components/StatList.tsx index 3ff22f9..bdc9a02 100644 --- a/src/mainview/components/StatList.tsx +++ b/src/mainview/components/StatList.tsx @@ -29,7 +29,7 @@ export default function StatList (data: { return
          - {data.stats.map((s, i) => + {data.stats.flatMap((s, i) => { let content: any = undefined; if (s.content instanceof Array) @@ -37,13 +37,9 @@ export default function StatList (data: { content =
          {s.content.map((c, ci) => {c})}
          ; } else { - content =
          {s.icon}{s.content}
          ; + content =
          {s.icon}{s.content}
          ; } - const element = <> -
        ; diff --git a/src/mainview/components/SvgIcon.tsx b/src/mainview/components/SvgIcon.tsx index 66a5a26..e449d84 100644 --- a/src/mainview/components/SvgIcon.tsx +++ b/src/mainview/components/SvgIcon.tsx @@ -1,5 +1,6 @@ import "virtual:svg-icons/register"; import { StaticAssetPath } from "../gen/static-icon-assets.gen"; +import { CSSProperties } from "react"; type OnlySvgIcon = T extends `${infer Rest}.svg` ? Rest @@ -15,17 +16,19 @@ export default function SvgIcon ({ icon, prefix = "icon", className, + style, ...props }: { icon: IconType; prefix?: string; className?: string; + style?: CSSProperties; }) { const symbolId = `#${prefix}-${icon}`; return ( -
        + return
        diff --git a/src/mainview/components/game/Achievements.tsx b/src/mainview/components/game/Achievements.tsx index 9296403..9fbe814 100644 --- a/src/mainview/components/game/Achievements.tsx +++ b/src/mainview/components/game/Achievements.tsx @@ -1,4 +1,5 @@ +import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@simeonradivoev/gameflow-sdk/shared"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Medal } from "lucide-react"; diff --git a/src/mainview/components/game/ActionButton.tsx b/src/mainview/components/game/ActionButton.tsx index c0f3b78..7a6db5e 100644 --- a/src/mainview/components/game/ActionButton.tsx +++ b/src/mainview/components/game/ActionButton.tsx @@ -12,12 +12,11 @@ export default function ActionButton (data: { square?: boolean, onFocus?: () => void; tooltip?: string, - tooltip_type?: 'accent' | 'error'; - onAction?: () => void; + tooltipType?: 'accent' | 'error'; disabled?: boolean; -}) +} & InteractParams) { - const { ref } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true }); + const { ref, focusKey } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: () => data.onAction?.({ focusKey }), focusable: data.disabled !== true }); const styles = { primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary", base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary", @@ -29,9 +28,9 @@ export default function ActionButton (data: { +
        } + {lookups?.matches.map((l, i) => + { + return + { + data.onSelect(l); + }} />; + })} + + } +
      +
  • ; +} \ No newline at end of file diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 1e82ea1..6f772af 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -1,18 +1,20 @@ -import { Router } from "@/mainview"; import { rommApi } from "@/mainview/scripts/clientApi"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { JSX, useEffect, useRef, useState } from "react"; +import { JSX, useContext, useEffect, useRef, useState } from "react"; import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; import { useLocalStorage } from "usehooks-ts"; -import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; -import { Clock, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react"; +import { ContextList, DialogEntry } from "../ContextDialog"; +import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react"; import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm"; import ActionButton from "./ActionButton"; +import { useNavigate, UseNavigateResult, useRouter } from "@tanstack/react-router"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; +import { CommandEntry, FrontEndGameTypeDetailed, DownloadSourceType } from "@simeonradivoev/gameflow-sdk/shared"; +import { GlobalDialogContext } from "@/mainview/scripts/contexts"; -export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) +export function usePlayMutation (navigate: UseNavigateResult) { - const installMut = useMutation(installMutation(data.source, data.id)); const playMut = useMutation({ ...playMutation, onError (error) { @@ -20,14 +22,42 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so }, onSuccess (data, { source, id }, onMutateResult, context) { - Router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id }, replace: true }); + navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } }); }, }); + + return playMut; +} + +export function playGame (source: string, id: string, cmd: CommandEntry, navigate: UseNavigateResult, playMutation: (options: { source: string, id: string, command_id: string | number; }) => void) +{ + if (cmd.emulator === 'EMULATORJS') + { + const params = new URLSearchParams(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command); + navigate({ to: '/embedded/$source/$id', params: { source: source, id: id }, search: Object.fromEntries(params.entries()) }); + } else + { + playMutation({ source: source, id: id, command_id: cmd.id }); + } +} + +export default function MainActions (data: { + game?: FrontEndGameTypeDetailed, + source: string, + id: string; +}) +{ + const installMut = useMutation(installMutation(data.source, data.id)); + const router = useRouter(); + + const navigate = useNavigate(); + const globalDialog = useContext(GlobalDialogContext); const ws = useRef<{ send: (data: string) => void; }>(undefined); const [progress, setProgress] = useState(undefined); const [status, setStatus] = useState(undefined); const [error, setError] = useState(undefined); const [details, setDetails] = useState(undefined); + const [installSources, setInstallSources] = useState(undefined); const [commands, setCommands] = useState(undefined); const [preferredCommand, setPreferredCommand] = useLocalStorage(`${data.game?.source ?? data.game?.id.source}-${data.game?.source_id ?? data.game?.id.id}-preferred-command`, undefined); const queryClient = useQueryClient(); @@ -38,7 +68,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so if (preferredCommand && c.id !== preferredCommand) return false; return true; }); - + const playMut = usePlayMutation(navigate); useEffect(() => { const sub = rommApi.api.romm.status({ source: data.source })({ id: data.id }).subscribe(); @@ -50,6 +80,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so setProgress((e.data as any).progress); setDetails((e.data as any).details); setCommands((e.data as any).commands); + setInstallSources((e.data as any).sources); if (e.data.status === 'refresh') { @@ -58,17 +89,16 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { if (localId) { - Router.navigate({ to: '/game/$source/$id', params: { id: String(localId), source: 'local' }, replace: true }); + router.navigate({ to: '/game/$source/$id', params: { id: String(localId), source: 'local' }, replace: true }); } else { - Router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true }); + router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true }); } }); } else if (e.data.status === 'error') { const errorMessage = getErrorMessage(e.data.error); if (!errorMessage) return; - toast.error(errorMessage); setError(errorMessage); } }); @@ -78,7 +108,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so sub.close(); ws.current = undefined; }; - }, [data.source, data.id]); + }, [data.source, data.id, router]); let progressIcon: JSX.Element | undefined = undefined; switch (status) @@ -95,56 +125,63 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so } const showProgress = progress !== null && !!progressIcon; - useEffect(() => - { - if (showProgress) return; - showInstallOptions(false); - }, [showProgress]); - const handlePlay = (cmd?: CommandEntry) => - { - if (!cmd) return; - if (cmd.emulator === 'EMULATORJS') - { - const params = new URLSearchParams(cmd.command); - Router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true }); - } else - { - playMut.mutate({ source: data.source, id: data.id, command_id: cmd.id }); - } - }; + let mainButton: any | undefined = undefined; + let showAllCommandsAction: ((focusKey: string) => void) | undefined; + let mainAction: () => void; if (status === 'installed') { - mainButton =
    handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details} - key="primary" - type='primary' - id="mainAction" - > - + if (validCommands.length > 1) showAllCommandsAction = (focusKey) => globalDialog.openContext({ + content: + { + const commands: DialogEntry = { + id: String(c.id), + content: c.label ?? "", + type: 'primary', + selected: preferredCommand !== undefined ? preferredCommand === c.id : i === 0, + action (ctx) + { + setPreferredCommand(c.id); + playGame(data.source, data.id, c, navigate, playMut.mutate); + }, + }; + return commands; + })} />, + preferredChildFocusKey: String(preferredCommand) + }, focusKey); + mainAction = () => validDefaultCommand ? playGame(data.source, data.id, validDefaultCommand, navigate, playMut.mutate) : undefined; + mainButton =
    + + - + - {validCommands.length > 1 && - showAllCommands(true, 'allActionsBtn')}> + {showAllCommandsAction && + showAllCommandsAction!('allActionsBtn')}> }
    ; } else if (error) { + mainAction = () => + { + if (status === 'missing-emulator') + { + router.navigate({ to: '/settings/directories' }); + } + }; mainButton = - { - if (status === 'missing-emulator') - { - Router.navigate({ to: '/settings/directories' }); - } - }} + onAction={mainAction} id="mainAction"> ; @@ -154,24 +191,47 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so let icon = ; if (status === 'install') { - icon = ; + if (installSources && installSources.length > 1) + icon = ; + else + icon = ; + } else if (status === 'present') { icon = ; } + mainAction = () => + { + if (installMut.isPending) return; + switch (status) + { + case 'present': + case 'install': + if (installSources && installSources.length > 1) + { + globalDialog.openContext({ + content: ({ + content: s.name, + action (ctx) + { + installMut.mutate({ downloadId: s.id }); + ctx.close(); + }, + type: 'primary', + id: s.id + } satisfies DialogEntry)) ?? []} /> + }, 'mainAction'); + } else + { + installMut.mutate({}); + } + + break; + } + }; mainButton = - { - if (installMut.isPending) return; - switch (status) - { - case 'present': - case 'install': - installMut.mutate(); - break; - } - }} + onAction={mainAction} tooltip={details ?? status} type='primary' id="mainAction"> @@ -179,42 +239,42 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so ; } - const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', { - content: - { - const commands: DialogEntry = { - id: String(c.id), - content: c.label ?? "", - type: 'primary', - selected: preferredCommand !== undefined ? preferredCommand === c.id : i === 0, - action (ctx) - { - setPreferredCommand(c.id); - handlePlay(c); - }, - }; - return commands; - })} />, - preferredChildFocusKey: String(preferredCommand) - }); + useShortcuts('mainAction', () => + { + const shortcuts: Shortcut[] = [{ + button: GamePadButtonCode.A, + action: mainAction + }]; - const { dialog: installOptionsDialog, setOpen: showInstallOptions } = useContextDialog('install-options-dialog', { - content: - }); + if (showAllCommandsAction) + shortcuts.push( + { + button: GamePadButtonCode.Y, + label: "All Commands", + action (e) + { + showAllCommandsAction('mainAction'); + }, + }); + + return shortcuts; + }, [showAllCommandsAction, mainAction]); return
    {mainButton}
    - {showProgress && showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" > + {showProgress && globalDialog.openContext({ + content: + }, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
    {progressIcon} @@ -222,7 +282,5 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
    } - {installOptionsDialog} - {allCommandDialog}
    ; } \ No newline at end of file diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index 1c6e63c..e131123 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -7,11 +7,12 @@ import import classNames from "classnames"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { CSSProperties } from "react"; +import { oneShot } from "@/mainview/scripts/audio/audio"; export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; const styles = { - base: 'dark:bg-base-200 light:bg-base-300 text-base-content active:not-disabled:bg-base-300! active:not-disabled:text-base-content! active:not-disabled:ring-offset-base-content', + base: 'dark:bg-base-200 light:bg-base-100 text-base-content active:not-disabled:bg-base-300! active:not-disabled:text-base-content! active:not-disabled:ring-offset-base-content', accent: "bg-accent text-accent-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:ring-offset-accent", primary: "bg-primary text-primary-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-primary", secondary: "bg-secondary text-secondary-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-secondary", @@ -21,44 +22,62 @@ const styles = { error: "bg-error text-error-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:not-disabled:ring-offset-error", }; +const externalStyles = { + base: '', + accent: "focusable-accent", + primary: "focusable-primary", + secondary: "focusable-secondary", + info: "focusable-info", + success: "focusable-success", + warning: "focusable-warning", + error: "focusable-error", +}; + export function Button (data: { id: string, children?: any, className?: string, disabled?: boolean, + external?: boolean, type?: "reset" | "button" | "submit"; style?: ButtonStyle, shortcutLabel?: string; focusClassName?: string; cssStyle?: CSSProperties; tooltip?: string; - tooltipType?: "base" | "accent" | "error"; + tooltipType?: "base" | "accent" | "error" | "warning"; } & InteractParams & FocusParams) { + const handleAction = (event?: Event) => + { + data.onAction?.({ event, focusKey }); + oneShot('click'); + }; const { ref, focused, focusKey } = useFocusable({ focusKey: data.id, - onEnterPress: data.onAction, + onEnterPress: () => handleAction(), onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details), focusable: !data.disabled }); if (data.shortcutLabel) { - useShortcuts(focusKey, () => [{ label: data.shortcutLabel, action: data.onAction, button: GamePadButtonCode.A }], [data.shortcutLabel]); + useShortcuts(focusKey, () => [{ label: data.shortcutLabel, action: handleAction, button: GamePadButtonCode.A }], [data.shortcutLabel]); } return
    }> + {!!schema.enum && String(v))} icon={data.icon} name={data.id ?? ""} - type={data.type} placeholder={data.placeholder} defaultValue={localValue} onChange={(v) => { - if (data.type === 'checkbox') - { - setLocalValue(v); - } else - { - setLocalValue(v); - } + setLocalValue(v); }} value={localValue} />} - {data.type !== 'dropdown' && { - if (data.type === 'checkbox') - { - setLocalValue(v); - } else - { - setLocalValue(v); - } + setLocalValue(v); }} value={localValue} />} diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index 083b6c4..e2ed246 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -1,13 +1,13 @@ -import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useState } from "react"; +import { FocusEventHandler, HTMLInputAutoCompleteAttribute, JSX, useState } from "react"; import { twMerge } from "tailwind-merge"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog"; import { ChevronDown } from "lucide-react"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; +import { oneShot } from "@/mainview/scripts/audio/audio"; export function OptionDropdown (data: { name: string; - type: HTMLInputTypeAttribute; className?: string; placeholder?: string; icon?: JSX.Element; @@ -23,6 +23,7 @@ export function OptionDropdown (data: { const handlePress = () => { setOpen(true); + oneShot('click'); }; const handleClose = () => setOpen(false); const { ref } = useFocusable({ @@ -33,11 +34,7 @@ export function OptionDropdown (data: { <> {open && ({ diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 1f43246..801e639 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -1,9 +1,10 @@ -import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react"; +import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useEffect, useRef, useState } from "react"; import { twMerge } from "tailwind-merge"; import { useOptionContext } from "./OptionSpace"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { systemApi } from "../../scripts/clientApi"; import { CheckIcon, X } from "lucide-react"; +import { oneShot } from "@/mainview/scripts/audio/audio"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; export function OptionInput (data: { name: string; @@ -11,11 +12,15 @@ export function OptionInput (data: { className?: string; placeholder?: string; icon?: JSX.Element; - value?: string | boolean; - defaultValue?: string | boolean; + value?: string | boolean | number; + min?: number; + max?: number; + step?: number; + defaultValue?: string | boolean | number; autocomplete?: HTMLInputAutoCompleteAttribute; + compact?: boolean; onBlur?: FocusEventHandler; - onChange?: (value: any) => void; + onChange?: (value: string | number | boolean) => void; }) { const handlePress = () => @@ -27,48 +32,119 @@ export function OptionInput (data: { { inputRef.current?.focus(); } + oneShot('click'); }; - const { ref } = useFocusable({ - focusKey: data.name, onEnterPress: handlePress - }); + const [inputFocused, setInputFocused] = useState(false); const inputRef = useRef(null); + const { ref, focusKey } = useFocusable({ + focusKey: data.name, + onEnterPress: handlePress, + onBlur: () => inputRef.current?.blur() + }); + const option = useOptionContext({ onOptionEnterPress: handlePress, }); - const handleFocus = () => + + useEffect(() => { - option.focus(); - if (inputRef.current) + if (data.type === 'range') { - var rect = inputRef.current?.getBoundingClientRect(); - systemApi.api.system.show_keyboard.post({ - XPosition: rect.x, - YPosition: rect.y, - Width: rect.width, - Height: rect.height + option.setFocusBoundary(inputFocused); + option.setFocusBoundaryDirections(['left', 'right']); + } + }, [inputFocused, option, data.type]); + + useShortcuts(focusKey, () => + { + + const shortcuts: Shortcut[] = []; + if (inputFocused && data.type === 'range') + { + shortcuts.push( + { + label: "Decrease", + button: GamePadButtonCode.Left, + action () + { + if (!inputRef.current) return; + inputRef.current?.stepDown(); + data.onChange?.(inputRef.current.valueAsNumber); + } + }, + { + label: "Increase", + button: GamePadButtonCode.Right, + action (e) + { + if (!inputRef.current) return; + inputRef.current?.stepUp(); + data.onChange?.(inputRef.current.valueAsNumber); + } + } + ); + } + if (inputFocused) + { + shortcuts.push({ + label: "Unfocus", + button: GamePadButtonCode.B, + action (e) + { + inputRef.current?.blur(); + } }); } + return shortcuts; + }, [inputFocused, data.type]); + + const handleInputFocus: FocusEventHandler = (e) => + { + option.focus(); + setInputFocused(true); + }; + + const handleInputBlur = (e: any) => + { + data.onBlur?.(e); + setInputFocused(false); }; return ( -
    } -
    +
    {data.children}
    diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 29ba634..7b2789f 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -1,5 +1,4 @@ import { HTMLInputTypeAttribute, JSX, useEffect, useState } from "react"; -import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; @@ -9,11 +8,12 @@ import { ContextDialog } from "../ContextDialog"; import FilePicker from "../FilePicker"; import { setFocus } from "@noriginmedia/norigin-spatial-navigation"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; +import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared"; export interface PathSettingsOptionParams { label: string; - id: KeysWithValueAssignableTo; + id: string; type: HTMLInputTypeAttribute; placeholder?: string; icon?: JSX.Element; @@ -24,10 +24,11 @@ export interface PathSettingsOptionParams allowNewFolderCreation?: boolean; } -export function PathSettingsOption (data: PathSettingsOptionParams) +export function PathSettingsOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo; }) { const [localValue, setLocalValue] = useState(); const [dirty, setDirty] = useState(false); + const { data: defaultValue } = useQuery(getSettingQuery(data.id)); const setMutation = useMutation({ ...setSettingMutation(data.id), onSuccess: (d, v, r, cx) => @@ -44,6 +45,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams) save={setMutation.mutate} localValue={localValue} allowNewFolderCreation={data.allowNewFolderCreation} + defaultValue={defaultValue as any} setLocalValue={(v) => { setLocalValue(v); @@ -56,16 +58,17 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { localValue: string | undefined; setLocalValue: (value: string | undefined) => void; isDirty: boolean; + className?: string; + defaultValue: string | undefined; }) { const [isBrowsing, setIsBrowsing] = useState(false); - const { data: defaultValue } = useQuery(getSettingQuery(data.id)); - const changed = defaultValue !== data.localValue; + const changed = data.defaultValue !== data.localValue; useEffect(() => { - data.setLocalValue(String(defaultValue)); - }, [defaultValue]); + data.setLocalValue(String(data.defaultValue ?? '')); + }, [data.defaultValue]); const handleSelectPath = (path: string) => { @@ -80,7 +83,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { const handleCloseSeatch = () => { setIsBrowsing(false); - setFocus(`${data.id}-browse`); + setFocus(`${data.id}-browse`, { instant: true }); }; const handleInputBlur = () => @@ -92,7 +95,8 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & { }; return ( - {data.label}{changed && }}> + {data.label}{changed && }}> + { - data.setLocalValue(e); + data.setLocalValue(String(e)); }} value={data.localValue} /> - {data.requireConfirmation === true &&
    ; } export function EmulatorsSection (data: { id: string; emulators?: FrontEndEmulator[]; - onSelect?: (id: string, focusKey: string) => void; + onSelect?: (em: FrontEndEmulator, focusKey: string) => void; header?: any; } & FocusParams) { + const router = useRouter(); const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.EMULATOR_SECTION(data.id), trackChildren: true, @@ -63,12 +65,12 @@ export function EmulatorsSection (data: { {data.emulators?.map((em) => ( - data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) => + data.onSelect?.(em, focusKey)} onFocus={({ node, details }) => { scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' }); }} /> )) ?? Array.from({ length: 8 }).map((_, i) =>
    )} - Router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} /> + router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} /> diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index 54f4057..ccdd076 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -10,6 +10,7 @@ import FrontEndGameCard from "../FrontEndGameCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import Carousel from "../Carousel"; import { twMerge } from "tailwind-merge"; +import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export function GamesSection (data: { games?: FrontEndGameType[]; @@ -30,7 +31,7 @@ export function GamesSection (data: { useEffect(() => { if (focused) - focusSelf(); + focusSelf({ instant: true }); }, [!!data.games]); return ( @@ -44,7 +45,7 @@ export function GamesSection (data: { {data.games?.map((g, i) => data.onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id))} onFocus={(key, node, details) => scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' })} diff --git a/src/mainview/components/store/InvalidStoreError.tsx b/src/mainview/components/store/InvalidStoreError.tsx new file mode 100644 index 0000000..646721d --- /dev/null +++ b/src/mainview/components/store/InvalidStoreError.tsx @@ -0,0 +1,10 @@ +import { ErrorComponentProps } from "@tanstack/react-router"; +import { TriangleAlert } from "lucide-react"; + +export default function Error (data: ErrorComponentProps) +{ + return
    +
    Invalid Store. Update App.
    +
    {data.error.message}
    +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/store/MissingEmulatorsSection.tsx b/src/mainview/components/store/MissingEmulatorsSection.tsx index b064ec8..6339dde 100644 --- a/src/mainview/components/store/MissingEmulatorsSection.tsx +++ b/src/mainview/components/store/MissingEmulatorsSection.tsx @@ -3,30 +3,33 @@ import useFocusable, FocusContext, } from "@noriginmedia/norigin-spatial-navigation"; -import { Button } from "../options/Button"; -import useActiveControl from "@/mainview/scripts/gamepads"; -import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react"; +import { CircleQuestionMark, SearchAlert } from "lucide-react"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { RPC_URL } from "@/shared/constants"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; +import { oneShot } from "@/mainview/scripts/audio/audio"; +import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared"; // ── Single missing-emulator card ─────────────────────────────────────────── interface MissingCardProps { emulator: FrontEndEmulator; - onSelect?: (id: string, focusKey: string) => void; + onSelect?: (em: FrontEndEmulator, focusKey: string) => void; } function MissingCard ({ emulator: em, onSelect }: MissingCardProps) { - const handleSelect = () => onSelect?.(em.name, focusKey); + const handleSelect = () => + { + onSelect?.(em, focusKey); + oneShot('click'); + }; const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.MISSING_CARD(em.name), onEnterPress: handleSelect, }); useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]); - const { isMouse } = useActiveControl(); return (
    e.key === "Enter" && handleSelect} - className={"focusable focusable-accent bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"} + className={"focusable focusable-accent focusable-hover cursor-pointer bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"} >
    @@ -52,10 +55,6 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)

    {em.systems?.map(s => s.name).join(',')}

    -
    -

    {em.name}

    - {isMouse && } -
    ); @@ -66,7 +65,7 @@ export function MissingEmulatorsSection ({ onSelect, }: { emulators: FrontEndEmulator[]; - onSelect?: (id: string, focusKey: string) => void; + onSelect?: (em: FrontEndEmulator, focusKey: string) => void; }) { const { ref, focusKey } = useFocusable({ diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index ccf81cb..3479579 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -1,14 +1,20 @@ import { twMerge } from "tailwind-merge"; import { RPC_URL } from "@/shared/constants"; -import { Button } from "../options/Button"; -import useActiveControl from "@/mainview/scripts/gamepads"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { BadgeCheck, ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Sparkles, Store, WandSparkles } from "lucide-react"; +import { CircleFadingArrowUp, FileQuestion, IceCream2, Package, Store, WandSparkles } from "lucide-react"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; -import { JSX } from "react"; +import { JSX, useContext } from "react"; +import { oneShot } from "@/mainview/scripts/audio/audio"; +import { useQuery } from "@tanstack/react-query"; +import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store"; +import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared"; +import { rommApi } from "@/mainview/scripts/clientApi"; +import { useNavigate } from "@tanstack/react-router"; +import { GlobalDialogContext } from "@/mainview/scripts/contexts"; +import { ContextList, DialogEntry } from "../ContextDialog"; export const emulatorStatusIcons: Record = { store: , @@ -26,7 +32,12 @@ export function StoreEmulatorCard (data: { className?: string; }) { - const handleSelect = () => data.onSelect?.(data.emulator.name, focusKey); + const navigate = useNavigate(); + const handleSelect = () => + { + data.onSelect?.(data.emulator.name, focusKey); + oneShot('click'); + }; const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.EMULATOR_CARD(data.id), @@ -37,17 +48,44 @@ export function StoreEmulatorCard (data: { } }); - useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]); - const { isMouse, isTouch } = useActiveControl(); + const { data: updateInfo } = useQuery(getUpdateInfoForEmulator(data.emulator.name)); + + const globalDialogContext = useContext(GlobalDialogContext); + useShortcuts(focusKey, () => [{ + button: GamePadButtonCode.A, + label: "Details", + action: handleSelect + + }, { + button: GamePadButtonCode.Y, + label: "Launch Emulator", + action: e => + { + const entries: DialogEntry[] = data.emulator.validSources.filter(s => s.exists).map(s => ({ + content: `Launch: ${s.type}`, + type: 'primary', + icon: emulatorStatusIcons[s.type], + action (ctx) + { + if (!data.emulator) return; + rommApi.api.romm.game({ source: 'emulator' })({ id: data.emulator.name }).play.post({ command_id: s.type }); + ctx.close(); + navigate({ to: '/launcher/$source/$id', params: { source: 'emulator', id: data.emulator.name } }); + }, id: `open-${s.type}` + } satisfies DialogEntry)); + globalDialogContext.openContext({ content: }, focusKey); + } + }], [handleSelect]); return (
    s.exists)} - onClick={isTouch ? handleSelect : undefined} - className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)} + onClick={handleSelect} + className={twMerge("relative focusable focusable-info focusable-hover bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none cursor-pointer", data.className)} >
    @@ -75,21 +113,27 @@ export function StoreEmulatorCard (data: {
    - {!!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') &&
    -
    + {updateInfo?.hasUpdate &&
    +
    + +
    } - {data.emulator.validSources.slice(0, 3).map(s => + {data.emulator.integrations.length > 0 &&
    i.supportLevel)} + data-full-support={data.emulator.integrations.some(i => i.supportLevel === 'full')} + className="tooltip not-aria-disabled:tooltip-primary" + data-tip={data.emulator.integrations.some(i => i.supportLevel) ? data.emulator.integrations.some(i => i.supportLevel === 'full') ? "Full Support" : "Partial SUpport" : "Can Integrate"} + > +
    +
    } + {data.emulator.validSources.slice(0, 3).map((s, i) => { - return
    -
    + return
    +
    {emulatorStatusIcons[s.type]}
    ; })} - {isMouse && <> - - } -
    diff --git a/src/mainview/emulatorjs/emulator.ts b/src/mainview/emulatorjs/emulator.ts index 61e570b..48ba808 100644 --- a/src/mainview/emulatorjs/emulator.ts +++ b/src/mainview/emulatorjs/emulator.ts @@ -10,10 +10,11 @@ Array.from(params.entries()).forEach(([key, value]) => window.addEventListener('message', (e) => { - switch (e.data.type) + const data = e.data as EmulatorJsMessage; + switch (data.type) { case 'pause': - if (e.data.data === true) + if (data.paused) { window.EJS_emulator.pause(); } else @@ -24,14 +25,51 @@ window.addEventListener('message', (e) => case 'restart': window.EJS_emulator.elements.bottomBar.restart[0].click(); break; + case 'requestSave': + window.EJS_emulator.elements.bottomBar.saveSavFiles[0].click(); + break; } }); -window.EJS_threads = true; +function postMessage (m: EmulatorJsMessage) +{ + window.parent.postMessage( + m, + "*" + ); +} + +export function loadEmulatorJSSave (save: Uint8Array) +{ + const FS = window.EJS_emulator.gameManager.FS; + const path = window.EJS_emulator.gameManager.getSaveFilePath(); + const paths = path.split("/"); + let cp = ""; + for (let i = 0; i < paths.length - 1; i++) + { + if (paths[i] === "") continue; + cp += "/" + paths[i]; + if (!FS.analyzePath(cp).exists) FS.mkdir(cp); + } + if (FS.analyzePath(path).exists) FS.unlink(path); + FS.writeFile(path, save); + window.EJS_emulator.gameManager.loadSaveFiles(); +} + +window.EJS_threads = !__PUBLIC__; window.EJS_player = "#game"; window.EJS_lightgun = false; window.EJS_startOnLoaded = true; +window.EJS_onGameStart = async () => +{ + const savesResponse = await fetch(`${RPC_URL(__HOST__)}/api/romm/emulatorjs/load?filePath=${encodeURIComponent(window.EJS_emulator.gameManager.getSaveFilePath())}`); + if (savesResponse.ok) + { + loadEmulatorJSSave(new Uint8Array(await savesResponse.arrayBuffer())); + postMessage({ type: "loaded" }); + } +}; // For core downloads, it either redirects to CDN or uses local if downloaded window.EJS_pathtodata = `${RPC_URL(__HOST__)}/api/romm/emulatorjs/data`; window.EJS_Buttons = { @@ -40,10 +78,8 @@ window.EJS_Buttons = { displayName: "Exit", callback: () => { - window.parent.postMessage( - { type: "exit" }, - "*" - ); + const saveFile = window.EJS_emulator.gameManager.getSaveFile(false); + postMessage({ type: "exit", save: saveFile ? new File([saveFile], window.EJS_emulator.gameManager.getSaveFilePath()) : undefined }); } } }; @@ -58,7 +94,18 @@ const moduleUrls = import.meta.glob import: 'default', }); +function handeSave (ctx: { save: ArrayBuffer, screenshot: ArrayBuffer | undefined, format: string; }) +{ + window.parent.postMessage({ type: 'save', save: new File([ctx.save], window.EJS_emulator.gameManager.getSaveFilePath()) }); +} + // emulatorjs expects basenames instead of paths for some reason window.EJS_paths = Object.fromEntries(await Promise.all(Object.entries(moduleUrls).map(async ([key, value]) => [basename(key), await value()]))); +window.EJS_onSaveUpdate = (ctx: { hash: string, save: ArrayBuffer, screenshot: ArrayBuffer | undefined, format: string; }) => handeSave(ctx); +window.EJS_onSaveSave = (ctx: { + save: ArrayBuffer; + screenshot: ArrayBuffer; + format: string; +}) => handeSave(ctx); await import('@emulatorjs/emulatorjs/data/loader.js' as any); \ No newline at end of file diff --git a/src/mainview/emulatorjs/types.d.ts b/src/mainview/emulatorjs/types.d.ts index 11b8f1f..4021f5d 100644 --- a/src/mainview/emulatorjs/types.d.ts +++ b/src/mainview/emulatorjs/types.d.ts @@ -14,6 +14,7 @@ export declare global EJS_cheats: string[][], EJS_fullscreenOnLoaded: boolean, EJS_startOnLoaded: boolean, + EJS_onGameStart, EJS_core: string, EJS_lightgun: boolean, EJS_biosUrl: string, @@ -56,7 +57,9 @@ export declare global EJS_browserMode, EJS_shaders, EJS_fixedSaveInterval, + EJS_onSaveUpdate, EJS_disableAutoUnload, EJS_disableBatchBootup; + EJS_onSaveSave; } } \ No newline at end of file diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index 5988dfd..89c6bc9 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -12,22 +12,31 @@ import { Route as rootRouteImport } from './../routes/__root' import { Route as GamesRouteImport } from './../routes/games' import { Route as SettingsRouteRouteImport } from './../routes/settings/route' import { Route as IndexRouteImport } from './../routes/index' +import { Route as SettingsUpdateRouteImport } from './../routes/settings/update' +import { Route as SettingsTasksRouteImport } from './../routes/settings/tasks' import { Route as SettingsPluginsRouteImport } from './../routes/settings/plugins' import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface' import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators' import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories' import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts' import { Route as SettingsAboutRouteImport } from './../routes/settings/about' +import { Route as GameAddRouteImport } from './../routes/game/add' import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route' import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index' +import { Route as StoreTabPluginsRouteImport } from './../routes/store/tab/plugins' import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games' import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators' +import { Route as StoreTabDownloadRouteImport } from './../routes/store/tab/download' +import { Route as SettingsPluginSourceRouteImport } from './../routes/settings/plugin.$source' import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id' import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id' import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id' import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id' import { Route as CollectionSourceIdRouteImport } from './../routes/collection.$source.$id' +import { Route as StoreDetailsPluginIdRouteImport } from './../routes/store/details.plugin.$id' import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id' +import { Route as GameUpdateSourceIdRouteImport } from './../routes/game/update.$source.$id' +import { Route as StoreDetailsDownloadSourceIdRouteImport } from './../routes/store/details.download.$source.$id' const GamesRoute = GamesRouteImport.update({ id: '/games', @@ -44,6 +53,16 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const SettingsUpdateRoute = SettingsUpdateRouteImport.update({ + id: '/update', + path: '/update', + getParentRoute: () => SettingsRouteRoute, +} as any) +const SettingsTasksRoute = SettingsTasksRouteImport.update({ + id: '/tasks', + path: '/tasks', + getParentRoute: () => SettingsRouteRoute, +} as any) const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ id: '/plugins', path: '/plugins', @@ -74,6 +93,11 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({ path: '/about', getParentRoute: () => SettingsRouteRoute, } as any) +const GameAddRoute = GameAddRouteImport.update({ + id: '/game/add', + path: '/game/add', + getParentRoute: () => rootRouteImport, +} as any) const StoreTabRouteRoute = StoreTabRouteRouteImport.update({ id: '/store/tab', path: '/store/tab', @@ -84,6 +108,11 @@ const StoreTabIndexRoute = StoreTabIndexRouteImport.update({ path: '/', getParentRoute: () => StoreTabRouteRoute, } as any) +const StoreTabPluginsRoute = StoreTabPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', + getParentRoute: () => StoreTabRouteRoute, +} as any) const StoreTabGamesRoute = StoreTabGamesRouteImport.update({ id: '/games', path: '/games', @@ -94,6 +123,16 @@ const StoreTabEmulatorsRoute = StoreTabEmulatorsRouteImport.update({ path: '/emulators', getParentRoute: () => StoreTabRouteRoute, } as any) +const StoreTabDownloadRoute = StoreTabDownloadRouteImport.update({ + id: '/download', + path: '/download', + getParentRoute: () => StoreTabRouteRoute, +} as any) +const SettingsPluginSourceRoute = SettingsPluginSourceRouteImport.update({ + id: '/plugin/$source', + path: '/plugin/$source', + getParentRoute: () => SettingsRouteRoute, +} as any) const PlatformSourceIdRoute = PlatformSourceIdRouteImport.update({ id: '/platform/$source/$id', path: '/platform/$source/$id', @@ -119,52 +158,86 @@ const CollectionSourceIdRoute = CollectionSourceIdRouteImport.update({ path: '/collection/$source/$id', getParentRoute: () => rootRouteImport, } as any) +const StoreDetailsPluginIdRoute = StoreDetailsPluginIdRouteImport.update({ + id: '/store/details/plugin/$id', + path: '/store/details/plugin/$id', + getParentRoute: () => rootRouteImport, +} as any) const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({ id: '/store/details/emulator/$id', path: '/store/details/emulator/$id', getParentRoute: () => rootRouteImport, } as any) +const GameUpdateSourceIdRoute = GameUpdateSourceIdRouteImport.update({ + id: '/game/update/$source/$id', + path: '/game/update/$source/$id', + getParentRoute: () => rootRouteImport, +} as any) +const StoreDetailsDownloadSourceIdRoute = + StoreDetailsDownloadSourceIdRouteImport.update({ + id: '/store/details/download/$source/$id', + path: '/store/details/download/$source/$id', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren + '/game/add': typeof GameAddRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/settings/tasks': typeof SettingsTasksRoute + '/settings/update': typeof SettingsUpdateRoute '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute + '/settings/plugin/$source': typeof SettingsPluginSourceRoute + '/store/tab/download': typeof StoreTabDownloadRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute + '/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab/': typeof StoreTabIndexRoute + '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute + '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute + '/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute + '/game/add': typeof GameAddRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/settings/tasks': typeof SettingsTasksRoute + '/settings/update': typeof SettingsUpdateRoute '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute + '/settings/plugin/$source': typeof SettingsPluginSourceRoute + '/store/tab/download': typeof StoreTabDownloadRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute + '/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab': typeof StoreTabIndexRoute + '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute + '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute + '/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -172,21 +245,30 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteRouteWithChildren '/games': typeof GamesRoute '/store/tab': typeof StoreTabRouteRouteWithChildren + '/game/add': typeof GameAddRoute '/settings/about': typeof SettingsAboutRoute '/settings/accounts': typeof SettingsAccountsRoute '/settings/directories': typeof SettingsDirectoriesRoute '/settings/emulators': typeof SettingsEmulatorsRoute '/settings/interface': typeof SettingsInterfaceRoute '/settings/plugins': typeof SettingsPluginsRoute + '/settings/tasks': typeof SettingsTasksRoute + '/settings/update': typeof SettingsUpdateRoute '/collection/$source/$id': typeof CollectionSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute + '/settings/plugin/$source': typeof SettingsPluginSourceRoute + '/store/tab/download': typeof StoreTabDownloadRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute + '/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab/': typeof StoreTabIndexRoute + '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute + '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute + '/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -195,62 +277,89 @@ export interface FileRouteTypes { | '/settings' | '/games' | '/store/tab' + | '/game/add' | '/settings/about' | '/settings/accounts' | '/settings/directories' | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/tasks' + | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' | '/platform/$source/$id' + | '/settings/plugin/$source' + | '/store/tab/download' | '/store/tab/emulators' | '/store/tab/games' + | '/store/tab/plugins' | '/store/tab/' + | '/game/update/$source/$id' | '/store/details/emulator/$id' + | '/store/details/plugin/$id' + | '/store/details/download/$source/$id' fileRoutesByTo: FileRoutesByTo to: | '/' | '/settings' | '/games' + | '/game/add' | '/settings/about' | '/settings/accounts' | '/settings/directories' | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/tasks' + | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' | '/platform/$source/$id' + | '/settings/plugin/$source' + | '/store/tab/download' | '/store/tab/emulators' | '/store/tab/games' + | '/store/tab/plugins' | '/store/tab' + | '/game/update/$source/$id' | '/store/details/emulator/$id' + | '/store/details/plugin/$id' + | '/store/details/download/$source/$id' id: | '__root__' | '/' | '/settings' | '/games' | '/store/tab' + | '/game/add' | '/settings/about' | '/settings/accounts' | '/settings/directories' | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/tasks' + | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' | '/game/$source/$id' | '/launcher/$source/$id' | '/platform/$source/$id' + | '/settings/plugin/$source' + | '/store/tab/download' | '/store/tab/emulators' | '/store/tab/games' + | '/store/tab/plugins' | '/store/tab/' + | '/game/update/$source/$id' | '/store/details/emulator/$id' + | '/store/details/plugin/$id' + | '/store/details/download/$source/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -258,12 +367,16 @@ export interface RootRouteChildren { SettingsRouteRoute: typeof SettingsRouteRouteWithChildren GamesRoute: typeof GamesRoute StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren + GameAddRoute: typeof GameAddRoute CollectionSourceIdRoute: typeof CollectionSourceIdRoute EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute GameSourceIdRoute: typeof GameSourceIdRoute LauncherSourceIdRoute: typeof LauncherSourceIdRoute PlatformSourceIdRoute: typeof PlatformSourceIdRoute + GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute + StoreDetailsPluginIdRoute: typeof StoreDetailsPluginIdRoute + StoreDetailsDownloadSourceIdRoute: typeof StoreDetailsDownloadSourceIdRoute } declare module '@tanstack/react-router' { @@ -289,6 +402,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/settings/update': { + id: '/settings/update' + path: '/update' + fullPath: '/settings/update' + preLoaderRoute: typeof SettingsUpdateRouteImport + parentRoute: typeof SettingsRouteRoute + } + '/settings/tasks': { + id: '/settings/tasks' + path: '/tasks' + fullPath: '/settings/tasks' + preLoaderRoute: typeof SettingsTasksRouteImport + parentRoute: typeof SettingsRouteRoute + } '/settings/plugins': { id: '/settings/plugins' path: '/plugins' @@ -331,6 +458,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsAboutRouteImport parentRoute: typeof SettingsRouteRoute } + '/game/add': { + id: '/game/add' + path: '/game/add' + fullPath: '/game/add' + preLoaderRoute: typeof GameAddRouteImport + parentRoute: typeof rootRouteImport + } '/store/tab': { id: '/store/tab' path: '/store/tab' @@ -345,6 +479,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StoreTabIndexRouteImport parentRoute: typeof StoreTabRouteRoute } + '/store/tab/plugins': { + id: '/store/tab/plugins' + path: '/plugins' + fullPath: '/store/tab/plugins' + preLoaderRoute: typeof StoreTabPluginsRouteImport + parentRoute: typeof StoreTabRouteRoute + } '/store/tab/games': { id: '/store/tab/games' path: '/games' @@ -359,6 +500,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StoreTabEmulatorsRouteImport parentRoute: typeof StoreTabRouteRoute } + '/store/tab/download': { + id: '/store/tab/download' + path: '/download' + fullPath: '/store/tab/download' + preLoaderRoute: typeof StoreTabDownloadRouteImport + parentRoute: typeof StoreTabRouteRoute + } + '/settings/plugin/$source': { + id: '/settings/plugin/$source' + path: '/plugin/$source' + fullPath: '/settings/plugin/$source' + preLoaderRoute: typeof SettingsPluginSourceRouteImport + parentRoute: typeof SettingsRouteRoute + } '/platform/$source/$id': { id: '/platform/$source/$id' path: '/platform/$source/$id' @@ -394,6 +549,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CollectionSourceIdRouteImport parentRoute: typeof rootRouteImport } + '/store/details/plugin/$id': { + id: '/store/details/plugin/$id' + path: '/store/details/plugin/$id' + fullPath: '/store/details/plugin/$id' + preLoaderRoute: typeof StoreDetailsPluginIdRouteImport + parentRoute: typeof rootRouteImport + } '/store/details/emulator/$id': { id: '/store/details/emulator/$id' path: '/store/details/emulator/$id' @@ -401,6 +563,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StoreDetailsEmulatorIdRouteImport parentRoute: typeof rootRouteImport } + '/game/update/$source/$id': { + id: '/game/update/$source/$id' + path: '/game/update/$source/$id' + fullPath: '/game/update/$source/$id' + preLoaderRoute: typeof GameUpdateSourceIdRouteImport + parentRoute: typeof rootRouteImport + } + '/store/details/download/$source/$id': { + id: '/store/details/download/$source/$id' + path: '/store/details/download/$source/$id' + fullPath: '/store/details/download/$source/$id' + preLoaderRoute: typeof StoreDetailsDownloadSourceIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -411,6 +587,9 @@ interface SettingsRouteRouteChildren { SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute SettingsInterfaceRoute: typeof SettingsInterfaceRoute SettingsPluginsRoute: typeof SettingsPluginsRoute + SettingsTasksRoute: typeof SettingsTasksRoute + SettingsUpdateRoute: typeof SettingsUpdateRoute + SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute } const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { @@ -420,6 +599,9 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { SettingsEmulatorsRoute: SettingsEmulatorsRoute, SettingsInterfaceRoute: SettingsInterfaceRoute, SettingsPluginsRoute: SettingsPluginsRoute, + SettingsTasksRoute: SettingsTasksRoute, + SettingsUpdateRoute: SettingsUpdateRoute, + SettingsPluginSourceRoute: SettingsPluginSourceRoute, } const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( @@ -427,14 +609,18 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( ) interface StoreTabRouteRouteChildren { + StoreTabDownloadRoute: typeof StoreTabDownloadRoute StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute StoreTabGamesRoute: typeof StoreTabGamesRoute + StoreTabPluginsRoute: typeof StoreTabPluginsRoute StoreTabIndexRoute: typeof StoreTabIndexRoute } const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = { + StoreTabDownloadRoute: StoreTabDownloadRoute, StoreTabEmulatorsRoute: StoreTabEmulatorsRoute, StoreTabGamesRoute: StoreTabGamesRoute, + StoreTabPluginsRoute: StoreTabPluginsRoute, StoreTabIndexRoute: StoreTabIndexRoute, } @@ -447,12 +633,16 @@ const rootRouteChildren: RootRouteChildren = { SettingsRouteRoute: SettingsRouteRouteWithChildren, GamesRoute: GamesRoute, StoreTabRouteRoute: StoreTabRouteRouteWithChildren, + GameAddRoute: GameAddRoute, CollectionSourceIdRoute: CollectionSourceIdRoute, EmbeddedSourceIdRoute: EmbeddedSourceIdRoute, GameSourceIdRoute: GameSourceIdRoute, LauncherSourceIdRoute: LauncherSourceIdRoute, PlatformSourceIdRoute: PlatformSourceIdRoute, + GameUpdateSourceIdRoute: GameUpdateSourceIdRoute, StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute, + StoreDetailsPluginIdRoute: StoreDetailsPluginIdRoute, + StoreDetailsDownloadSourceIdRoute: StoreDetailsDownloadSourceIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) 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..332862e 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -1,13 +1,15 @@ @import "tailwindcss"; @import 'animate.css'; @plugin "daisyui"; +@plugin "@tailwindcss/typography"; @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); @custom-variant light (&:where([data-theme=light], [data-theme=light] *)); @theme { --breakpoint-sm: 0px; - --breakpoint-md: 1280px; + --breakpoint-md: 1024px; + --breakpoint-lg: 1280px; --page-scroll-bg: transparent; --animation-size: 1; diff --git a/src/mainview/index.tsx b/src/mainview/index.tsx index c76e8e9..166cc4f 100644 --- a/src/mainview/index.tsx +++ b/src/mainview/index.tsx @@ -8,16 +8,24 @@ import RouterProvider, } from "@tanstack/react-router"; import { routeTree } from "./gen/routeTree.gen"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { RPC_URL } from "../shared/constants"; +import { QueryClient } from "@tanstack/react-query"; import "./scripts/gamepads"; import "./scripts/windowEvents"; -import { client as rommClient } from "../clients/romm/client.gen"; import "./scripts/spatialNavigation"; import NotFound from "./components/NotFound"; import Error from "./components/Error"; import serviceWorker from './scripts/serviceWorker?worker&url'; -import { getCurrentFocusKey, setFocus } from "@noriginmedia/norigin-spatial-navigation"; +import App from "./App"; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { createStore, get, set, del } from "idb-keyval"; +import +{ + PersistedClient, + Persister, +} from '@tanstack/react-query-persist-client'; +import pkg from '../../package.json'; + +const idbStore = createStore("tanstack-query", "cache"); if ('serviceWorker' in navigator) { @@ -26,13 +34,31 @@ if ('serviceWorker' in navigator) const hashHistory = createHashHistory({}); -rommClient.setConfig({ - baseUrl: `${RPC_URL(__HOST__)}/api/romm`, - credentials: "include", - mode: "cors", +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60 * 24 * 5, // 5 days + } + } }); -const queryClient = new QueryClient(); +export function createIDBPersister (idbValidKey: IDBValidKey = 'reactQuery'): Persister +{ + return { + persistClient: async (client: PersistedClient) => + { + await set(idbValidKey, client, idbStore); + }, + restoreClient: async () => + { + return await get(idbValidKey, idbStore); + }, + removeClient: async () => + { + await del(idbValidKey, idbStore); + }, + } satisfies Persister; +} export interface RouterContext { @@ -66,25 +92,6 @@ export const Router = createRouter({ } }); -const focusMap = new Map(); -export const focusQueue: string[] = []; - -Router.history.subscribe((op) => -{ - if (op.action.type === 'PUSH') - { - focusMap.set(op.location.state.__TSR_index - 1, getCurrentFocusKey()); - } else if (op.action.type === 'BACK') - { - if (focusMap.has(op.location.state.__TSR_index)) - { - focusQueue.pop(); - focusQueue.push(focusMap.get(op.location.state.__TSR_index)!); - focusMap.delete(op.location.state.__TSR_index); - } - } -}); - // Register things for typesafety declare module "@tanstack/react-router" { interface Register @@ -100,9 +107,11 @@ if (!rootElement.innerHTML) const root = createRoot(rootElement); root.render( - - - + + + + + , ); } diff --git a/src/mainview/preload.tsx b/src/mainview/preload.tsx index c95a05b..add9d87 100644 --- a/src/mainview/preload.tsx +++ b/src/mainview/preload.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; +import LoadingScreen from "./components/LoadingScreen"; const rootElement = document.getElementById("preload")!; @@ -9,13 +10,11 @@ if (!rootElement.innerHTML) const root = createRoot(rootElement); root.render( -
    - -
    -
    -
    - Loading Gameflow +
    + + Loading Gameflow +
    - , + ); } diff --git a/src/mainview/query-options.ts b/src/mainview/query-options.ts index a52c649..879d632 100644 --- a/src/mainview/query-options.ts +++ b/src/mainview/query-options.ts @@ -1,6 +1,7 @@ import { keepPreviousData, queryOptions } from "@tanstack/react-query"; import { getRomApiRomsIdGetOptions, getRomsApiRomsGetOptions } from "../clients/romm/@tanstack/react-query.gen"; -import { DefaultRommStaleTime, GameListFilterType } from "../shared/constants"; +import { DefaultRommStaleTime } from "../shared/constants"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; export function gamesQueryOptions (filter?: GameListFilterType) { diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index 345a707..cafbab4 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -4,11 +4,11 @@ import Notifications from "../components/Notifications"; import { Toaster } from "react-hot-toast"; import { mobileCheck, useLocalSetting } from "../scripts/utils"; import useActiveControl from "../scripts/gamepads"; -import { useEffect, useState } from "react"; -import { SystemInfoContext } from "../scripts/contexts"; -import { SystemInfoType } from "@/shared/constants"; -import { systemApi } from "../scripts/clientApi"; +import { useEffect } from "react"; import AppCommunication from "../components/AppCommunication"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import GlobalContextDialog from "../components/GlobalContextDialog"; export const Route = createRootRouteWithContext()({ component: RootComponent, @@ -35,19 +35,20 @@ function RootComponent () }, [theme]); + const queryDevOptions = useLocalSetting('showQueryDevOptions'); + const routerDevOptions = useLocalSetting('showRouterDevOptions'); + return (
    - - - + + + + + - {/*import.meta.env.DEV && !isMobile && - <> - - - - */} + {queryDevOptions && } + {routerDevOptions && }
    ); } diff --git a/src/mainview/routes/collection.$source.$id.tsx b/src/mainview/routes/collection.$source.$id.tsx index 2f62d91..a08c164 100644 --- a/src/mainview/routes/collection.$source.$id.tsx +++ b/src/mainview/routes/collection.$source.$id.tsx @@ -6,10 +6,14 @@ import { AnimatedBackgroundContext } from '../scripts/contexts'; import { getCollectionQuery } from '@queries/romm'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; +import { useLocalStorage } from 'usehooks-ts'; export const Route = createFileRoute('/collection/$source/$id')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ countHint: z.number().optional() })) + validateSearch: zodValidator(z.object({ + countHint: z.number().optional() + })) }); function RouteComponent () @@ -18,8 +22,16 @@ function RouteComponent () const { countHint } = Route.useSearch(); const { data: collection } = useQuery(getCollectionQuery(source, id)); const animatedBgContext = useContext(AnimatedBackgroundContext); + const [filter, setFilter] = useLocalStorage("collection-filter", {}); return ( - {collection?.name}
    } filters={{ collection_id: Number(id), collection_source: source }} /> + {collection?.name}
    } + filters={{ collection_id: Number(id), collection_source: source }} + /> ); } diff --git a/src/mainview/routes/embedded.$source.$id.tsx b/src/mainview/routes/embedded.$source.$id.tsx index 9d67605..b9a39b6 100644 --- a/src/mainview/routes/embedded.$source.$id.tsx +++ b/src/mainview/routes/embedded.$source.$id.tsx @@ -1,23 +1,29 @@ import { RPC_URL, SERVER_URL } from '@/shared/constants'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; import { RefObject, useEffect, useRef, useState } from 'react'; -import { Router } from '..'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { ButtonStyle } from '../components/options/Button'; -import { DoorOpen, RefreshCw, Undo } from 'lucide-react'; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; -import Shortcuts from '../components/Shortcuts'; +import { CloudDownload, DoorOpen, RefreshCw, Undo } from 'lucide-react'; +import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; +import { FloatingShortcuts } from '../components/Shortcuts'; import { useEventListener } from 'usehooks-ts'; import useActiveControl from '../scripts/gamepads'; import { twMerge } from 'tailwind-merge'; import { HeaderAccounts, HeaderStatusBar } from '../components/Header'; import { RoundButton } from '../components/RoundButton'; import { gameQuery } from '@queries/romm'; +import { rommApi } from '../scripts/clientApi'; +import toast from 'react-hot-toast'; +import { getErrorMessage } from 'react-error-boundary'; export const Route = createFileRoute('/embedded/$source/$id')({ component: RouteComponent, + staticData: { + enterSound: 'launch', + missNavSound: false + }, loader: async (ctx) => { const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id)); @@ -43,7 +49,7 @@ function OverlayButton (data: { function Overlay (data: { open: boolean; - iframeRef: RefObject; + postMessage: (m: EmulatorJsMessage) => void; close: () => void; goBack: () => void; }) @@ -57,12 +63,11 @@ function Overlay (data: { { if (data.open) { - focusSelf(); + focusSelf({ instant: true }); } }, [data.open]); const { isPointer } = useActiveControl(); - const handleEvent = (type: string, value?: any) => data.iframeRef.current?.contentWindow?.postMessage({ type, data: value }); return
    @@ -76,7 +81,7 @@ function Overlay (data: { { data.close(); - handleEvent('restart'); + data.postMessage({ type: 'restart' }); }} > @@ -99,7 +104,7 @@ function Frame (data: { ref: RefObject; }) const search = Route.useSearch(); search['gameName'] = game.name; - search['backgroundImage'] = `${RPC_URL(__HOST__)}${game.path_cover}`; + search['backgroundImage'] = `${RPC_URL(__HOST__)}${game.path_covers[0]}`; search['backgroundBlur'] = "true"; if (!__PUBLIC__) @@ -122,6 +127,7 @@ function Frame (data: { ref: RefObject; }) function RouteComponent () { + const router = useRouter(); const { ref, focusSelf, focusKey } = useFocusable({ focusKey: 'emulatorjs', preferredChildFocusKey: 'frame', @@ -129,18 +135,39 @@ function RouteComponent () }); const iframeRef = useRef(null); const [overlayOpen, setOverlayOpen] = useState(false); + const postMessage = (m: EmulatorJsMessage) => iframeRef.current?.contentWindow?.postMessage(m); const { source, id } = Route.useParams(); function HandleGoBack () { - Router.navigate({ to: '/game/$source/$id', params: { source, id }, replace: true }); + if (router.history.canGoBack()) + { + router.history.back(); + } else + { + router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + } } useEventListener('message', e => { - if (e.data.type === 'exit') + const data = e.data as EmulatorJsMessage; + switch (data.type) { - HandleGoBack(); + case "exit": + rommApi.api.romm.emulatorjs.post_play({ source })({ id }).post({ save: data.save }); + HandleGoBack(); + break; + case "loaded": + toast.success("Save Loaded", { icon: }); + break; + case "save": + rommApi.api.romm.emulatorjs.save.put({ save: data.save }).then(r => + { + if (r.error) toast.error(getErrorMessage(r.error.value) ?? "Error While Saving"); + else toast.success("Save Backed Up"); + }); + break; } }); @@ -164,16 +191,15 @@ function RouteComponent () const setPaused = (paused: boolean) => { - if (paused) iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: true }); + if (paused) postMessage({ type: 'pause', paused: true }); else { // we want to prevent input from closing the overlay spilling - setTimeout(() => iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: false }), 100); + setTimeout(() => postMessage({ type: 'pause', paused: false }), 100); } }; useEffect(() => setPaused(overlayOpen), [overlayOpen]); - const { shortcuts } = useShortcutContext(); - useEffect(() => { if (!overlayOpen) focusSelf(); }, [overlayOpen]); + useEffect(() => { if (!overlayOpen) focusSelf({ instant: true }); }, [overlayOpen]); function handleClose () { setOverlayOpen(false); @@ -183,11 +209,9 @@ function RouteComponent ()
    - -
    -
    - +
    +
    ; } \ No newline at end of file diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 6f97fbc..13ea8ac 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -1,16 +1,15 @@ -import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router"; +import { createFileRoute, ErrorComponentProps, useRouter } from "@tanstack/react-router"; import { RPC_URL } from "@shared/constants"; -import { useEffect, useRef, useState } from "react"; -import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { Calendar, Clock, Folder, Gamepad2, Image, Info, Store, TriangleAlert, Trophy } from "lucide-react"; -import { HeaderUI } from "../../components/Header"; +import { useRef, useState } from "react"; +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "lucide-react"; +import { HeaderUI, StickyHeaderUI } from "../../components/Header"; import { AnimatedBackground } from "../../components/AnimatedBackground"; import { useQuery } from "@tanstack/react-query"; -import { Router } from "../.."; -import Shortcuts from "../../components/Shortcuts"; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; +import { FloatingShortcuts } from "../../components/Shortcuts"; +import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import Screenshots from "@/mainview/components/Screenshots"; -import { HandleGoBack, scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils"; +import { HandleGoBack, scrollIntoViewHandler, useOnNavigateBack } from "@/mainview/scripts/utils"; import { FilterUI } from "@/mainview/components/Filters"; import StatList, { StatEntry } from "@/mainview/components/StatList"; import { useIntersectionObserver, useLocalStorage } from "usehooks-ts"; @@ -21,8 +20,11 @@ import Achievements from "@/mainview/components/game/Achievements"; import { GameDetailsContext } from "@/mainview/scripts/contexts"; import { gameQuery, gamesRecommendedBasedOnGameQuery } from "@queries/romm"; import { GamesSection } from "@/mainview/components/store/GamesSection"; -import Details, { DetailElement } from "@/mainview/components/game/Details"; +import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; +import SelectMenu from "@/mainview/components/SelectMenu"; +import { IGDBIcon } from "@/mainview/scripts/brandIcons"; +import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => @@ -31,7 +33,13 @@ export const Route = createFileRoute("/game/$source/$id")({ }, component: RouteComponent, errorComponent: Error, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })) + validateSearch: zodValidator(z.object({ + focus: z.string().optional(), + })), + staticData: { + enterSound: 'openDetails', + goBackSound: "returnDetails" + }, }); function useDetailsSection () @@ -43,12 +51,8 @@ function Error (data: ErrorComponentProps) { const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { shortcuts } = useShortcutContext(); - useEffect(() => - { - focusSelf(); - }, []); + const router = useRouter(); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }]); return
    @@ -60,14 +64,9 @@ function Error (data: ErrorComponentProps)
    {JSON.stringify(data.error, null, 3)}
    -
    - -
    - -
    -
    +
    ; } @@ -101,14 +100,20 @@ function Stats (data: { game: FrontEndGameTypeDetailed | undefined; }) { if (data.game.path_fs) stats.push({ label: "Location", content: data.game.path_fs, icon: }); - if (data.game.companies) - stats.push({ label: "Companies", content: data.game.companies }); - if (data.game.genres) - stats.push({ label: 'Genres', content: data.game.genres }); - if (data.game.release_date) - stats.push({ label: "Release Date", content: data.game.release_date.toLocaleDateString(), icon: }); + if (data.game.metadata.companies) + stats.push({ label: "Companies", content: data.game.metadata.companies }); + if (data.game.metadata.genres) + stats.push({ label: 'Genres', content: data.game.metadata.genres }); + if (data.game.metadata.first_release_date) + stats.push({ label: "Release Date", content: data.game.metadata.first_release_date.toLocaleDateString(), icon: }); if (data.game.emulators) stats.push({ label: "Emulators", content: data.game.emulators.map(e => e.name) }); + if (data.game.igdb_id) + stats.push({ label: "IGDB", icon: IGDBIcon, content: String(data.game.igdb_id) }); + if (data.game.source) + stats.push({ label: "Source", content: `${data.game.source} - ${data.game.source_id}` }); + const integrations = new Set(data.game.emulators?.flatMap(e => e.integrations).flatMap(i => i.capabilities).filter(c => !!c)); + stats.push({ label: "Integrations", content: Array.from(integrations) }); } return ; @@ -139,22 +144,23 @@ function Divider (data: { rootFocusKey: string; showShortcuts: boolean; game: Fr function RouteComponent () { + const router = useRouter(); const [recommendedGamesVisible, setRecommendedGamesVisible] = useState(false); const { source, id } = Route.useParams(); const { data } = useQuery(gameQuery(source, id)); - const { focus } = Route.useSearch(); const [, setUpdate] = useState(0); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true }); const headerRef = useRef(null); - const sentinelRef = useRef(null); - const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined; + const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_covers[0]}`) : undefined; const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { shortcuts } = useShortcutContext(); + useShortcuts(focusKey, () => [{ + label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) + }], [router]); - useStickyDataAttr(headerRef, sentinelRef, ref); - const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists)); + useOnNavigateBack((s) => s.sound = 'returnDetails'); + + const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists) || e.source === 'store'); const { ref: intersct } = useIntersectionObserver({ onChange: (isIntersecting, entry) => @@ -165,16 +171,12 @@ function RouteComponent () return ( - setUpdate(v => v + 1) }} >
    -
    -
    - -
    +
    @@ -188,9 +190,10 @@ function RouteComponent () Related Emulators } onFocus={scrollIntoViewHandler({ block: 'center' })} - onSelect={(id, focus) => + onSelect={(em, focus) => { - Router.navigate({ to: '/store/details/emulator/$id', params: { id } }); + if (em.source === 'local') return; + router.navigate({ to: '/store/details/emulator/$id', params: { id: em.name } }); }} emulators={recommendedEmulators} />} @@ -206,16 +209,16 @@ function RouteComponent ()
    { - Router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } }); + router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } }); }} onFocus={scrollIntoViewHandler({ block: 'center', inline: 'nearest' })} games={recommendedGames} />
    +
    -
    - -
    + + ); } \ No newline at end of file diff --git a/src/mainview/routes/game/add.tsx b/src/mainview/routes/game/add.tsx new file mode 100644 index 0000000..6399cd0 --- /dev/null +++ b/src/mainview/routes/game/add.tsx @@ -0,0 +1,425 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import { OptionElement } from '@/mainview/components/ContextDialog'; +import GameLookupElement from '@/mainview/components/game/GameLookup'; +import { StickyHeaderUI } from '@/mainview/components/Header'; +import LoadingScreen from '@/mainview/components/LoadingScreen'; +import { Button } from '@/mainview/components/options/Button'; +import { PathSettingsOptionBase } from '@/mainview/components/options/PathSettingsOption'; +import SelectMenu from '@/mainview/components/SelectMenu'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import { oneShot } from '@/mainview/scripts/audio/audio'; +import { rommApi } from '@/mainview/scripts/clientApi'; +import { addManualGameMutation, allGamesInvalidateQuery, gameLookupDetails, platformLookupMatchQuery } from '@/mainview/scripts/queries/romm'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { isUrl } from '@/shared/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { zodValidator } from '@tanstack/zod-adapter'; +import { ArrowBigRightDash, Check, CirclePlus, CircleQuestionMark, CircleX, File, FileSearch, FolderOpen, Globe, HardDrive, Link, Save } from 'lucide-react'; +import { basename } from 'pathe'; +import prettyBytes from 'pretty-bytes'; +import { JSX, useState } from 'react'; +import toast from 'react-hot-toast'; +import { twMerge } from 'tailwind-merge'; +import z from 'zod'; + + +const StateSchema = z.object({ + step: z.number().default(0), + gameLocation: z.string().optional(), + selectedGame: z.object({ source: z.string(), id: z.string() }).optional(), + platformId: z.number().optional(), + search: z.string().optional() +}); + +export const Route = createFileRoute('/game/add')({ + component: RouteComponent, + validateSearch: zodValidator(StateSchema) +}); + +function FileSelectionField (data: { location: string | undefined, setLocation: (location: string | undefined) => void; }) +{ + const [localLocation, setLocalLocation] = useState(data.location); + const navigate = useNavigate(); + return + + ; +} + +const TAG_REGEX = /\(([^)]+)\)|\[([^\]]+)\]/g; +const EXTENSION_REGEX = /\.(([a-z]+\.)*\w+)$/g; +const LEADING_ARTICLE_PATTERN = /^(a|an|the)\b/g; +const COMMA_ARTICLE_PATTERN = /,\s(a|an|the)\b(?=\s*[^\w\s]|$)/g; +const NON_WORD_SPACE_PATTERN = /[^\w\s]/g; +const MULTIPLE_SPACE_PATTERN = /\s+/g; + +function BuildSearch (filePath: string) +{ + const name = basename(filePath); + const nameWithoutExt = name.replace(EXTENSION_REGEX, "").trim(); + if (!nameWithoutExt) return undefined; + const nameWithoutTags = nameWithoutExt.replaceAll(TAG_REGEX, "").trim(); + if (TAG_REGEX.test(nameWithoutExt)) console.log("match"); + if (!nameWithoutTags) return undefined; + + // Lower and replace underscores with spaces + let finalSearch = nameWithoutTags.toLowerCase().replace("_", " "); + + // Remove articles (combined if possible) + finalSearch = finalSearch.replaceAll(LEADING_ARTICLE_PATTERN, ''); + finalSearch = finalSearch.replaceAll(COMMA_ARTICLE_PATTERN, ''); + + // Remove punctuation and normalize spaces in one step + finalSearch = finalSearch.replaceAll(NON_WORD_SPACE_PATTERN, ''); + finalSearch = finalSearch.replaceAll(MULTIPLE_SPACE_PATTERN, ''); + + return nameWithoutTags; +} + +const typeIconMap: Record = { + new: , + existing: , + unknown: +}; + +function Overview (data: {}) +{ + const navigate = useNavigate(); + const router = useRouter(); + const state = Route.useSearch(); + const linkInfo = useQuery({ + enabled (query) + { + return isUrl(query.queryKey[1]); + }, + queryKey: ['dl-link-info', state.gameLocation], + queryFn: async () => + { + return rommApi.api.romm.download.file.info.get({ query: { file_url: state.gameLocation! } }); + } + }); + const { data: game } = useQuery(gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id)); + const { data: platform } = useQuery(platformLookupMatchQuery(state.selectedGame?.source, state.platformId)); + const addGame = useMutation({ + ...addManualGameMutation, + onError (error, variables, onMutateResult, context) + { + toast.error(error.message); + }, + async onSuccess (data, variables, onMutateResult, context) + { + if (data.id === null || isUrl(state.gameLocation)) return; + await context.client.invalidateQueries(allGamesInvalidateQuery); + navigate({ + to: '/game/$source/$id', params: { + source: data.source, id: String(data.id) + }, replace: true + }); + }, + }); + + if (!game) return
    Select A Game
    ; + + return
    +
    Preview
    +
    +
    {!!game[0].coverUrl && }
    +
    +
    {game[0].name}
    +
    {game[0].summary}
    +
    +
    {platform?.details.name}
    + +
    + {!!platform?.match.coverUrl && } +
    {platform?.match.name}
    +
    {platform?.match.family_name}
    + + {!!platform?.match.type && typeIconMap[platform?.match.type]} +
    {platform?.match.type}
    +
    +
    +
    {isUrl(state.gameLocation) ? : }{state.gameLocation}
    +
    + + {linkInfo.isFetching ? : (linkInfo.data?.data?.size && prettyBytes(linkInfo.data.data.size))} + + {linkInfo.isFetching ? : (linkInfo.data?.data?.content_type && linkInfo.data.data.content_type)} +
    +
    +
    +
    Actions
    +
    + + +
    +
    ; +} + +function PlatformEntry (data: { + id: string, + displayName: string, + platformSource: string, + platformId: number; +}) +{ + const state = Route.useSearch(); + const { data: match, isFetching: matchIsFetching } = useQuery({ ...platformLookupMatchQuery(data.platformSource, data.platformId), staleTime: 1000 * 60 * 60 }); + const navigate = useNavigate(); + const handleAction = () => + { + navigate({ to: '/game/add', search: { ...state, platformId: data.platformId, step: 3 }, replace: true }); + oneShot('openGeneric'); + }; + + return +
    {data.displayName}
    +
    + {matchIsFetching ? : match && <> + + {match.match.coverUrl ? : } +
    {match.match.name} - {!!match.match.type && typeIconMap[match.match.type]} {match.match.type}
    + } +
    + } type={'primary'} />; +} + +function PlatformSelection (data: {}) +{ + const state = Route.useSearch(); + const { data: game, isFetching } = useQuery({ ...gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id), staleTime: 1000 * 60 * 60 }); + if (isFetching) return ; + if (!game) return
    Select A Game
    ; + return
      + {game[0].platforms.map((p, i) => )} +
    ; +} + +function Lookup () +{ + const state = Route.useSearch(); + const [search, setSearch] = useState(state.search); + const navigate = useNavigate(); + const handleSetSelectedGame = (source: string, id: string) => + { + navigate({ to: '/game/add', search: { ...state, selectedGame: { source, id }, platformId: undefined, search, step: 2 }, replace: true }); + oneShot('openGeneric'); + }; + return + { + handleSetSelectedGame(l.source, l.id); + }} />; +} + +const StepDetails = [{ label: "Select Location" }, { label: "Find Match" }, { label: "Select Platform" }, { label: "Confirm" }]; + +function Location () +{ + const state = Route.useSearch(); + const navigate = useNavigate(); + const handleSetLocation = (location: string | undefined) => + { + if (!location) return; + navigate({ + to: '/game/add', search: { + ...state, + gameLocation: location, + search: BuildSearch(location), + selectedGame: undefined, + platformId: undefined, + step: 1 + }, replace: true + }); + oneShot('openGeneric'); + }; + return
    +
    Select Game Rom
    + +
    + Select The Rom File from your local storage or use a link +
    +
    ; +} + +function Details (data: {}) +{ + const { ref, focusKey } = useFocusable({ focusKey: 'add-game-details-section' }); + const state = Route.useSearch(); + const step = state.step ?? 0; + return
    + + {step === 0 && } + {step === 1 && } + {step === 2 && } + {step === 3 && } + + +
    ; +} + +function getStepDetails (index: number, state: z.infer) +{ + let completed = index < state.step; + if (index === 0 && state.gameLocation) completed = true; + if (index === 1 && state.selectedGame) completed = true; + if (index === 2 && state.platformId) completed = true; + if (index === 3 && state.gameLocation && state.selectedGame && state.platformId) completed = true; + let canNavigate = index <= state.step; + if (index === 1 && state.gameLocation) canNavigate = true; + if (index === 2 && state.selectedGame) canNavigate = true; + if (index === 3 && state.platformId) canNavigate = true; + return { completed, canNavigate }; +} + +function Step (data: { index: number; label: string; }) +{ + const navigate = useNavigate(); + const handleGoToStep = (step: number) => + { + navigate({ to: '/game/add', search: { ...state, step: step }, replace: true }); + oneShot('openGeneric'); + }; + const state = Route.useSearch(); + const step = state.step ?? 0; + const { canNavigate, completed } = getStepDetails(data.index, state); + + const { ref } = useFocusable({ + focusKey: `step-${data.index}`, + focusable: canNavigate, + onFocus: () => + { + if (step === data.index) return; + navigate({ to: '/game/add', search: { ...state, step: data.index }, replace: true }); + oneShot('openGeneric'); + } + }); + return
  • + { + if (!canNavigate) return; + handleGoToStep(data.index); + }} className={twMerge("step not-aria-disabled:cursor-pointer", data.index <= step ? "step-primary" : "")}> + {completed ? : } + {data.label} +
  • ; +} + +function Steps () +{ + const state = Route.useSearch(); + const step = state.step ?? 0; + const { ref, focusKey } = useFocusable({ focusKey: "steps", preferredChildFocusKey: `step-${step}`, saveLastFocusedChild: false }); + return
    + +
      + + {StepDetails.map((s, i) => )} + +
    ; +} + +function RouteComponent () +{ + const navigate = useNavigate(); + const state = Route.useSearch(); + const step = state.step ?? 0; + const router = useRouter(); + const queryClient = useQueryClient(); + const isAddingGame = queryClient.isMutating(addManualGameMutation) > 0; + + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'add-game-page', preferredChildFocusKey: 'steps' }); + + const handleReturnStep = (e: Event) => + { + if (step <= 0) + { + HandleGoBack(router, e); + } else + { + const newStep = step - 1; + navigate({ to: '/game/add', search: { ...state, step: newStep }, replace: true }); + } + }; + + const handleStepNavigation = (newStep: number) => + { + if (step === newStep) return; + const { canNavigate } = getStepDetails(newStep, state); + if (!canNavigate) return; + navigate({ to: '/game/add', search: { ...state, step: newStep }, replace: true }); + oneShot('openGeneric'); + }; + + useShortcuts(focusKey, () => [ + { button: GamePadButtonCode.B, label: step === 0 ? "Cancel" : "Prev Step", action: handleReturnStep }, + { button: GamePadButtonCode.Y, label: "Cancel", action: e => HandleGoBack(router, e) }, + { + button: GamePadButtonCode.L1, label: "Prev Step", action (e) + { + handleStepNavigation(Math.max(step - 1, 0)); + }, + }, + { + button: GamePadButtonCode.R1, label: "Next Step", action (e) + { + handleStepNavigation(Math.min(step + 1, 3)); + }, + } + ], [step]); + + return
    + +
    + + +
    + +
    + + + {isAddingGame && +
    + +
    Adding Game
    +
    +
    } + +
    +
    ; +} diff --git a/src/mainview/routes/game/update.$source.$id.tsx b/src/mainview/routes/game/update.$source.$id.tsx new file mode 100644 index 0000000..0a6ef83 --- /dev/null +++ b/src/mainview/routes/game/update.$source.$id.tsx @@ -0,0 +1,61 @@ +import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import GameLookupElement from '@/mainview/components/game/GameLookup'; +import { HeaderUI } from '@/mainview/components/Header'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import { customUpdateMutation, gameInvalidationQuery, gameQuery } from '@/mainview/scripts/queries/romm'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; + +export const Route = createFileRoute('/game/update/$source/$id')({ + component: RouteComponent, +}); + +function RouteComponent () +{ + const { source, id } = Route.useParams(); + const [search, setSearch] = useState(undefined); + + const router = useRouter(); + const { data: game } = useQuery(gameQuery(source, id)); + const update = useMutation({ + ...customUpdateMutation, + async onSuccess (data, variables, onMutateResult, context) + { + toast.success("Updated Metadata"); + await context.client.invalidateQueries(gameInvalidationQuery(source, id)); + router.history.back(); + }, + }); + + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: `custom-update-page`, preferredChildFocusKey: 'search-field-section' }); + + useShortcuts(focusKey, () => [{ button: GamePadButtonCode.B, label: "Return", action (e) { HandleGoBack(router, e); }, }]); + useEffect(() => + { + if (search) return; + setSearch(game?.name ?? undefined); + }, [game]); + + return + + +
    + + + update.mutate({ source, id, destination: l.source, destinationId: l.id })} + /> + + +
    +
    +
    ; +} diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index d1071fa..bd0fc8e 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -1,16 +1,45 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { CollectionsDetail } from '../components/CollectionsDetail'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; +import { useSessionStorage } from 'usehooks-ts'; +import HeaderSearchField from '../components/HeaderSearchField'; +import { useEffect } from 'react'; +import { RoundButton } from '../components/RoundButton'; +import { Plus } from 'lucide-react'; export const Route = createFileRoute('/games')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })) + validateSearch: zodValidator(z.object({ + focus: z.string().optional(), + search: z.string().optional() + })) }); function RouteComponent () { const { focus } = Route.useSearch(); + const { search } = Route.useSearch(); + const [filter, setFilter] = useSessionStorage('all-games-filters', {}); + const navigate = useNavigate(); - return ; + useEffect(() => + { + setFilter(v => ({ ...v, search })); + }, [search]); + + return + { + navigate({ to: '/game/add' }); + }} >, + setFilter({ ...filter, search: v })} search={filter.search} id='search-filter' />] + } + localFilter={filter} + setLocalFilter={setFilter} + focus={focus} + id='all-games' + />; } \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 945b6d7..8ae562a 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -3,17 +3,19 @@ import { Gamepad2, Settings, - MessageSquare, - Image, Search, Power, OctagonAlert, Maximize, Store, + LayoutGrid, + LucideIcon, } from "lucide-react"; import { createFileRoute, + useNavigate, + useRouter, } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import @@ -33,18 +35,27 @@ import { AutoFocus } from "../components/AutoFocus"; import SaveScroll from "../components/SaveScroll"; import { ErrorBoundary, useErrorBoundary } from "react-error-boundary"; import { twMerge } from "tailwind-merge"; -import Shortcuts from "../components/Shortcuts"; import { PlatformsList } from "../components/PlatformsList"; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; +import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import z from "zod"; -import { Router } from ".."; import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; -import { mobileCheck, useDragScroll } from "../scripts/utils"; -import { AnimatedBackgroundContext } from "../scripts/contexts"; +import { mobileCheck, scrollIntoViewHandler, useDragScroll } from "../scripts/utils"; +import { AnimatedBackgroundContext, GlobalDialogContext } from "../scripts/contexts"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; import { gameQuery } from "../scripts/queries/romm"; +import { oneShot } from "../scripts/audio/audio"; +import { FloatingShortcuts } from "../components/Shortcuts"; +import SelectMenu from "../components/SelectMenu"; +import HeaderSearchField from "../components/HeaderSearchField"; +import CardElement from "../components/CardElement"; +import { Router } from ".."; +import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; +import { playGame, usePlayMutation } from "../components/game/MainActions"; +import { rommApi } from "../scripts/clientApi"; +import { ContextList, DialogEntry } from "../components/ContextDialog"; +import { FOCUS_KEYS } from "../scripts/types"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -88,20 +99,56 @@ function HomeListError (data: { focused: boolean; })
    ; } -function ShowAllGamesCard () +function Preview (data: { index: number; children?: any; }) { + const isMobile = mobileCheck(); + return
    + {data.children} +
    ; +} + +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: '/games', viewTransition: { types: ['zoom-in'] } }); + router.navigate({ to: data.route as any }); }; - const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate }); - return
    All Games
    ; + 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: { selectedFilter: string; }) { + const router = useRouter(); const queryClient = useQueryClient(); const [initFocus, setInitFocus] = useState(false); const bg = useContext(AnimatedBackgroundContext); @@ -110,6 +157,9 @@ function HomeList (data: { focusKey: "home-list", preferredChildFocusKey: `${data.selectedFilter}-list` }); + const navigate = useNavigate(); + const playGameMut = usePlayMutation(navigate); + const globalDialog = useContext(GlobalDialogContext); const handleNodeFocus = (id: string, node: HTMLElement, details: FocusDetails) => { @@ -124,9 +174,55 @@ function HomeList (data: { function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null) { - Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); + router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; + async function handleGamePlay (id: FrontEndId, source: string | null, sourceId: string | null) + { + const finalSource = source ?? id.source; + const finalId = String(sourceId ?? id.id); + + const validCommands = await rommApi.api.romm.game({ source: finalSource })({ id: finalId }).commands.get(); + if (validCommands.data) + { + const preferredCommand = localStorage.getItem(`${finalSource}-${finalId}-preferred-command`); + if (preferredCommand) + { + playGame(finalSource, finalId, validCommands.data.commands[JSON.parse(preferredCommand)], navigate, playGameMut.mutate); + } else + { + if (validCommands.data.commands.length > 1) + { + globalDialog.openContext({ + content: + { + const option: DialogEntry = { + id: String(c.id), + content: c.label ?? String(c.id), + type: "primary", + action (ctx) + { + localStorage.setItem(`${finalSource}-${finalId}-preferred-command`, JSON.stringify(i)); + ctx.close(); + playGame(finalSource, finalId, validCommands.data.commands[0], navigate, playGameMut.mutate); + }, + }; + + return option; + }) + } /> + }, FOCUS_KEYS.GAME_LIST_CARD('games-list', id)); + } else if (validCommands.data.commands.length === 1) + { + playGame(finalSource, finalId, validCommands.data.commands[0], navigate, playGameMut.mutate); + } + + } + } + + } + let activeList: JSX.Element; switch (data.selectedFilter) { @@ -148,10 +244,11 @@ function HomeList (data: { activeList = <> { - const [source, id] = l.split('@'); + const [source, id] = d.id?.split('@', 2); queryClient.prefetchQuery(gameQuery(source, id)); handleNodeFocus(l, n, d); }} @@ -160,7 +257,13 @@ function HomeList (data: { id="games-list" setBackground={bg.setBackground} filters={{ limit: 12, orderBy: 'activity' }} - finalElement={} + finalElement={[ + , + + ]} + emptyElement={[ + + ]} /> ; @@ -213,9 +316,11 @@ function HomeList (data: { function MainMenu () { + const router = useRouter(); const { ref, focusKey } = useFocusable({ focusKey: `main-menu`, trackChildren: true, + focusBoundaryDirections: ['up', 'down'] }); return (
      Router.navigate({ to: "/games" })} + onAction={(e) => router.navigate({ to: "/games", state: { eventType: e?.event?.type } })} icon={} label="Home" type="secondary" /> - } label="News" /> - } action={() => Router.navigate({ to: "/store/tab" })} label="Shop" /> - } label="Album" /> + } onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.event?.type } })} label="Shop" /> } - label="Controllers" - /> - + onAction={(e) => { - Router.navigate({ to: '/settings/accounts' }); + router.navigate({ to: '/settings/interface', state: { eventType: e?.event?.type } }); }} icon={} label="Settings" @@ -253,17 +352,21 @@ function MainMenu () } function CircleIcon (data: { - action?: () => void; type?: "secondary" | "accent" | "info"; label?: string; icon?: JSX.Element; -}) +} & InteractParams) { + const handleAction = (event?: Event) => + { + data.onAction?.({ event, focusKey }); + oneShot('click'); + }; const { ref, focusKey } = useFocusable({ - focusKey: `navigation-icon-${data.label}`, - onEnterPress: data.action, + focusKey: `menu-navigation-icon-${data.label}`, + onEnterPress: handleAction, }); - useShortcuts(focusKey, () => [{ label: data.label, action: (e) => data.action?.(), button: GamePadButtonCode.A }]); + useShortcuts(focusKey, () => [{ label: data.label, action: handleAction, button: GamePadButtonCode.A }]); const typeClasses = { secondary: "bg-secondary text-secondary-content", accent: "bg-accent text-accent-content", @@ -273,7 +376,8 @@ function CircleIcon (data: { return (
    • handleAction(e.nativeEvent)} className={twMerge( `portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all focusable focusable-primary focused:drop-shadow-2xl focused:animate-scale focusable-hover bg-base-content border-6 md:border-12 border-base-content focused:border-0 hover:border-0 z-1 active:border-0 active:bg-base-300 active:text-base-content active:transition-none`, typeClasses[data.type ?? 'none'])} > @@ -287,7 +391,7 @@ export default function ConsoleHomeUI () const { filter } = Route.useSearch(); const close = useMutation(closeMutation); - + const router = useRouter(); const { ref, focusKey } = useFocusable({ forceFocus: true, autoRestoreFocus: false, @@ -296,17 +400,19 @@ export default function ConsoleHomeUI () preferredChildFocusKey: `home-list`, }); - const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); + const setFilter = (filter: string) => router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); - const { shortcuts } = useShortcutContext(); const headerButtons: HeaderButton[] = []; if (mobileCheck()) headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); headerButtons.push( - { id: "search-header-button", icon: }, - { id: "power-button", icon: , external: true, action: () => close.mutate() }, - { id: "settings-header-button", icon: , external: true, action: () => Router.navigate({ to: "/settings/accounts" }) } + { id: "power-button", icon: , external: true, action: () => close.mutate(), className: "focusable-error!" }, + { id: "settings-header-button", icon: , external: true, action: () => router.navigate({ to: "/settings/accounts" }) } ); + const handleSearch = (search: string | undefined) => + { + router.navigate({ to: '/games', search: { search } }); + }; return ( @@ -324,7 +430,7 @@ export default function ConsoleHomeUI () />
    - + } />
    - + - + ); diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 89c4a65..78df354 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -1,59 +1,73 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useBlocker, useRouter } from '@tanstack/react-router'; import DotsLoading from '../components/backgrounds/dots'; -import { Router } from '..'; -import { useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; +import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import Shortcuts from '../components/Shortcuts'; -import { gameQuery } from '@queries/romm'; -import { rommApi } from '../scripts/clientApi'; +import { FloatingShortcuts } from '../components/Shortcuts'; +import { useJobStatus } from '../scripts/utils'; +import { useRef } from 'react'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, + staticData: { + enterSound: 'launch', + missNavSound: false + }, }); +const stateLookup: Record = { + saves: "Syncing Saves" +}; + function RouteComponent () { + const router = useRouter(); function HandleGoBack () { - Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + if (router.history.canGoBack()) + { + router.history.back(); + } else + { + router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id }, replace: true }); + } } + const progressRef = useRef(null); const { source, id } = Route.useParams(); const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); - const { data } = useQuery(gameQuery(source, id)); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { shortcuts } = useShortcutContext(); - useEffect(() => - { - if (!data) return; - const sub = rommApi.api.romm.status({ source: data.id.source })({ id: data.id.id }).subscribe(); - - sub.subscribe((e) => + const { state, data } = useJobStatus('launch-game', { + onProgress (process, data) { - if (e.data.status !== 'playing') - { - HandleGoBack(); - } - }); - - return () => + if (progressRef.current) + progressRef.current.value = process; + }, + onEnded (data) { - sub.close(); - }; - }, [data?.id]); + HandleGoBack(); + }, + onWaiting () + { + HandleGoBack(); + }, + }, [progressRef.current, HandleGoBack]); + + + useBlocker({ shouldBlockFn: () => !!data }); return
    -

    Launching {data?.name} ...

    -
    -
    - + {!!state && !!stateLookup[state] ? + <> +

    Launching {data?.name} ...

    + + : +

    Launching {data?.name} ...

    }
    +
    ; } diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index 4f5347d..bc35faf 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -1,14 +1,23 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; import { CollectionsDetail } from "../components/CollectionsDetail"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { RPC_URL } from "../../shared/constants"; -import { platformQuery } from "@queries/romm"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; +import { deletePlatformMutation, localPlatformFilter, platformQuery, updatePlatformMutation } from "@queries/romm"; import { zodValidator } from "@tanstack/zod-adapter"; import z from "zod"; +import { useLocalStorage } from "usehooks-ts"; +import { RefreshCcw, Settings2 } from "lucide-react"; +import { ContextList, DialogEntry } from "../components/ContextDialog"; +import toast from "react-hot-toast"; +import { useContext } from "react"; +import { GlobalDialogContext } from "../scripts/contexts"; export const Route = createFileRoute("/platform/$source/$id")({ component: RouteComponent, - validateSearch: zodValidator(z.object({ countHint: z.number().optional() })) + validateSearch: zodValidator(z.object({ + countHint: z.number().optional() + })) }); function PlatformTitle (data: {}) @@ -16,7 +25,7 @@ function PlatformTitle (data: {}) const { source, id } = Route.useParams(); const { data: platform } = useQuery(platformQuery(source, id)); - return
    + return
    {!!platform && } @@ -28,12 +37,74 @@ function PlatformTitle (data: {}) function RouteComponent () { const { source, id } = Route.useParams(); + const router = useRouter(); const { countHint } = Route.useSearch(); + const { data: platform } = useQuery(platformQuery(source, id)); + const [filter, setFilter] = useLocalStorage("platforms-filters", {}); + const updatePlatform = useMutation({ + ...updatePlatformMutation(source, id), onSuccess (data, variables, onMutateResult, context) + { + context.client.invalidateQueries(localPlatformFilter(id)); + }, + }); + const globalDialog = useContext(GlobalDialogContext); + const deletePlatform = useMutation({ + ...deletePlatformMutation(id), + onError (error, variables, onMutateResult, context) + { + toast.error(error.message); + }, + onSuccess (data, variables, onMutateResult, context) + { + context.client.invalidateQueries(localPlatformFilter(id)); + router.history.back(); + }, + }); + const settingsOptions: DialogEntry[] = []; + if (source === 'local' || platform?.hasLocal) + { + settingsOptions.push({ + id: 'update-platform', + type: "primary", + content: "Update Platform", + icon: updatePlatform.isPending ? : , + async action (ctx) + { + await updatePlatform.mutateAsync(); + ctx.close(); + router.navigate({ replace: true }); + }, + }); + } + + if (source === 'local') + { + settingsOptions.push({ + id: 'delete-platform', + type: "error", + content: "Delete", + icon: deletePlatform.isPending ? : , + action (ctx) + { + deletePlatform.mutateAsync(); + }, + }); + } return (
    , + action () + { + globalDialog.openContext({ content: }, 'open-platform-settings-btn'); + }, + }]} + countHint={countHint} title={} filters={{ platform_id: Number(id), platform_source: source }} /> diff --git a/src/mainview/routes/settings/about.tsx b/src/mainview/routes/settings/about.tsx index d30b291..da450d4 100644 --- a/src/mainview/routes/settings/about.tsx +++ b/src/mainview/routes/settings/about.tsx @@ -1,5 +1,6 @@ +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { systemInfoQuery } from '@queries/system'; import { useQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; @@ -12,58 +13,68 @@ 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' }); + + + 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}
    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/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index 7c0faf5..eacd432 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -5,15 +5,16 @@ import useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; import classNames from "classnames"; -import { Key, Link, Lock, LogIn, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react"; +import { Info, Key, Link, Lock, LogIn, LogOut, ScanQrCode, User, X } from "lucide-react"; import { useEffect, useRef, } from "react"; -import { RommLoginDataSchema, RPC_URL } from "@shared/constants"; +import { RPC_URL } from "@shared/constants"; +import { RommLoginDataSchema } from '@simeonradivoev/gameflow-sdk/shared'; import toast from "react-hot-toast"; import { OptionSpace } from "../../components/options/OptionSpace"; import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm"; @@ -24,11 +25,15 @@ import { useJobStatus } from "@/mainview/scripts/utils"; import { useInterval } from "usehooks-ts"; import { TwitchIcon } from "@/mainview/scripts/brandIcons"; import { twitchLoginMutation, twitchLoginVerificationQuery, twitchLogoutMutation } from "@queries/settings"; -import { rommGetOptionsQuery, rommLoggedInQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery } from "@queries/romm"; +import { rommGetOptionsQuery, rommLoggedInQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery, invalidateLogin } from "@queries/romm"; import { systemApi } from "@/mainview/scripts/clientApi"; +import z from "zod"; export const Route = createFileRoute("/settings/accounts")({ component: RouteComponent, + validateSearch: z.object({ + focus: z.string().optional() + }), }); function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; startedAt: Date; code?: string; }) @@ -59,10 +64,7 @@ function TwitchLogin () { const loginStatus = useQuery(twitchLoginVerificationQuery); - const loginMutation = useMutation({ - ...twitchLoginMutation, - onSuccess: () => loginStatus.refetch() - }); + const loginMutation = useMutation(twitchLoginMutation); const logoutMutation = useMutation({ ...twitchLogoutMutation, onSuccess: () => loginStatus.refetch() }); @@ -90,6 +92,7 @@ function TwitchLogin () function LoginControls (data: {}) { const user = useQuery(rommUserQuery); + const router = useRouter(); const loginMutation = useMutation(rommQrLoginMutation); const { data: statusValue, wsRef } = useJobStatus('login-job'); const { data: loginStatusData } = useQuery(rommLoggedInQuery); @@ -99,8 +102,9 @@ function LoginControls (data: {}) ...rommLogoutMutation, onSuccess: async (d, v, r, c) => { - user.refetch(); - c.client.invalidateQueries({ queryKey: ["romm", "auth"] }); + await user.refetch(); + await invalidateLogin(c.client); + await router.navigate({ replace: true }); } }); return
    @@ -171,7 +175,7 @@ function RouteComponent () { if (focus) { - focusSelf(); + focusSelf({ instant: true }); } }, [focus]); @@ -222,6 +226,8 @@ function RouteComponent () } type="text" />} /> } type="password" placeholder="Password" />} /> + +
    For Romm Client API Token open plugin settings
    } /> diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index 5a32eec..5adee26 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -13,9 +13,13 @@ import { systemApi } from '@/mainview/scripts/clientApi'; import useActiveControl from '@/mainview/scripts/gamepads'; import { changeDownloadsMutation } from '@queries/settings'; import { downloadDrivesQuery } from '@/mainview/scripts/queries/system'; +import { DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; export const Route = createFileRoute('/settings/directories')({ component: RouteComponent, + validateSearch: zodValidator(z.object({ focus: z.string().optional() })) }); function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; refetchDrives: () => void; }) diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 4d7c177..17344df 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -1,14 +1,15 @@ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; import { OptionSpace } from '../../components/options/OptionSpace'; import { OptionInput } from '../../components/options/OptionInput'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { JSX, useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Button } from '../../components/options/Button'; -import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Store, Trash, TriangleAlert } from 'lucide-react'; +import { Check, ChevronDown, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react'; import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; import { RPC_URL } from '../../../shared/constants'; +import { SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import emulators from '@emulators'; import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; @@ -19,11 +20,16 @@ import Carousel from '@/mainview/components/Carousel'; import { FOCUS_KEYS } from '@/mainview/scripts/types'; import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils'; import { SettingsOption } from '@/mainview/components/options/SettingsOption'; -import { Router } from '@/mainview'; +import { SettingsDropdown } from '@/mainview/components/options/SettingsDropdown'; +import { FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; +import { isUrl } from '@/shared/utils'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, pendingComponent: EmulatorsPending, + validateSearch: zodValidator(z.object({ focus: z.string().optional() })) }); function EmulatorsPending () @@ -76,11 +82,14 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd const handleCloseContext = () => { setNewEmulatorTypeOpen(false); - setFocus('emulator'); + setFocus('emulator', { instant: true }); }; - return + return +
    Custom Emulator Path
    +
    Manually Pick a path to an emulator if not automatically found.
    +
    }> + ; +} + +function PluginOption (data: { name: string, title?: string, prop: JSONSchema7; }) +{ + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); + const { data: value, refetch: refetchValue } = useQuery(getPluginSettingQuery(source, data.name)); + const setValue = useMutation({ + ...setPluginSettingMutation(source, data.name), + onError (error, variables, onMutateResult, context) + { + toast.error(error.message); + }, + onSuccess (data, variables, onMutateResult, context) + { + refetchValue(); + }, + }); + let input: any = undefined; + switch (data.prop.type) + { + case "string": + if (Array.isArray(data.prop.examples)) + { + input = !!e).map(e => e!.toString())} onChange={v => setValue.mutate(v)} value={value?.value as any} />; + } else + { + input = setValue.mutate(v)} type="text" name={data.name} />; + } + break; + + case "boolean": + input = setValue.mutate(v)} type='checkbox' name={data.name} />; + break; + } + return +
    {data.title ?? data.name}
    +
    {data.prop.description}
    +
    }> + {input} + ; +} + +function Settings (data: { update: PluginUpdateCheck | undefined; }) +{ + const { definitions, actions } = Route.useLoaderData(); + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const update = useMutation({ + ...updatePluginMutation(source), + onSuccess (data, variables, onMutateResult, context) + { + context.client.invalidateQueries(allPluginsFilter); + navigate({ to: '/settings/plugin/$source', params: { source: encodeURIComponent(source) }, replace: true }); + }, + }); + const handleReload = () => + { + queryClient.refetchQueries(getPluginSettingsDefinitionQuery(source)); + queryClient.refetchQueries(getPluginActionsQuery(source)); + }; + const { ref, focusKey } = useFocusable({ + focusKey: 'plugin-settings', + focusable: (definitions?.properties && Object.keys(definitions?.properties).length > 0) || actions.length > 0 || !!data.update + }); + return
    + + {!!definitions?.properties && Object.entries(Object.groupBy(Object.entries(definitions?.properties) + .filter(([key, prop]) => typeof prop === 'object'), ([key, prop]) => + { + const schema = prop as JSONSchema7; + if (schema.$comment) + { + const meta = JSON.parse(schema.$comment); + return meta.category; + } + return "settings"; + })).map(([cat, data]) => + { + return
    +
    {cat !== "settings" ? cat : <> Settings}
    + {data?.map(([key, prop]) => + { + const schema = prop as JSONSchema7; + return ; + })} +
    ; + + })} +
    Actions
    + {!!data.update && +
    Update
    +
    {data?.update?.current} {'>'} {data?.update?.new}
    +
    }> + + } + {actions?.map(a => )} + +
    ; +} + +function RouteComponent () +{ + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); + + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugins' }); + const { data } = useQuery(getPluginDetailsQuery(source)); + const navigate = useNavigate(); + const handleReturn = () => navigate({ to: '/settings/plugins', replace: true, viewTransition: { types: ['slide-up'] } }); + useShortcuts(focusKey, () => [{ label: "Return", button: GamePadButtonCode.B, action: handleReturn }]); + + return
    + + +
    +
    + + +
    {data?.displayName}
    +
    {data?.version}
    + {!!data?.update &&
    {data?.update.new}
    } +
    +
      {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 7b1a8da..c55fc8f 100644 --- a/src/mainview/routes/settings/plugins.tsx +++ b/src/mainview/routes/settings/plugins.tsx @@ -1,10 +1,15 @@ -import { Button } from '@/mainview/components/options/Button'; +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import { pluginCategoryIcons, pluginCategoryPriorities } from '@/mainview/components/Constants'; import { OptionInput } from '@/mainview/components/options/OptionInput'; import { OptionSpace } from '@/mainview/components/options/OptionSpace'; -import { enablePluginMutation, getAllPluginsQuery } from '@/mainview/scripts/queries/plugins'; +import { RoundButton } from '@/mainview/components/RoundButton'; +import { enablePluginMutation, getAllPluginsQuery, uninstallPluginMutation } from '@/mainview/scripts/queries/plugins'; +import { GamePadButtonCode, Shortcut } from '@/mainview/scripts/shortcuts'; +import { FrontendPlugin } from '@simeonradivoev/gameflow-sdk/shared'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { createFileRoute } from '@tanstack/react-router'; -import { Puzzle, Search } from 'lucide-react'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { CircleFadingArrowUp, Eye, Puzzle, Settings2, Trash } from 'lucide-react'; export const Route = createFileRoute('/settings/plugins')({ component: RouteComponent, @@ -19,23 +24,52 @@ function Plugin (data: { setEnabled: (enabled: boolean) => void; }) { - return -
    - {data.plugin.icon ? : } + const shortcuts: Shortcut[] = []; + const navigate = useNavigate(); + if (data.plugin.hasSettings) + shortcuts.push({ + button: GamePadButtonCode.Y, label: "Details", action (e) + { + + }, + }); + const uninstall = useMutation(uninstallPluginMutation(data.plugin.name)); + const handleUninstall = () => uninstall.mutate(); + const handleDetails = () => navigate({ to: '/settings/plugin/$source', params: { source: encodeURIComponent(data.plugin.name) }, replace: true, viewTransition: { types: ['slide-up'] } }); + + return +
    + {data.plugin.icon ? : } +
    +
    +
    {data.plugin.displayName ?? data.plugin.name}
    +
    +
    {data.plugin.name} ({data.plugin.version})
    + {data.plugin.hasSettings && } + {data.plugin.update &&
    + +
    } +
    +
    +
    + } + className='flex p-4 bg-base-200 rounded-3xl scroll-m-12' + shortcuts={shortcuts} + > +
    + {data.plugin.hasSettings ? : } + {data.plugin.canUninstall && {uninstall.isPending ? : }} + {data.plugin.canDisable && data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />}
    -
    -
    {data.plugin.displayName}
    -
    {data.plugin.name} ({data.plugin.version})
    -
    -
    } className='flex p-4 bg-base-200 rounded-3xl'> - - ; } function RouteComponent () { const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugins' }); const pluginMutation = useMutation({ ...enablePluginMutation, onSuccess (data, variables, onMutateResult, context) { @@ -43,15 +77,21 @@ function RouteComponent () }, }); - return <> - {!!plugins && Object.entries(Object.groupBy(plugins, p => p.source)).map(([source, plugins]) => - { - return <> -
    {source === 'builtin' ? "Built In" : "Store"}
    -
    - {plugins.map(p => pluginMutation.mutate({ id: p.name, enabled: v })} />)} -
    - ; - })} - ; + return
    + + {!!plugins && Object.entries(Object.groupBy(plugins, p => p.category)) + .filter(([cat, plugins]) => !!plugins) + .toSorted(([catA], [catB]) => pluginCategoryPriorities[catB] - pluginCategoryPriorities[catA]) + .map(([cat, plugins]) => + { + return
    +
    {pluginCategoryIcons[cat]}{cat}
    +
    + {plugins!.map(p => pluginMutation.mutate({ id: p.name, enabled: v })} />)} +
    +
    ; + })} + +
    +
    ; } diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index c6e8198..625e884 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -7,58 +7,69 @@ import { Outlet, createFileRoute, - useMatch, + useMatchRoute, + useRouter, + useRouterState, } from "@tanstack/react-router"; import { ViewTransitionOptions } from "@tanstack/router-core"; import classNames from "classnames"; import { ArrowBigLeft, + Cog, FingerprintPattern, HardDrive, Info, Joystick, MonitorCog, Puzzle, + RefreshCcw, } from "lucide-react"; -import { JSX, useEffect } from "react"; +import { JSX, useMemo } from "react"; import { twMerge } from "tailwind-merge"; -import z from "zod"; -import { SettingsSchema } from "../../../shared/constants"; -import { Router } from "../.."; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; +import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import Shortcuts from "@/mainview/components/Shortcuts"; import { HandleGoBack } from "@/mainview/scripts/utils"; +import { AutoFocus } from "@/mainview/components/AutoFocus"; +import { oneShot } from "@/mainview/scripts/audio/audio"; +import SelectMenu from "@/mainview/components/SelectMenu"; export const Route = createFileRoute("/settings")({ component: SettingsUI, - validateSearch: z.object({ - focus: z.keyof(SettingsSchema).optional() - }) + staticData: { + enterSound: 'openSettings' + } }); function MenuItem (data: { route: string; + matchRoutes?: string[]; return?: boolean; viewTransition?: boolean | ViewTransitionOptions; icon: JSX.Element; focusSelect?: boolean; className?: string; linkClassName?: string; + active?: boolean; label: string; }) { - const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });; - const handleNonFocusSelect = () => + const router = useRouter(); + const routerState = useRouterState(); + const matchRoute = useMatchRoute(); + + const acitve = useMemo(() => data.matchRoutes ? data.matchRoutes.some(r => !!matchRoute({ to: r })) : !!router.matchRoute({ to: data.route }), + [routerState, matchRoute, data.matchRoutes, data.route]); + const handleNonFocusSelect = (e?: Event) => { if (data.return) { - HandleGoBack(); + HandleGoBack(router, e); } else if (!acitve) { - Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); + router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); } - + oneShot('click'); }; const { ref, focusSelf } = useFocusable({ focusKey: `menu-item-${data.route}`, @@ -67,7 +78,7 @@ function MenuItem (data: { { if (data.focusSelect && !acitve) { - Router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); + router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true }); } (ref.current as HTMLElement).scrollIntoView({ inline: 'center' }); }, @@ -81,7 +92,8 @@ function MenuItem (data: {
  • handleNonFocusSelect(e.nativeEvent)} onFocus={focusSelf} className={twMerge("flex group-focusable cursor-pointer", data.className)} > @@ -106,10 +118,11 @@ function MenuItem (data: { function SettingsMenu (data: {}) { + const router = useRouter(); const { ref, focusKey } = useFocusable({ focusable: true, focusKey: 'settings-menu', - preferredChildFocusKey: location.hash.replaceAll(/#|(\?.+)/g, '') + preferredChildFocusKey: `menu-item-${router.history.location.pathname}` }); return
      } /> + } + /> + } + /> } + route="/settings/update" + label="Updates" + icon={} /> - { - focusSelf(); - }, []); - - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); - const { shortcuts } = useShortcutContext(); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); return ( @@ -192,10 +213,14 @@ export function SettingsUI ()
  • -
    - +
    +
    + } />
    + + ); } diff --git a/src/mainview/routes/settings/tasks.tsx b/src/mainview/routes/settings/tasks.tsx new file mode 100644 index 0000000..afc6f54 --- /dev/null +++ b/src/mainview/routes/settings/tasks.tsx @@ -0,0 +1,123 @@ +import { Button } from '@/mainview/components/options/Button'; +import { jobsApi } from '@/mainview/scripts/clientApi'; +import { FrontEndJob } from '@simeonradivoev/gameflow-sdk/shared'; +import { createFileRoute } from '@tanstack/react-router'; +import { Ban, Clock, Cog, Download, DownloadCloud, Gauge } from 'lucide-react'; +import prettyBytes from 'pretty-bytes'; +import { useEffect, useRef, useState } from 'react'; + +export const Route = createFileRoute('/settings/tasks')({ + component: RouteComponent, +}); + +function RouteComponent () +{ + + const [activeJobs, setActiveJobs] = useState([]); + const [queuedJobs, setQueuedJobs] = useState([]); + const wsRef = useRef<{ send: (data: any) => void; }>(null); + + useEffect(() => + { + const sub = jobsApi.api.jobs.list.subscribe(); + wsRef.current = { + send (data) + { + sub.ws.send(JSON.stringify(data)); + }, + }; + sub.on('message', e => + { + switch (e.data.type) + { + case 'allJobs': + setActiveJobs(e.data.active); + setQueuedJobs(e.data.queued); + break; + + case 'aborted': + const abortedJobId = e.data.id; + setActiveJobs(jobs => jobs.map(j => j.id === abortedJobId ? { ...j, status: 'aborted' } : j)); + setQueuedJobs(jobs => jobs.filter(j => j.id !== abortedJobId)); + break; + + case 'queued': + const queuedJob = e.data.job; + setQueuedJobs(jobs => [...jobs, queuedJob]); + break; + + case 'progress': + const progressJob = e.data.job; + setActiveJobs(jobs => jobs.map(j => j.id === progressJob.id ? progressJob : j)); + break; + + case 'started': + const newJob = e.data.job; + setActiveJobs(jobs => [newJob, ...jobs]); + setQueuedJobs(jobs => jobs.filter(j => j.id !== newJob.id)); + break; + + case 'ended': + const endedJobId = e.data.id; + setActiveJobs(jobs => jobs.filter(j => j.id !== endedJobId)); + break; + } + }); + + return () => + { + sub.close(); + wsRef.current = null; + }; + }, []); + + const handleCancel = (id: string) => + { + wsRef.current?.send({ type: 'cancel', id: id }); + }; + + return
    +
    Active
    +
      + {activeJobs.map((job, i) =>
    • +
      +
      + {job.data.preview_url ? : } +
      +
      {job.data.name ?? job.id}
      +
      +
      +
      +
      +
      {job.state}
      +
      {job.progress.toFixed(1)}%
      +
      + +
      + {job.data.downloaded != null && job.data.total != null &&
      {prettyBytes(job.data.downloaded)}/{prettyBytes(job.data.total)}
      } + {job.data.speed != null &&
      {prettyBytes(job.data.speed)}/s
      } +
      +
      + +
      +
    • )} +
    +
    Queued
    +
      + {queuedJobs.map((job, i) =>
    • +
      +
      +
      {job.data.name ?? job.id}
      +
      +
      +
      +
      + {job.data.total !== undefined &&
      {prettyBytes(job.data.total)}
      } +
      +
      + +
      +
    • )} +
    +
    ; +} diff --git a/src/mainview/routes/settings/update.tsx b/src/mainview/routes/settings/update.tsx new file mode 100644 index 0000000..3f27639 --- /dev/null +++ b/src/mainview/routes/settings/update.tsx @@ -0,0 +1,75 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import DotsLoading from '@/mainview/components/backgrounds/dots'; +import { Button } from '@/mainview/components/options/Button'; +import { checkUpdateMutation, hasUpdateQuery, updateMutation } from '@/mainview/scripts/queries/system'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { useMutation } from '@tanstack/react-query'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { CircleFadingArrowUp, RefreshCcw } from 'lucide-react'; +import { MarkdownAsync } from 'react-markdown'; + +export const Route = createFileRoute('/settings/update')({ + component: RouteComponent, + pendingComponent: Loading, + async loader (ctx) + { + const data = await ctx.context.queryClient.fetchQuery(hasUpdateQuery); + return { data: data }; + }, +}); + +function Loading () +{ + const { ref, focusSelf } = useFocusable({ focusKey: 'updates' }); + return <> + + + ; +} + +function RouteComponent () +{ + const { data } = Route.useLoaderData(); + const navigate = useNavigate(); + const update = useMutation(updateMutation); + const forceCheckUpdate = useMutation({ + ...checkUpdateMutation, + onSuccess (data, variables, onMutateResult, context) + { + context.client.invalidateQueries(hasUpdateQuery); + navigate({ to: '/settings/update', replace: true }); + }, + }); + const { ref, focusKey } = useFocusable({ focusKey: 'updates' }); + return
    + +

    Version: {data.version}

    +
    + { + data.hasUpdate > 0 ? + : + + } + {} +
    +
    Version Info
    +
    + {children}; + }, + }} >{data.info} +
    +
    +
    ; +} diff --git a/src/mainview/routes/store/details.download.$source.$id.tsx b/src/mainview/routes/store/details.download.$source.$id.tsx new file mode 100644 index 0000000..2100442 --- /dev/null +++ b/src/mainview/routes/store/details.download.$source.$id.tsx @@ -0,0 +1,129 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import DotsLoading from '@/mainview/components/backgrounds/dots'; +import { ContextList, DialogEntry } from '@/mainview/components/ContextDialog'; +import { StickyHeaderUI } from '@/mainview/components/Header'; +import { Button } from '@/mainview/components/options/Button'; +import Screenshots from '@/mainview/components/Screenshots'; +import SelectMenu from '@/mainview/components/SelectMenu'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import { GlobalDialogContext } from '@/mainview/scripts/contexts'; +import { downloadLookupQuery } from '@/mainview/scripts/queries/romm'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { Download } from 'lucide-react'; +import prettyBytes from 'pretty-bytes'; +import { useContext } from 'react'; + +export const Route = createFileRoute('/store/details/download/$source/$id')({ + component: RouteComponent, + pendingComponent: Loading, + async loader (ctx) + { + const data = await ctx.context.queryClient.fetchQuery(downloadLookupQuery(decodeURIComponent(ctx.params.source), decodeURIComponent(ctx.params.id))); + return { data }; + } +}); + +function Loading () +{ + const { ref, focusSelf } = useFocusable({ focusKey: 'download-details' }); + return <> + + + ; +} + +const imagesMap = new Set(['JPEG', 'PNG', 'Motion JPEG', 'Item Image']); +const videoFormat = new Set(['h.264']); +const downloadsBlacklist = new Set(['JPEG Thumb', 'Metadata', 'Thumbnail', 'Item Tile', 'Archive BitTorrent', ...videoFormat, ...imagesMap]); + +function Details (data: { onDownload: (focusKey: string) => void; }) +{ + const { data: download } = Route.useLoaderData(); + const screenshots = download.files.filter(f => f.format && imagesMap.has(f.format)).map(f => f.download_url); + if (screenshots.length <= 0 && download.cover_url) screenshots.push(download.cover_url); + return
    + +
    +
    +
    + {!!download.cover_url && } +
    +
    {download.name}
    +
    +
    {download.date?.toDateString()}
    +
    +
    {download.source}
    +
    +
    +
    +
    + +
    +
    +
    + {!!download.summary &&
    +
    +
    } +
    +
    Downloads
    +
      + {download.files.filter(f => f.format && !downloadsBlacklist.has(f.format)).map(f =>
    • + {f.id} + {!!f.size && prettyBytes(f.size)} +
    • )} +
    +
    + +
    +
    +
    ; +} + +function RouteComponent () +{ + const navigate = useNavigate(); + const router = useRouter(); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'download-details', preferredChildFocusKey: 'download-btn' }); + const { data } = Route.useLoaderData(); + const globalDialog = useContext(GlobalDialogContext); + + useShortcuts(focusKey, () => [{ + label: "Return", + action: (e) => HandleGoBack(router, e), + button: GamePadButtonCode.B + }], [router]); + + return
    + + +
    globalDialog.openContext({ + content: f.format && !downloadsBlacklist.has(f.format)).map(f => + { + const option: DialogEntry = { + id: f.id, + content: f.id, + type: 'primary', + action (ctx) + { + navigate({ + to: '/game/add', search: { + gameLocation: f.download_url, + search: data.name, + step: 1 + } + }); + }, + }; + + return option; + })} /> + }, focusKey)} /> + + + + +
    ; +} diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 1593bd9..d8c8372 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -1,18 +1,17 @@ -import { useEffect, useRef, useState } from "react"; +import { useContext, useRef, useState } from "react"; import { useFocusable, FocusContext, } from "@noriginmedia/norigin-spatial-navigation"; -import { createFileRoute } from "@tanstack/react-router"; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { Router } from "@/mainview"; -import Shortcuts from "@/mainview/components/Shortcuts"; +import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router"; +import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; +import { FloatingShortcuts } from "@/mainview/components/Shortcuts"; import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; -import { systemApi } from "@/mainview/scripts/clientApi"; +import { rommApi, systemApi } from "@/mainview/scripts/clientApi"; import { Button } from "@/mainview/components/options/Button"; -import { ChevronDown, Cpu, Download, Gamepad2, Info, Puzzle, Settings, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; -import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; +import { ChevronDown, CircleFadingArrowUp, CloudUpload, Cpu, Download, Fullscreen, Gamepad2, Info, Monitor, Puzzle, Settings, Settings2, Terminal, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; +import { ContextList, DialogEntry } from "@/mainview/components/ContextDialog"; import { RPC_URL } from "@/shared/constants"; import Screenshots from "@/mainview/components/Screenshots"; import { StickyHeaderUI } from "@/mainview/components/Header"; @@ -27,6 +26,11 @@ import { GamesSection } from "@/mainview/components/store/GamesSection"; import { deleteBiosMutation, downloadBiosMutation, installEmulatorMutation, storeEmulatorDeleteMutation, storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@queries/store"; import { gamesRecommendedBasedOnEmulatorQuery } from "@queries/romm"; import FocusTooltip from "@/mainview/components/FocusTooltip"; +import { AutoFocus } from "@/mainview/components/AutoFocus"; +import { FilterUI } from "@/mainview/components/Filters"; +import Markdown from "react-markdown"; +import { FrontEndEmulatorDetailed } from "@simeonradivoev/gameflow-sdk/shared"; +import { GlobalDialogContext } from "@/mainview/scripts/contexts"; export const Route = createFileRoute('/store/details/emulator/$id')({ component: RouteComponent, @@ -35,6 +39,10 @@ export const Route = createFileRoute('/store/details/emulator/$id')({ ctx.context.queryClient.prefetchQuery(storeEmulatorDetailsQuery(ctx.params.id)); ctx.context.queryClient.prefetchQuery(storeEmulatorsRecommendedQuery(ctx.params.id)); ctx.context.queryClient.prefetchQuery(gamesRecommendedBasedOnEmulatorQuery(ctx.params.id)); + }, + staticData: { + enterSound: "openDetails", + goBackSound: "returnDetails" } }); @@ -55,8 +63,11 @@ function HomePageLink (data: { homepage?: string; }) function TitleArea (data: { emulator?: FrontEndEmulatorDetailed; onInstall: (source: string) => void; + onUpdate: (source: string) => void; }) { + const globalDialog = useContext(GlobalDialogContext); + const navigation = useNavigate(); const queryClient = useQueryClient(); const deleteMutation = useMutation({ ...storeEmulatorDeleteMutation, @@ -66,6 +77,7 @@ function TitleArea (data: { }, }); const downloadBios = useMutation(downloadBiosMutation(data.emulator?.name ?? '')); + const updateToVersion = data.emulator?.downloads.find(d => d.version === data.emulator!.storeDownloadInfo?.type)?.version ?? data.emulator?.downloads[0]?.version; const deleteBios = useMutation({ ...deleteBiosMutation, onSuccess (data, variables, onMutateResult, context) @@ -118,7 +130,7 @@ function TitleArea (data: { const isInstalling = !!installJob || !!biosInstallJob; const options: DialogEntry[] = []; - const installedFromStore = !!data.emulator?.sources.find(s => s.type === 'store' && s.exists); + const installedFromStore = !!data.emulator?.validSources.find(s => s.type === 'store' && s.exists); if (data.emulator) { if (!isInstalling && !installedFromStore) @@ -151,6 +163,22 @@ function TitleArea (data: { id: "delete" }); + if ((!data.emulator.storeDownloadInfo || data.emulator.storeDownloadInfo.hasUpdate)) + { + options.push({ + content: `Update ${data.emulator.storeDownloadInfo?.type}: ${data.emulator.storeDownloadInfo?.version ?? "Unknown"} > ${updateToVersion}`, + type: 'warning', + icon: , + action (ctx) + { + const source = data.emulator?.storeDownloadInfo?.type ?? data.emulator?.downloads[0]?.type; + if (source) data.onUpdate(source); + ctx.close(); + }, + id: 'update' + }); + } + if (!data.emulator.bios || data.emulator.bios.length <= 0) { options.push({ @@ -179,8 +207,16 @@ function TitleArea (data: { id: "download-bios" }); } - } + + options.push(...data.emulator.validSources.filter(s => s.exists).map(s => ({ + content: `Launch: ${s.type}`, type: 'primary', icon: emulatorStatusIcons[s.type], action (ctx) + { + if (!data.emulator) return; + rommApi.api.romm.game({ source: 'emulator' })({ id: data.emulator.name }).play.post({ command_id: s.type }); + navigation({ to: '/launcher/$source/$id', params: { source: 'emulator', id: data.emulator.name } }); + }, id: `open-${s.type}` + } satisfies DialogEntry))); } const { ref, focusKey, hasFocusedChild } = useFocusable({ @@ -219,14 +255,12 @@ function TitleArea (data: { installButtonContent = <>Unsupported; } - const { dialog: installOptionsDialog, setOpen } = useContextDialog("install-context-menu", { - content: - }); + const openOptionsDialog = (focusKey: string) => globalDialog.openContext({ content: }, focusKey); const handleOptionsOpen = () => { - if (isInstalling || !data.emulator || data.emulator.downloads.length <= 0) return false; - setOpen(true, 'install-btn'); + if (isInstalling || !data.emulator) return false; + openOptionsDialog('install-btn'); }; return
    @@ -249,15 +283,21 @@ function TitleArea (data: { {!!data.emulator?.bios?.[0] &&
    } - {data.emulator && !!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') &&
    + {data.emulator && data.emulator.integrations.length > 0 &&
    } + {data.emulator?.integrations.some(s => s.capabilities?.includes('saves')) &&
    +
    +
    }
    + {(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion &&
    + +
    } {(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore &&
    - +
    }
    - {installOptionsDialog}
    ; } @@ -285,10 +324,28 @@ function Description (data: { emulator?: FrontEndEmulatorDetailed; }) ; } +const capabilityIconMap: Record = { + saves: , + fullscreen: , + resolution: , + config: , + batch: +}; + +function InfoTabs (data: { tabs: Record, selectTab: (v: string) => void; }) +{ + const { ref, focusKey } = useFocusable({ focusKey: 'emulator-info-tabs-section', onFocus: (l, p, d) => scrollIntoViewHandler({ block: 'start' })(focusKey, ref.current, d) }); + return
    + + data.selectTab(v)} /> + +
    ; +} + export function RouteComponent () { const { id } = Route.useParams(); - + const router = useRouter(); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: `GAME_DETAIL_${id}`, trackChildren: true, @@ -298,54 +355,72 @@ export function RouteComponent () const { data: emulator, isPending: isEmulatorPending } = useQuery(storeEmulatorDetailsQuery(id)); const { data: recommendedEmulators } = useQuery(storeEmulatorsRecommendedQuery(id)); const { data: recommendedGames } = useQuery(gamesRecommendedBasedOnEmulatorQuery(id)); + const [infoTab, setInfoTab] = useState("stats"); useShortcuts(focusKey, () => [{ label: "Return", - action: HandleGoBack, + action: (e) => HandleGoBack(router, e), button: GamePadButtonCode.B - }]); + }], [router]); const installMutation = useMutation({ - ...installEmulatorMutation(id), onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), + ...installEmulatorMutation(id), + onSuccess: (data, variables, onMutateResult, context) => context.client.refetchQueries(storeEmulatorDetailsQuery(id)), }); - useEffect(() => - { - focusSelf(); - }, []); - - const { shortcuts } = useShortcutContext(); - - const stats: StatEntry[] = []; + if (emulator) { if (emulator.keywords) stats.push({ label: "Tags", content: emulator.keywords }); + if (emulator.storeDownloadInfo) + stats.push({ label: "Version", content: `${emulator.storeDownloadInfo.version ?? "Unknown"} (${emulator.storeDownloadInfo.type})` }); stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) }); - stats.push(...emulator.sources.flatMap(s => [{ - label: "Source", content:
    -
    {emulatorStatusIcons[s.type]}{s.type}:
    -
    {s.binPath}
    + stats.push(...emulator.validSources.flatMap(s => [{ + label: "Source", content:
    +
    +
    {emulatorStatusIcons[s.type]}{s.type}
    +
    {s.binPath}
    +
    + {emulator.integrations.some(i => i.source?.type === s.type) &&
    } + {emulator.integrations.filter(i => i.source?.type === s.type).map(i => + { + return
    +
    + +
    {i.id}
    +
    +
    + {i.capabilities?.map(c => <>
    {capabilityIconMap[c]}{c}
    )} +
    +
    ; + })}
    }])); if (emulator.bios) stats.push({ label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios :
    Missing
    }); - if (emulator.integration) - { - stats.push({ label: "Integration", icon: , content: `${emulator.integration.name} (${emulator.integration.version})` }); - } + } + + const infoTabs: Record = { + stats: { label: "Stats", selected: infoTab === 'stats', icon: }, + }; + + if (emulator?.storeDownloadInfo?.hasUpdate) + { + infoTabs.update = { label: "Update", icon: , selected: infoTab === 'update' }; } return ( +
    - + installMutation.mutate({ source: s, isUpdate: false })} onUpdate={s => installMutation.mutate({ source: s, isUpdate: true })} />
    @@ -357,8 +432,9 @@ export function RouteComponent ()
    -
    Stats
    - + + {infoTab === 'stats' && } + {infoTab === 'update' && {emulator?.storeDownloadInfo?.description}} {recommendedEmulators &&
    } onFocus={scrollIntoViewHandler({ block: 'center' })} - onSelect={(id, focus) => + onSelect={(em, focus) => { - Router.navigate({ - to: '/store/details/emulator/$id', params: { id } + if (em.source === 'local') return; + router.navigate({ + to: '/store/details/emulator/$id', params: { id: em.name } }); }} emulators={recommendedEmulators} /> @@ -386,13 +463,13 @@ export function RouteComponent ()
    { - Router.navigate({ + router.navigate({ to: '/game/$source/$id', params: { id: id.id, source: id.source } }); }} games={recommendedGames} />
    }
    - +
    diff --git a/src/mainview/routes/store/details.plugin.$id.tsx b/src/mainview/routes/store/details.plugin.$id.tsx new file mode 100644 index 0000000..5e5168c --- /dev/null +++ b/src/mainview/routes/store/details.plugin.$id.tsx @@ -0,0 +1,161 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import DotsLoading from '@/mainview/components/backgrounds/dots'; +import { StickyHeaderUI } from '@/mainview/components/Header'; +import LoadingScreen from '@/mainview/components/LoadingScreen'; +import { Button } from '@/mainview/components/options/Button'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import StatList, { StatEntry } from '@/mainview/components/StatList'; +import { installPluginMutation, pluginFilter, uninstallPluginMutation, updatePluginMutation } from '@/mainview/scripts/queries/plugins'; +import { pluginDetailsQuery } from '@/mainview/scripts/queries/store'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { QueryClient, useMutation } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { ArrowRight, CircleFadingArrowUp, Download, Settings, Trash } from 'lucide-react'; +import prettyBytes from 'pretty-bytes'; +import { Suspense } from 'react'; + +export const Route = createFileRoute('/store/details/plugin/$id')({ + component: RouteComponent, + pendingComponent: Loading, + async loader (ctx) + { + const id = decodeURIComponent(ctx.params.id); + const data = await ctx.context.queryClient.fetchQuery(pluginDetailsQuery(id)); + return { data }; + }, +}); + +function Loading () +{ + const { ref, focusSelf } = useFocusable({ focusKey: 'plugin-details' }); + return <> + + + ; +} + +function Details () +{ + const { id } = Route.useParams(); + const plugin = decodeURIComponent(id); + const { data } = Route.useLoaderData(); + const navigate = useNavigate(); + const handleRefresh = (client: QueryClient) => + { + client.invalidateQueries(pluginFilter(plugin)); + navigate({ to: '/store/details/plugin/$id', params: { id: encodeURIComponent(id) }, replace: true }); + }; + const update = useMutation({ + ...updatePluginMutation(plugin), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + const install = useMutation({ + ...installPluginMutation(plugin), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + const uninstall = useMutation({ + ...uninstallPluginMutation(plugin), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + + const stats: StatEntry[] = []; + if (data.devDependencies) + { + stats.push({ content: Object.keys(data.devDependencies), label: "Dev Dependecies" }); + } + if (data.dependencies) + { + stats.push({ content: Object.keys(data.dependencies), label: "Dependecies" }); + } + if (data.maintainers) + { + stats.push({ content: data.maintainers.map(m => m.name), label: "Maintainers" }); + } + if (data.dist) + { + stats.push({ content: prettyBytes(data.dist.unpackedSize), label: "Size" }); + } + if (data.license) + { + stats.push({ content: data.license, label: "License" }); + } + return <> + +
    +
    +
    {data.name}
    +
    +
    + {data.update ? <> +
    {data.update.from}
    + +
    {data.version}
    + : +
    {data.version}
    } + +
    + by {data.author?.name ?? data._npmUser?.name}
    +
    +
    + {data.installed && <> + {!!data.update && } + + + + } + {!data.installed && } + +
    +
    +
    Details
    +
    +
    {data.description}
    + +
    +
    Keywords
    +
    + {data.keywords.map(k =>
  • {k}
  • )} +
    + ; +} + +function RouteComponent () +{ + const router = useRouter(); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugin-details' }); + useShortcuts(focusKey, () => [{ + label: "Return", button: GamePadButtonCode.B, action (e) + { + HandleGoBack(router, e); + }, + }]); + return
    + + + }> +
    + + + + +
    ; +} diff --git a/src/mainview/routes/store/tab/download.tsx b/src/mainview/routes/store/tab/download.tsx new file mode 100644 index 0000000..062698a --- /dev/null +++ b/src/mainview/routes/store/tab/download.tsx @@ -0,0 +1,109 @@ +import DotsLoading from '@/mainview/components/backgrounds/dots'; +import LoadMoreButton from '@/mainview/components/LoadMoreButton'; +import { SideDownloadFilters } from '@/mainview/components/SideFilters'; +import { downloadLookupFiltersQuery, downloadsLookupQuery } from '@/mainview/scripts/queries/romm'; +import { scrollIntoViewHandler } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { DownloadLookupEntry, DownloadsLookupFilter } from '@simeonradivoev/gameflow-sdk/shared'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { DownloadIcon, Eye, MessageCircle, Save, Star } from 'lucide-react'; +import prettyBytes from 'pretty-bytes'; +import { useSessionStorage } from 'usehooks-ts'; + +export const Route = createFileRoute('/store/tab/download')({ + component: RouteComponent, +}); + +function Download (data: { focusKey: string, match: DownloadLookupEntry; }) +{ + const navigate = useNavigate(); + const handleAction = () => navigate({ + to: '/store/details/download/$source/$id', params: { + source: encodeURIComponent(data.match.source), + id: encodeURIComponent(data.match.id) + } + }); + const { ref, focusKey } = useFocusable({ + focusKey: data.focusKey, + onFocus: (l, p, d) => scrollIntoViewHandler({ behavior: "smooth", block: "center", inline: "center" })(focusKey, ref.current, d), + onEnterPress: handleAction + }); + return
  • + {!!data.match.cover_url && } +
    +
    {data.match.name}
    +
    {data.match.date?.toDateString()}
    +
      + {!!data.match.size &&
    • {prettyBytes(data.match.size)}
    • } + {!!data.match.download_count &&
    • {data.match.download_count}
    • } + {!!data.match.view_count &&
    • {data.match.view_count}
    • } + {!!data.match.comment_count &&
    • {data.match.comment_count}
    • } + {!!data.match.rating &&
    • {data.match.rating}
    • } +
    +
    +
  • ; +} + +function Downloads (data: { + pages: { + data: DownloadLookupEntry[]; + totalCount: number; + nextPage: number; + }[]; + hasNextPage: boolean, + isFetchingNextPage: boolean, + isFetching: boolean, + fetchNextPage: () => void, + error: string | undefined; +}) +{ + const { ref, focusKey } = useFocusable({ focusKey: 'downloads-list' }); + return
      + + {data.pages.flatMap((page, p) => page.data.map((match, i) => ))} + {data.hasNextPage && + { + if (data.isFetchingNextPage || data.isFetching) + return; + data.fetchNextPage(); + }} />} + {!!data.error} + +
    ; +} + +function RouteComponent () +{ + const [search] = useSessionStorage(`${Route.to}-search`, undefined); + const [filter, setFilter] = useSessionStorage('store-download-lookup-filters', {}); + const { data, error, isPending, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteQuery({ + ...downloadsLookupQuery({ ...filter, search }), + maxPages: 10, + refetchOnMount: false + }); + const { ref, focusKey } = useFocusable({ + focusKey: "main-area", + preferredChildFocusKey: "downloads-list" + }); + + const { data: lookupFilters } = useQuery(downloadLookupFiltersQuery); + + return
    + +
    + {isFetching && } + Results + {isPending ? : {data?.pages[0].totalCount}} +
    + {isPending && } + {data && } +
    + +
    + +
    +
    ; +} diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index 7fd1e0f..1fb831f 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -1,6 +1,6 @@ -import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; import { Joystick } from 'lucide-react'; import { useContext, useEffect } from 'react'; import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; @@ -9,20 +9,33 @@ import { StoreContext } from '@/mainview/scripts/contexts'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import { useQuery } from '@tanstack/react-query'; import { storeEmulatorsQuery } from '@queries/store'; +import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; +import { useSessionStorage } from 'usehooks-ts'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; export const Route = createFileRoute('/store/tab/emulators')({ component: RouteComponent, + errorComponent: InvalidStoreError, + validateSearch: zodValidator(z.object({ + search: z.string().optional() + })) }); function RouteComponent () { - const { focus } = useSearch({ from: '/store/tab' }); + const { focus } = Route.useSearch(); + const [search] = useSessionStorage(`${Route.to}-search`, undefined); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); const storeContext = useContext(StoreContext); - const { data: emulators } = useQuery(storeEmulatorsQuery); + const { data: emulators } = useQuery({ + ...storeEmulatorsQuery({ search }), + retry: false, + throwOnError: true + }); useEffect(() => { @@ -58,6 +71,7 @@ function RouteComponent () /> )) ?? Array.from({ length: 10 }).map((_, i) =>
    )}
    + ; diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index 7bbf93e..9176c20 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -1,25 +1,44 @@ import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { createFileRoute, useSearch } from '@tanstack/react-router'; -import { Gamepad2 } from 'lucide-react'; -import { useContext, useEffect } from 'react'; -import { useInfiniteQuery } from '@tanstack/react-query'; -import FrontEndGameCard from '@/mainview/components/FrontEndGameCard'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { Gamepad2, HardDrive } from 'lucide-react'; +import { JSX, useEffect } from 'react'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation'; import LoadMoreButton from '@/mainview/components/LoadMoreButton'; import { storeGamesInfiniteQuery } from '@queries/store'; -import { StoreContext } from '@/mainview/scripts/contexts'; +import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; +import { CardList, GameMetaExtra } from '@/mainview/components/CardList'; +import { RPC_URL } from '@/shared/constants'; +import { GameListFilterType, FrontEndGameType } from '@simeonradivoev/gameflow-sdk/shared'; +import { useSessionStorage } from 'usehooks-ts'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; +import SideFilters from '@/mainview/components/SideFilters'; +import { gameFiltersQuery } from '@/mainview/scripts/queries/romm'; +import { isUrl } from '@/shared/utils'; export const Route = createFileRoute('/store/tab/games')({ - component: RouteComponent + component: RouteComponent, + errorComponent: InvalidStoreError, + validateSearch: zodValidator(z.object({ + search: z.string().optional() + })) }); function RouteComponent () { - const { focus } = useSearch({ from: '/store/tab' }); - const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus }); + const { focus } = Route.useSearch(); + const [search] = useSessionStorage(`${Route.to}-search`, undefined); + const navigator = useNavigate(); + 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' })); - const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery); - const storeContext = useContext(StoreContext); + useEffect(() => + { + setFilter(v => ({ ...v, search })); + }, [search]); useEffect(() => { @@ -36,6 +55,11 @@ function RouteComponent () node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }; + function handleDefaultSelect (g: FrontEndGameType) + { + navigator({ to: '/game/$source/$id', params: { id: g.id.id, source: g.id.source } }); + }; + return <>
    @@ -45,19 +69,9 @@ function RouteComponent () Games -
    - {data?.pages.flatMap((page) => ( - page.data.map((g, i) => - { - storeContext.prefetchDetails('game', g.id.source, g.id.id); - handleFocus(k, n, d); - }} key={g.id.id} game={g} index={i} />)) - ) ?? Array.from({ length: 20 }).map((_, i) =>
    -
    -
    -
    -
    )} - +
    +
    +
    diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx index b596807..dcb53c8 100644 --- a/src/mainview/routes/store/tab/index.tsx +++ b/src/mainview/routes/store/tab/index.tsx @@ -15,6 +15,8 @@ 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'; +import { FrontEndGameTypeDetailed } from '@simeonradivoev/gameflow-sdk/shared'; export const Route = createFileRoute('/store/tab/')({ component: RouteComponent @@ -50,40 +52,37 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; }) }, 10); const storeContext = useContext(StoreContext); - const previewUrl = data.games ? new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`) : undefined; - previewUrl?.searchParams.set('blur', '16'); + const previewUrls = data.games?.[selectedGame] ? data.games[selectedGame].path_covers.map(c => + { + const url = new URL(`${RPC_URL(__HOST__)}${c}`); + url.searchParams.set('blur', '16'); + return url; + }) : undefined; + return
    - {game ?
    + {game ?
    - - { - e.currentTarget.dataset.loaded = "true"; - e.currentTarget.classList.toggle('scale-110', false); - }} - /> +
    -
    -
    - {!!data.games && } +
    +
    + {!!data.games && }
    -

    {game.name}

    -

    {game.summary}

    +

    {game.name}

    +

    {game.summary}

    - +
    :
    } @@ -128,19 +127,19 @@ export function RouteComponent () {
    } {!!crucialEmulators && crucialEmulators?.length > 0 && storeContext.showDetails('emulator', 'store', id, focus)} + onSelect={(em, focus) => storeContext.showDetails('emulator', em.source, em.name, focus)} emulators={crucialEmulators} />}
    storeContext.showDetails('emulator', 'store', id, focus)} + onSelect={(em, focus) => storeContext.showDetails('emulator', em.source, em.name, focus)} onFocus={scrollIntoViewHandler({ block: 'end' })} emulators={recommendedEmulators} />
    -
    +

    Featured Games diff --git a/src/mainview/routes/store/tab/plugins.tsx b/src/mainview/routes/store/tab/plugins.tsx new file mode 100644 index 0000000..36fd70a --- /dev/null +++ b/src/mainview/routes/store/tab/plugins.tsx @@ -0,0 +1,146 @@ +import { allPluginsFilter, installPluginMutation, uninstallPluginMutation, updatePluginMutation } from '@/mainview/scripts/queries/plugins'; +import { pluginsQuery } from '@/mainview/scripts/queries/store'; +import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { FOCUS_KEYS } from '@/mainview/scripts/types'; +import { PluginEntryType } from '@simeonradivoev/gameflow-sdk/shared'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { QueryClient, useMutation, useQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { CircleFadingArrowUp, Dot, Download, HardDrive, Puzzle } from 'lucide-react'; +import prettyMilliseconds from 'pretty-ms'; +import { useSessionStorage } from 'usehooks-ts'; + +export const Route = createFileRoute('/store/tab/plugins')({ + component: RouteComponent +}); + +function PluginCard (data: { plugin: PluginEntryType; }) +{ + const navigate = useNavigate(); + const onAction = () => + { + navigate({ to: '/store/details/plugin/$id', params: { id: decodeURIComponent(data.plugin.package.name) } }); + }; + const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.PLUGIN_ENTRY(data.plugin.package.sanitized_name), onEnterPress: onAction }); + const handleRefresh = (client: QueryClient) => + { + client.invalidateQueries(allPluginsFilter); + navigate({ to: '/store/tab/plugins', replace: true }); + }; + const update = useMutation({ + ...updatePluginMutation(data.plugin.package.name), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + const install = useMutation({ + ...installPluginMutation(data.plugin.package.name), + onSuccess (f, variables, onMutateResult, context) + { + handleRefresh(context.client); + } + }); + const uninstall = useMutation({ + ...uninstallPluginMutation(data.plugin.package.name), + onSuccess (f, variables, onMutateResult, context) + { + handleRefresh(context.client); + } + }); + useShortcuts(focusKey, () => + { + const shortcuts: Shortcut[] = [{ + label: "Details", button: GamePadButtonCode.A, action (e) + { + onAction(); + }, + }]; + + if (data.plugin.installed) + { + shortcuts.push({ + label: "Uninstall", + button: GamePadButtonCode.X, + action (e) + { + uninstall.mutate(); + }, + }); + + if (data.plugin.update) + { + shortcuts.push({ + label: "Update", + button: GamePadButtonCode.Y, + action (e) + { + update.mutate(); + }, + }); + } + + } else + { + shortcuts.push({ + label: "Install", + button: GamePadButtonCode.X, + action (e) + { + install.mutate(); + }, + }); + } + return shortcuts; + }, [data.plugin.installed, install.isPending, uninstall.isPending]); + return
    +
    +
    + {data.plugin.installed && } + {data.plugin.update && } + {data.plugin.package.name} + {(install.isPending || uninstall.isPending) && } +
    +
    {data.plugin.package.description}
    +
      {data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map((k, i) =>
    • {k}
    • )}
    +
      +
    • {data.plugin.package.publisher.username}
    • + +
    • {data.plugin.package.version}
    • + +
    • {prettyMilliseconds(new Date().getTime() - data.plugin.package.date.getTime(), { hideSeconds: true })}
    • + +
    • {data.plugin.package.license}
    • + {install.isPending && <> + +
    • installing
    • + } + {uninstall.isPending && <> + +
    • uninstalling
    • + } +
    +
    +
    +
    + + {data.plugin.downloads.monthly} +
    +
    +
    ; +} + +function RouteComponent () +{ + const [search] = useSessionStorage(`${Route.to}-search`, undefined); + const { data: plugins } = useQuery(pluginsQuery(search)); + const { ref, focusKey } = useFocusable({ focusKey: "plugins-store" }); + return
    + +
    {plugins?.total} Plugins
    +
    + {plugins?.objects.map((p, i) => )} +
    +
    +
    ; +} diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index e656b8e..81a5a01 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -1,24 +1,31 @@ -import { Router } from '@/mainview'; +import { AutoFocus } from '@/mainview/components/AutoFocus'; import { FilterUI } from '@/mainview/components/Filters'; import { HeaderUI } from '@/mainview/components/Header'; -import Shortcuts from '@/mainview/components/Shortcuts'; +import HeaderSearchField from '@/mainview/components/HeaderSearchField'; +import SelectMenu from '@/mainview/components/SelectMenu'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { StoreContext } from '@/mainview/scripts/contexts'; import { gameQuery } from '@/mainview/scripts/queries/romm'; import { storeEmulatorDetailsQuery } from '@/mainview/scripts/queries/store'; -import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts'; -import { mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack, mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useQueryClient } from '@tanstack/react-query'; -import { useMatchRoute } from '@tanstack/react-router'; +import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; -import { Settings } from 'lucide-react'; -import { useEffect, useRef } from 'react'; +import { DownloadCloud, Gamepad2, Home, Joystick, Puzzle } from 'lucide-react'; +import { useRef } from 'react'; +import { useSessionStorage } from 'usehooks-ts'; import z from 'zod'; export const Route = createFileRoute('/store/tab')({ component: RouteComponent, - validateSearch: zodValidator(z.object({ focus: z.string().optional() })) + validateSearch: zodValidator(z.object({ focus: z.string().optional() })), + staticData: { + enterSound: 'openStore', + enterHaptic: 'navigateStore' + } }); function useIsSettings (subPath: string) @@ -33,6 +40,7 @@ function useIsSettings (subPath: string) function TopArea (data: { filters: Record; }) { + const router = useRouter(); const { ref, focusKey } = useFocusable({ focusKey: 'top-area', preferredChildFocusKey: `store-tabs`, @@ -44,13 +52,13 @@ function TopArea (data: { filters: Record; }) useShortcuts("STORE_ROOT", () => [{ label: "Return", - action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }), + action: (e) => HandleGoBack(router, e), button: GamePadButtonCode.B - }], []); + }], [router]); const handleNavigate = (s: string) => { - Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}`, viewTransition: { types: ['slide-up'] }, replace: true }); + router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}`, viewTransition: { types: ['slide-up'] }, replace: true }); }; return
    @@ -76,6 +84,7 @@ function StoreOutlet () function RouteComponent () { // Root spatial nav container + const router = useRouter(); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "STORE_ROOT", preferredChildFocusKey: 'top-area', @@ -85,33 +94,26 @@ function RouteComponent () const headerRef = useRef(null); const sentinelRef = useRef(null); const filters: Record = { - home: { label: "Home", selected: useIsSettings(''), }, - emulators: { label: "Emulators", selected: useIsSettings('emulators') }, - games: { label: "Games", selected: useIsSettings('games') } + home: { label: "Home", icon: , selected: useIsSettings(''), }, + emulators: { label: "Emulators", icon: , selected: useIsSettings('emulators') }, + games: { label: "Games", icon: , selected: useIsSettings('games') }, + download: { label: "Download", icon: , selected: useIsSettings('download') }, + plugins: { label: "Plugins", icon: , selected: useIsSettings('plugins') } }; - - const { shortcuts } = useShortcutContext(); - const { focus } = Route.useSearch(); - - useEffect(() => - { - if (!focus) - { - focusSelf(); - } - }, []); + const [search, setSearch] = useSessionStorage(`${router.history.location.pathname}-search`, undefined); + const [, setGamesSearch] = useSessionStorage(`/store/tab/games-search`, undefined); const handleDetails = (type: string, source: string, id: string, focus: string) => { if (type === 'emulator') { - Router.navigate({ to: '/store/details/emulator/$id', params: { id } }); + if (!source || source === 'local') return; + router.navigate({ to: '/store/details/emulator/$id', params: { id } }); } else if (type === 'game') { - Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id } }); + router.navigate({ to: '/game/$source/$id', params: { source: source, id: id } }); } - }; const handlePrefetch = (type: string, source: string, id: string) => @@ -126,6 +128,19 @@ function RouteComponent () } }; + const handleSearch = (search: string | undefined) => + { + if (filters['home'].selected) + { + setGamesSearch(search); + router.navigate({ to: '/store/tab/games', replace: true, viewTransition: { types: ['slide-up'] } }); + } else + { + setSearch(search); + } + + }; + const isMobile = mobileCheck(); useStickyDataAttr(headerRef, sentinelRef, ref); @@ -135,20 +150,20 @@ function RouteComponent ()
    - + } />
    -
    - -
    {!isMobile && <>
    }
    + + +
    ; } diff --git a/src/mainview/scripts/audio/audio.ts b/src/mainview/scripts/audio/audio.ts new file mode 100644 index 0000000..430e4de --- /dev/null +++ b/src/mainview/scripts/audio/audio.ts @@ -0,0 +1,60 @@ +import { Howl } from 'howler'; +import sounds from '../../assets/sounds.ogg'; +import soundSprites from '../../assets/sounds.json'; +import { getLocalSetting } from '../utils'; +import { hapticMap } from '../gamepads'; +import { soundMap, SoundMapEntry } from './audioConstants'; + +const timingMap = new Map(); + +// Browsers need input to start any sound, so intro doesn't auto play. +/*const introSound = new Howl({ + src: [intro], + volume: getLocalSetting("soundEffectsVolume") / 100, + autoplay: true, +});*/ + +const sound = new Howl({ + src: [sounds], + sprite: soundSprites.sprite as any, + volume: getLocalSetting("soundEffectsVolume") / 100, +}); + +import.meta.hot?.dispose(() => { sound.unload(); }); + +declare module '@tanstack/react-router' { + interface StaticDataRouteOption + { + enterSound?: keyof typeof soundMap | null; + enterHaptic?: keyof typeof hapticMap | null; + goBackSound?: keyof typeof soundMap | null; + missNavSound?: boolean; + } +} + +function sinRandom () +{ + return Math.sin(new Date().getMilliseconds() / 1000 * Math.PI); +} + + +function random () +{ + return Math.random() * 2 - 1; +} + +export function oneShot (id: keyof typeof soundMap, options?: { volume?: number; }) +{ + const currentDate = timingMap.get(id); + if (!getLocalSetting('soundEffects')) return; + const soundValue = soundMap[id] as SoundMapEntry; + const maxDelay = soundValue.maxDelay ?? 100; + if (currentDate && new Date().getTime() - currentDate.getTime() <= maxDelay) return; + + const instanceId = sound.play(soundValue.key); + const baseVolume = getLocalSetting("soundEffectsVolume") / 100; + sound.volume(Math.min(baseVolume * (soundValue.volume ?? 1) * (options?.volume ?? 1) * (1 + random() * (soundValue.volumeVariation ?? 0), 1)), instanceId); + sound.rate(1 + sinRandom() * (soundValue.rateVariation ?? 0), instanceId); + timingMap.set(id, new Date()); +} + diff --git a/src/mainview/scripts/audio/audioConstants.ts b/src/mainview/scripts/audio/audioConstants.ts new file mode 100644 index 0000000..25c85c6 --- /dev/null +++ b/src/mainview/scripts/audio/audioConstants.ts @@ -0,0 +1,38 @@ +import soundSprites from '../../assets/sounds.json'; + +const volumeVariation = 0.05; +const rateVariation = 0.02; + +export interface SoundMapEntry +{ + key: keyof typeof soundSprites.sprite; + rateVariation?: number; + volumeVariation?: number; + volume?: number; + maxDelay?: number; +} + +export const soundMap = { + openDetails: { key: 'Classic UI SFX - Chords #2' }, + returnGeneric: { key: 'Classic UI SFX - Short - Low #2' }, + returnDetails: { key: 'Classic UI SFX - Short - Low #5' }, + openGeneric: { key: 'Classic UI SFX - Short - High #9' }, + select: { key: "UI_TwoNote Up_Set 11_01", rateVariation, volumeVariation }, + selectAlt: { key: "UI_TwoNote Up_Set 11_01", rateVariation, volumeVariation }, + selectMenu: { key: "UI_TwoNote Up_Set 11_02", rateVariation, volumeVariation }, + selectFilter: { key: 'Classic UI SFX - Short - High #3', volumeVariation }, + closeContext: { key: 'Classic UI SFX - Short - High #19' }, + openContext: { key: 'Classic UI SFX - Short - High #22' }, + openKeyboard: { key: 'Classic UI SFX - Short - High #25' }, + openStore: { key: 'Classic UI SFX - Chords #16' }, + openSettings: { key: 'Classic UI SFX - Short - High #8' }, + click: { key: "UI_Single_Set 16_03", rateVariation, volumeVariation }, + clickAlt: { key: "UI_Single_Set 16_01", rateVariation, volumeVariation }, + keyPress: { key: "UI_Single_Set 5_02", rateVariation, volumeVariation }, + keyPressReturn: { key: "UI_Single_Set 5_04", rateVariation, volumeVariation }, + keyPressSpace: { key: "UI_Single_Set 5_03", rateVariation, volumeVariation }, + keyPressBackspace: { key: "UI_Single_Set 5_01", rateVariation, volumeVariation }, + keyHover: { key: "UI_Single_Set 11_02", rateVariation, volumeVariation, volume: 0.5, maxDelay: 60 }, + invalidNavigation: { key: "Classic UI SFX - Short - Low #6", rateVariation, volumeVariation }, + launch: { key: "UI SFX_InGameMenu_Open" } +} satisfies Record; \ No newline at end of file diff --git a/src/mainview/scripts/brandIcons.tsx b/src/mainview/scripts/brandIcons.tsx index ef35534..bf2e576 100644 --- a/src/mainview/scripts/brandIcons.tsx +++ b/src/mainview/scripts/brandIcons.tsx @@ -3,4 +3,8 @@ export const TwitchIcon = ; +export const IGDBIcon = IGDB; + +export const Rclone = Rclone; + export const FlatpackIcon = Flathub; \ No newline at end of file diff --git a/src/mainview/scripts/contexts.ts b/src/mainview/scripts/contexts.ts index 3257bfb..0c958b1 100644 --- a/src/mainview/scripts/contexts.ts +++ b/src/mainview/scripts/contexts.ts @@ -1,6 +1,7 @@ -import { SystemInfoType } from "@/shared/constants"; -import { FocusDetails } from "@noriginmedia/norigin-spatial-navigation"; +import { SystemInfoType, Drive, AppInfoContext } from '@simeonradivoev/gameflow-sdk/shared'; +import { Direction, FocusDetails } from "@noriginmedia/norigin-spatial-navigation"; import { createContext } from "react"; +import { Shortcut } from "./shortcuts"; export const StoreContext = createContext({} as { showDetails: (type: 'emulator' | 'game', source: string, id: string, focusSource: string) => void; @@ -20,6 +21,8 @@ export const OptionContext = createContext( focused: boolean; focus: (focusDetails?: FocusDetails | undefined) => void; eventTarget: EventTarget; + setFocusBoundary: (b: boolean) => void; + setFocusBoundaryDirections: (dirs: Direction[]) => void; }, ); @@ -34,8 +37,24 @@ export const FilePickerContext = createContext<{ activeDrive: Drive | undefined; }>({} as any); +export const ShortcutsContext = createContext({} as { + shortcuts: ({ + key: string; + } & Shortcut)[] | undefined; +}); + export const SystemInfoContext = createContext({} as SystemInfoType | undefined); +export const AppContext = createContext({} as AppInfoContext); + +export const GlobalDialogContext = createContext({} as { + openContext: (options: { + content: any; + preferredChildFocusKey?: string; + onClose?: () => void; + }, focusKey: string) => void; +}); + export const GameDetailsContext = createContext<{ update: () => void; }>({} as any); \ No newline at end of file diff --git a/src/mainview/scripts/feedbackCallbacks.ts b/src/mainview/scripts/feedbackCallbacks.ts new file mode 100644 index 0000000..f103d68 --- /dev/null +++ b/src/mainview/scripts/feedbackCallbacks.ts @@ -0,0 +1,100 @@ +import { Router } from "@/mainview"; +import { soundMap } from "./audio/audioConstants"; +import { oneShotRumble } from "./gamepads"; +import { oneShot } from "./audio/audio"; + +export default function load () +{ + let lastLocationPath: string | undefined; + const unsub = Router.history.subscribe((op) => + { + if (op.action.type === 'PUSH') + { + lastLocationPath = op.location.pathname; + + const routes = Router.matchRoutes(op.location.pathname); + const soundRoute = routes.find(r => r.staticData.enterSound !== undefined); + if (soundRoute) + { + oneShot(soundRoute.staticData.enterSound!); + } else + { + oneShot("openGeneric"); + } + + if (op.location.state.eventType === 'gamepadbuttondown') + { + const hapticRoute = routes.find(r => r.staticData.enterHaptic !== undefined); + if (hapticRoute) oneShotRumble(hapticRoute.staticData.enterHaptic!, { all: true }); + else oneShotRumble('navigateForward', { all: true }); + } + } else if (op.action.type === 'BACK') + { + if (lastLocationPath) + { + const soundRoutes = Router.matchRoutes(lastLocationPath); + const soundRoute = soundRoutes.find(r => r.staticData.goBackSound !== undefined); + if (soundRoute) + { + if (soundRoute.staticData.goBackSound) oneShot(soundRoute.staticData.goBackSound); + } else + { + oneShot("returnGeneric"); + } + } else + { + oneShot("returnGeneric"); + } + + lastLocationPath = op.location.state.key; + } + }); + + let focusChangeDebounced: undefined | NodeJS.Timeout; + + const focuschangedHandler = (e: CustomEvent) => + { + clearTimeout(focusChangeDebounced); + if (!e.detail.focusKeyChanged) return; + + if (e.detail.nativeEvent || e.detail.event) + { + let sound: keyof typeof soundMap; + if (e.detail.node && e.detail.node.matches('[data-sound-category="menu"]')) + { + sound = 'selectMenu'; + + } else if (e.detail.node && e.detail.node.matches('[data-sound-category="filter"]')) + { + sound = "selectFilter"; + } + else if (e.detail.node && e.detail.node.matches('[data-sound-category="emulator"]')) + { + sound = "selectAlt"; + } + else if (!e.detail.node || !e.detail.node.matches('[data-sound-disable="focus"]')) + { + sound = e.detail.sound as any ?? 'select'; + } + + setTimeout(() => + { + if (e.detail.nativeEvent || e.detail.event) + { + oneShot(sound); + oneShotRumble('select', { event: e.detail.event }); + } + }, 10); + } + }; + + window.addEventListener('focuschanged', focuschangedHandler as any); + + return { + cleanup: () => + { + unsub(); + window.removeEventListener('focuschanged', focuschangedHandler as any); + } + }; +} \ No newline at end of file diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index fed3f3d..5959d90 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -1,7 +1,9 @@ import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-spatial-navigation"; import { GetFocusedElement } from "./spatialNavigation"; import { useEffect, useState } from "react"; -import { mobileCheck } from "./utils"; +import { getLocalSetting, isTextInputFocused, mobileCheck } from "./utils"; +import { oneShot } from "./audio/audio"; +import { Router } from "@/mainview"; let loopStarted = false; let isTouching = false; @@ -96,6 +98,11 @@ const throttleMap = new Map(); const throttleAcceleration = new Map(); function throttleNav (key: string, dir: string, event: Event) { + if (isTextInputFocused()) + { + return false; + } + const minSpeed = 150; const maxSpeed = 300; const currentDate = new Date(); @@ -104,7 +111,16 @@ function throttleNav (key: string, dir: string, event: Event) const speed = Math.max(maxSpeed - (maxSpeed - minSpeed) * (acceleration / 6), minSpeed); if ((currentDate.getTime() - (lastTime ?? 0) > speed)) { + const currentFocusKey = getCurrentFocusKey(); navigateByDirection(dir, { event }); + if (currentFocusKey === getCurrentFocusKey()) + { + const routes = Router.matchRoutes(Router.history.location.pathname); + if (!routes.some(r => r.staticData.missNavSound === false)) + { + oneShot('invalidNavigation'); + } + } throttleMap.set(key, currentDate.getTime()); throttleAcceleration.set(key, acceleration + 1); return true; @@ -276,4 +292,45 @@ function updateStatus () } requestAnimationFrame(updateStatus); +} + +export const hapticMap = { + select: [{ duration: 50, strongMagnitude: 0, weakMagnitude: 1 }], + navigateForward: [{ duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }, { duration: 100, strongMagnitude: 0.5, weakMagnitude: 0.5 }], + navigateBack: [{ duration: 100, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }], + navigateStore: [{ duration: 200, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 300, strongMagnitude: 0.2, weakMagnitude: 0.2 }], + openContext: [{ duration: 50, strongMagnitude: 0.5, weakMagnitude: 0.5 }, { duration: 50, strongMagnitude: 0.0, weakMagnitude: 0.0 }, { duration: 50, strongMagnitude: 0.2, weakMagnitude: 0.2 }], +} satisfies Record; + +let lastRumble: AbortController; + +export function oneShotRumble (effect: keyof typeof hapticMap, init?: { event?: Event, all?: boolean; }) +{ + if (!getLocalSetting('hapticsEffects')) return; + + async function play (g: Gamepad) + { + lastRumble = new AbortController(); + for (const e of hapticMap[effect]) + { + await new Promise(resolve => + { + g.vibrationActuator.playEffect('dual-rumble', e); + const timeout = setTimeout(() => resolve(true), e.duration + 50); + lastRumble.signal.onabort = () => clearTimeout(timeout); + if (lastRumble.signal.aborted) resolve(false); + }); + + if (lastRumble.signal.aborted) return; + } + } + + if (lastRumble) lastRumble.abort(); + if (init?.event instanceof GamepadEvent || init?.event instanceof GamepadButtonEvent) + { + if (init?.event.gamepad) play(init?.event.gamepad); + } else if (init?.all) + { + navigator.getGamepads().filter(g => !!g).forEach(g => play(g)); + } } \ No newline at end of file diff --git a/src/mainview/scripts/queries/plugins.ts b/src/mainview/scripts/queries/plugins.ts index 6be97dc..8e6e948 100644 --- a/src/mainview/scripts/queries/plugins.ts +++ b/src/mainview/scripts/queries/plugins.ts @@ -1,4 +1,4 @@ -import { mutationOptions, queryOptions } from "@tanstack/react-query"; +import { mutationOptions, QueryFilters, queryOptions } from "@tanstack/react-query"; import { pluginsApi } from "../clientApi"; export const getAllPluginsQuery = queryOptions({ @@ -11,11 +11,64 @@ export const getAllPluginsQuery = queryOptions({ } }); +export const getPluginDetailsQuery = (source: string) => queryOptions({ + queryKey: ['plugins', source], queryFn: async () => + { + const { data, error } = await pluginsApi.plugins({ id: encodeURIComponent(source) }).get(); + if (error) throw error; + return data; + } +}); + export const enablePluginMutation = mutationOptions({ mutationKey: ['plugin', 'enable'], mutationFn: async (vars: { id: string, enabled: boolean; }) => { - const { error } = await pluginsApi.plugins({ id: vars.id }).post({ enabled: vars.enabled }); + const { error } = await pluginsApi.plugins({ id: encodeURIComponent(vars.id) }).post({ enabled: vars.enabled }); if (error) throw error; } +}); + +export const installPluginMutation = (id: string) => mutationOptions({ + mutationKey: ['plugin', 'install', id], + mutationFn: async () => + { + const { data, error } = await pluginsApi.plugins.install.post({ id }); + if (error) throw error; + return data; + } +}); + +export const updatePluginMutation = (id: string) => mutationOptions({ + mutationKey: ['plugin', 'update', id], + mutationFn: async () => + { + const { data, error } = await pluginsApi.plugins.update.post({ id }); + if (error) throw error; + return data; + } +}); + +export const uninstallPluginMutation = (id: string) => mutationOptions({ + mutationKey: ['plugin', 'uninstall', id], + mutationFn: async () => + { + const { data, error } = await pluginsApi.plugins.uninstall.post({ id: id }); + if (error) throw error; + return data; + } +}); + +export const pluginFilter = (id: string): QueryFilters => ({ + predicate (query) + { + return query.queryKey.includes(id); + }, +}); + +export const allPluginsFilter: QueryFilters = ({ + predicate (query) + { + return query.queryKey.includes('plugin') || query.queryKey.includes('plugins'); + }, }); \ No newline at end of file diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 4ecf6ca..5c76d2b 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -1,6 +1,7 @@ -import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants"; +import { DefaultRommStaleTime } from "@/shared/constants"; +import { GameListFilterType, RommLoginDataSchema, FrontEndId, DownloadLookupEntry, DownloadsLookupFilter } from '@simeonradivoev/gameflow-sdk/shared'; import { rommApi, settingsApi } from "../clientApi"; -import { mutationOptions, QueryFilters, queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions, InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions } from "@tanstack/react-query"; import z from "zod"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; @@ -23,6 +24,20 @@ export const gameQuery = (source: string, id: string) => queryOptions({ }, }); export const rommLogoutMutation = mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.romm.post() }); +export const invalidateLogin = (client: QueryClient) => +{ + return client.invalidateQueries({ + predicate (query) + { + return query.queryKey.includes('auth') + || query.queryKey.includes('games') + || query.queryKey.includes('game') + || query.queryKey.includes('platform') + || query.queryKey.includes('platforms') + || query.queryKey.includes('collections'); + }, + }); +}; export const rommQrLoginMutation = mutationOptions({ mutationKey: ['login', 'qr', 'cancel'], mutationFn: async () => @@ -30,7 +45,11 @@ export const rommQrLoginMutation = mutationOptions({ const { data, error } = await rommApi.api.romm.login.romm.qr.post(); if (error) throw error; return data; - } + }, + onSuccess: (d, v, r, c) => + { + invalidateLogin(c.client); + }, }); export const rommLoginMutation = mutationOptions({ mutationKey: ["romm", "login"], @@ -41,7 +60,7 @@ export const rommLoginMutation = mutationOptions({ }, onSuccess: (d, v, r, c) => { - c.client.invalidateQueries({ queryKey: ['romm', 'auth'] }); + invalidateLogin(c.client); }, onError: (e) => { @@ -72,8 +91,8 @@ export const rommLoggedInQuery = queryOptions({ return data; } }); -export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }); -export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }); +export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ source: 'local' })({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }); +export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ source: 'local' })({ id: 'rommUser' }).get().then(d => d.data?.value as string) }); export const deleteGameMutation = (id: FrontEndId) => mutationOptions({ mutationKey: ['delete', id], mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete() @@ -107,9 +126,9 @@ export const platformQuery = (source: string, id: string) => queryOptions({ }); export const installMutation = (source: string, id: string) => mutationOptions({ mutationKey: ['install', source, id], - mutationFn: async () => + mutationFn: async (init: { downloadId?: string; }) => { - const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post(); + const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post({ downloadId: init.downloadId }); if (error) throw error; return data; } @@ -148,6 +167,9 @@ export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) => return data; } }); +export const allGamesInvalidateQuery: QueryFilters = { + queryKey: ['games'] +}; export const gameInvalidationQuery = (source: string, id: string): QueryFilters => ({ predicate (query) { @@ -155,4 +177,152 @@ export const gameInvalidationQuery = (source: string, id: string): QueryFilters if (query.queryKey.includes(source) && query.queryKey.includes(id)) return true; return false; }, +}); +export const validateSourceQuery = (source: string, id: string) => queryOptions({ + queryKey: ["game", source, id, "validate"], queryFn: async () => + { + const { data } = await rommApi.api.romm.game({ source })({ id }).validate.get(); + return data; + } +}); +export const fixSourceMutation = mutationOptions({ + mutationKey: ['game', "fix_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) => + { + const { data, error } = await rommApi.api.romm.game({ source })({ id }).fix_source.post(); + if (error) throw error; + return data; + } +}); +export const updateSourceMutation = mutationOptions({ + mutationKey: ['game', "update_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) => + { + const { data, error } = await rommApi.api.romm.game({ source })({ id }).update.post({ + source: source, + id: id + }); + if (error) throw error; + return data; + } +}); +export const updatePlatformMutation = (source: string, id: string) => mutationOptions({ + mutationKey: ['platform', source, 'update', id], + mutationFn: async () => + { + const { data, error } = await rommApi.api.romm.platform({ source })({ id }).update.post(); + if (error) throw error; + return data; + } +}); +export const deletePlatformMutation = (id: string) => mutationOptions({ + mutationKey: ['platform', 'local', 'delete', id], + mutationFn: async () => + { + const { data, error } = await rommApi.api.romm.platform.local({ id }).delete(); + if (error) throw error; + return data; + } +}); +export const localPlatformFilter = (id: string) => ({ + predicate (query) + { + return query.queryKey.includes('platform') && ((query.queryKey.includes('local') && query.queryKey.includes(id)) || query.queryKey.includes('all')); + }, +} satisfies InvalidateQueryFilters as InvalidateQueryFilters); + +export const gameFiltersQuery = (filters: { source?: string; }) => queryOptions({ + queryKey: ['game', 'filters', filters], queryFn: async () => + { + const { data, error } = await rommApi.api.romm.games.filters.get({ query: { source: filters.source } }); + if (error) throw error; + return data; + } +}); + +export const gameLookupQuery = (search: string | undefined) => queryOptions({ + queryKey: ['game', 'lookup', search], + queryFn: async () => + { + if (!search) return []; + const { data, error } = await rommApi.api.romm.lookup.get({ query: { search } }); + if (error) throw error; + return data; + } +}); + +export const gameLookupDetails = (source: string | undefined, id: string | undefined) => queryOptions({ + enabled: !!source && !!id, + queryKey: ['game', 'lookup', source, id], + queryFn: async () => + { + const { data, error } = await rommApi.api.romm.lookup({ source: source! })({ id: id! }).get(); + if (error) throw error; + return data; + } +}); + +export const platformLookupMatchQuery = (source: string | undefined, id: number | undefined) => queryOptions({ + enabled: !!source && !!id, + queryKey: ['platform', 'lookup', 'match', source, id], + queryFn: async () => + { + const { data, error } = await rommApi.api.romm.platform.lookup.match({ source: source! })({ id: id! }).get(); + if (error) throw error; + return data; + } +}); + +export const customUpdateMutation = mutationOptions({ + mutationKey: ['game', 'custom-update'], mutationFn: async (args: { source: string, id: string, destination: string, destinationId: string; }) => + { + const { data, error } = await rommApi.api.romm.game({ source: args.source })({ id: args.id }).update.post({ source: args.destination, id: args.destinationId }); + if (error) throw error; + return data; + } +}); + +export const addManualGameMutation = mutationOptions({ + mutationKey: ['game', 'custom-add'], + mutationFn: async (args: { source: string, id: string, gamePath: string, platformId: number; }) => + { + const { data, error } = await rommApi.api.romm.add.custom.post({ + source: args.source, + id: args.id, + gamePath: args.gamePath, + platformId: args.platformId + }); + if (error) throw error; + return data; + } +}); + +export const downloadsLookupQuery = (filter: DownloadsLookupFilter) => infiniteQueryOptions<{ data: DownloadLookupEntry[], totalCount: number, nextPage: number; }>({ + initialPageParam: 1, + queryKey: ["downloads", filter], + getNextPageParam: (lastPage, pages) => lastPage.nextPage, + queryFn: async (params) => + { + const pageParam = params.pageParam as number; + const { data, error } = await rommApi.api.romm.downloads.lookup.get({ query: { ...filter, page: pageParam } }); + if (error) throw error; + return { data: data.matches, totalCount: data.totalCount, nextPage: pageParam + 1 }; + } +}); + +export const downloadLookupQuery = (source: string, id: string) => queryOptions({ + queryKey: ["downloads", source, id], + queryFn: async () => + { + const { data, error } = await rommApi.api.romm.download.lookup({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).get(); + if (error) throw error; + return data; + } +}); + +export const downloadLookupFiltersQuery = queryOptions({ + queryKey: ['game', 'filters'], queryFn: async () => + { + const { data, error } = await rommApi.api.romm.download.lookup.filters.get(); + if (error) throw error; + return data; + } }); \ No newline at end of file diff --git a/src/mainview/scripts/queries/settings.ts b/src/mainview/scripts/queries/settings.ts index 186b224..03956af 100644 --- a/src/mainview/scripts/queries/settings.ts +++ b/src/mainview/scripts/queries/settings.ts @@ -2,6 +2,7 @@ import { mutationOptions, queryOptions } from "@tanstack/react-query"; import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; import { rommApi, settingsApi } from "../clientApi"; +import { invalidateLogin } from "./romm"; export const changeDownloadsMutation = mutationOptions({ mutationKey: ["setting", "downloads"], @@ -29,21 +30,25 @@ export const autoEmulatorsQuery = queryOptions({ } }); export const twitchLogoutMutation = mutationOptions({ - mutationKey: ['twitch', 'logout'], + mutationKey: ['twitch', 'auth', 'logout'], mutationFn: () => { return rommApi.api.romm.logout.twitch.post(); } }); export const twitchLoginMutation = mutationOptions({ - mutationKey: ['twitch', 'login'], + mutationKey: ['twitch', 'auth', 'login'], mutationFn: (openInBrowser: boolean) => { return rommApi.api.romm.login.twitch.post({ openInBrowser }); - } + }, + onSuccess (data, variables, onMutateResult, context) + { + invalidateLogin(context.client); + }, }); export const twitchLoginVerificationQuery = queryOptions({ - queryKey: ['twitch', 'login', 'status'], + queryKey: ['twitch', 'login', 'status', 'auth'], retry (failureCount, error) { if ((error as any).status === 404) @@ -113,7 +118,7 @@ export const setSettingMutation = (id?: string) => mutationOptions({ mutationKey: ["setting", id], mutationFn: async (value: any) => { - const response = await settingsApi.api.settings({ id: id! }).post({ value }); + const response = await settingsApi.api.settings.local({ id: id! }).post({ value }); if (response.error) throw response.error; return response.data; } @@ -123,9 +128,58 @@ export const getSettingQuery = (id: string | undefined) => queryOptions({ queryKey: ["setting", id], queryFn: async () => { - const { data: value, error } = await settingsApi.api.settings({ id: id! }).get(); + const { data: value, error } = await settingsApi.api.settings.local({ id: id! }).get(); if (error) throw error; return value.value; }, +}); +export const getPluginSettingsDefinitionQuery = (source: string) => queryOptions({ + queryKey: ['settings', source, 'definitions'], + queryFn: async () => + { + const { data: value, error } = await settingsApi.api.settings.definitions({ source: encodeURIComponent(source) }).get(); + if (error) throw error; + + return value; + } +}); +export const getPluginSettingQuery = (source: string, id: string) => queryOptions({ + queryKey: ["setting", source, id], + queryFn: async () => + { + const { data, error } = await settingsApi.api.settings({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).get(); + if (error) throw error; + + return data; + }, +}); +export const setPluginSettingMutation = (source: string, id: string) => mutationOptions({ + mutationKey: ["setting", source, id], + mutationFn: async (value: any) => + { + const { data, error } = await settingsApi.api.settings({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).put({ value }); + if (error) throw error; + + return data; + }, +}); +export const getPluginActionsQuery = (source: string) => queryOptions({ + queryKey: ['plugin', source, 'actions'], queryFn: async () => + { + const { data, error } = await settingsApi.api.settings.actions({ source: encodeURIComponent(source) }).get(); + if (error) throw error; + + return data; + } +}); +export const pluginActionMutation = (source: string, id: string) => mutationOptions({ + mutationKey: ["plugin", source, "action"], + mutationFn: async () => + { + const { data, error, response } = await settingsApi.api.settings.actions({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).post(); + if (error) throw error; + + return { data: data as any, response }; + }, }); \ No newline at end of file diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index e7b84f1..a428f40 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -1,12 +1,12 @@ import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query"; import { rommApi, storeApi } from "../clientApi"; +import { GameListFilterType, FrontEndGameType } from '@simeonradivoev/gameflow-sdk/shared'; - -export const storeEmulatorsQuery = queryOptions({ - queryKey: ['store-emulators'], queryFn: async () => +export const storeEmulatorsQuery = (filters: { search?: string; }) => queryOptions({ + queryKey: ['store-emulators', filters], queryFn: async () => { - const { data, error } = await storeApi.api.store.emulators.get(); - if (error) throw error; + const { data, error } = await storeApi.api.store.emulators.get({ query: { search: filters.search } }); + if (error) throw new Error(JSON.stringify(error.value)); return data; } }); @@ -42,18 +42,20 @@ export const storeEmulatorDeleteMutation = mutationOptions({ if (error) throw error; } }); -export const storeGamesInfiniteQuery = infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ + +export const storeGamesInfiniteQuery = (filter: GameListFilterType) => infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({ initialPageParam: 0, - queryKey: ['store-games'], + queryKey: ['store-games', filter], getNextPageParam: (lastPage, pages) => lastPage.nextPage, queryFn: async (data) => { const pageParam = data.pageParam as number; - const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } }); + const { data: games, error } = await rommApi.api.romm.games.get({ query: { ...filter, source: 'store', offset: pageParam * 10, limit: 10 } }); if (error) throw error; return { data: games.games, nextPage: pageParam + 1 }; } }); + export const storeGetStatsQuery = queryOptions({ queryKey: ['store', 'stats'], queryFn: async () => { @@ -64,9 +66,9 @@ export const storeGetStatsQuery = queryOptions({ }); export const installEmulatorMutation = (id: string) => mutationOptions({ mutationKey: ['install', 'emulator', id], - mutationFn: async (source: string) => + mutationFn: async (ctx: { source: string, isUpdate: boolean; }) => { - const { data, error } = await storeApi.api.store.install.emulator({ id })({ source }).post(); + const { data, error } = await storeApi.api.store.install.emulator({ id })({ source: ctx.source }).post({ isUpdate: ctx.isUpdate }); if (error) throw error; return data; } @@ -85,4 +87,30 @@ export const deleteBiosMutation = mutationOptions({ const { error } = await storeApi.api.store.bios({ id }).delete(); if (error) throw error; } +}); +export const getUpdateInfoForEmulator = (id: string) => queryOptions({ + queryKey: ['emulator', 'update'], queryFn: async () => + { + const { data, error } = await storeApi.api.store.emulator({ id }).update.get(); + if (error) throw error; + return data; + } +}); +export const pluginsQuery = (search?: string) => queryOptions({ + queryKey: ['plugins', 'store', search ?? 'all'], + queryFn: async () => + { + const { data, error } = await storeApi.api.store.plugins.get({ query: { search } }); + if (error) throw error; + return data; + } +}); +export const pluginDetailsQuery = (id: string) => queryOptions({ + queryKey: ['plugin', 'store', id], + queryFn: async () => + { + const { data, error } = await storeApi.api.store.plugin.get({ query: { plugin: id } }); + if (error) throw error; + return data; + } }); \ No newline at end of file diff --git a/src/mainview/scripts/queries/system.ts b/src/mainview/scripts/queries/system.ts index 853e3a9..ffc503f 100644 --- a/src/mainview/scripts/queries/system.ts +++ b/src/mainview/scripts/queries/system.ts @@ -46,4 +46,34 @@ export const closeMutation = mutationOptions({ const { error } = await systemApi.api.system.exit.post(); if (error) throw error; } +}); +export const hasUpdateQuery = queryOptions({ + queryKey: ['update'], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.update.get(); + if (error) throw error; + 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/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index 5defdf7..c947d23 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -2,6 +2,7 @@ import { DependencyList, useEffect, useState } from "react"; import { GamepadButtonEvent } from "./gamepads"; import { dispatchFocusedEvent, GetFocusedTree } from "./spatialNavigation"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; +import { isTextInputFocused } from "./utils"; const shortcutMap = new Map Shortcut[])[]>(); const conflictSet = new Set(); @@ -34,6 +35,7 @@ export interface Shortcut button: GamePadButtonCode; heldTime?: number; action?: (e: GamepadButtonEvent) => void; + side?: "left" | "right"; } let isDirty = false; @@ -122,12 +124,21 @@ export function useShortcutContext () if (e.key === 'Escape') { shortcuts.get(GamePadButtonCode.B)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.B })); - } else if (e.key === 'Backspace') + } else { - shortcuts.get(GamePadButtonCode.X)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.X })); - } else if (e.key === ' ') - { - shortcuts.get(GamePadButtonCode.Y)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.Y })); + // We use backspace and space in typing + if (isTextInputFocused()) + { + return false; + } + + if (e.key === 'Backspace') + { + shortcuts.get(GamePadButtonCode.X)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.X })); + } else if (e.key === ' ') + { + shortcuts.get(GamePadButtonCode.Y)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.Y })); + } } }; @@ -190,7 +201,7 @@ export function useShortcutContext () return { shortcuts: array }; } -export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps: DependencyList) +export function useShortcuts (focusKey: string, build: () => Shortcut[], deps?: DependencyList) { useEffect(() => { @@ -210,6 +221,6 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps markDirtyThrottled(); }; - }, [...deps, focusKey]); + }, [focusKey, ...deps ?? []]); } \ No newline at end of file diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index 3d4b182..d076df4 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -1,6 +1,5 @@ import { - FocusDetails, getCurrentFocusKey, init, SpatialNavigation, @@ -9,7 +8,7 @@ import UseFocusableResult, } from "@noriginmedia/norigin-spatial-navigation"; import { RefObject, useEffect, useState } from "react"; -import { focusQueue, Router } from ".."; +import { focusQueue } from "../App"; init({ shouldFocusDOMNode: false, @@ -33,7 +32,7 @@ export function GetFocusedElement (focusKey: string) export function GetFocusedTree (leaf: string): string[] { - const tree: string[] = []; + const tree: string[] = ["window"]; let component = (SpatialNavigation as any).focusableComponents[leaf]; while (component) { @@ -97,13 +96,21 @@ SpatialNavigation.updateLayout = (focusKey) => SpatialNavigation.setFocus = (newFocusKey, focusDetails) => { setFocus(newFocusKey, focusDetails); - dispatchFocusedEvent(new CustomEvent('focuschanged', { bubbles: true, detail: focusDetails })); }; SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) => { + const details: FocusEventDetails = { + ...focusDetails, + focusKey: newFocusKey, + focusKeyChanged: newFocusKey !== getCurrentFocusKey(), + node: GetFocusedElement(newFocusKey) + }; setCurrentFocusedKey(newFocusKey, focusDetails); - window.dispatchEvent(new CustomEvent('focuschanged', { bubbles: true, detail: focusDetails })); + (GetFocusedElement(newFocusKey) ?? window).dispatchEvent(new CustomEvent('focuschanged', { + bubbles: true, + detail: details + })); }; SpatialNavigation.updateFocusable = (key, data) => diff --git a/src/mainview/scripts/types.ts b/src/mainview/scripts/types.ts index ca39657..fbfcb36 100644 --- a/src/mainview/scripts/types.ts +++ b/src/mainview/scripts/types.ts @@ -1,3 +1,5 @@ +import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; + export const FOCUS_KEYS = { NAV_CATEGORIES: "NAV_CATEGORIES", NAV_CATEGORY: (cat: string) => `NAV_CAT_${cat}`, @@ -6,8 +8,13 @@ export const FOCUS_KEYS = { EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`, EMULATOR_CUSTOM_PATH: (id: string) => `EMULATOR_CUSTOM_PATH_${id}`, CONTEXT_DIALOG_OPTION: (contextId: string, id: string) => `${contextId}_LIST_OPTION${id}`, + CONTEXT_DIALOG: (contextId: string) => `${contextId}_CONTEXT_DIALOG`, EMULATOR_CARD: (id: string) => `EMULATOR_${id}`, GAME_SECTION: "GAME_SECTION", GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, + GAME_LIST_CARD: (list: string, id: FrontEndId) => `LIST_${list}_GAME_${id.source}_${id.id}`, + GAME_MATCH: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, STATS_SECTION: "STATS_SECTION", + PLUGIN_ENTRY: (id: string) => `PLUGIN_${id}`, + DOWNLOAD_ENTRY: (source: string, id: string) => `DOWNLOAD_${source}_${id}` } as const; \ No newline at end of file diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index fd2572b..6635bd6 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,9 +1,11 @@ -import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; -import { RefObject, useEffect, useRef, useState } from "react"; +import { LocalSettingsSchema, LocalSettingsType } from '@simeonradivoev/gameflow-sdk/shared'; +import { DependencyList, RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; -import { jobsApi } from "./clientApi"; +import { jobsApi, systemApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; -import { Router } from ".."; +import { AnyRouter, useRouter } from "@tanstack/react-router"; +import { soundMap } from "./audio/audioConstants"; +import { GamepadButtonEvent, oneShotRumble } from "./gamepads"; export type ScrollSaveParams = { id: string; @@ -11,6 +13,12 @@ export type ScrollSaveParams = { storage?: "session" | "local"; shouldSave?: boolean; }; + +export function isTextInputFocused () +{ + return document.activeElement && document.activeElement instanceof HTMLInputElement; +} + export function useScrollSave (data: ScrollSaveParams) { useEffect(() => @@ -59,6 +67,13 @@ export function mobileCheck () return check; }; +export function getLocalSetting (key: TKey): LocalSettingsType[TKey] +{ + const localValueRaw = localStorage.getItem(key); + if (!localValueRaw) return LocalSettingsSchema.shape[key].parse(undefined) as any; + return LocalSettingsSchema.shape[key].parse(JSON.parse(localValueRaw)) as any; +} + export function useLocalSetting (key: TKey) { const [localValue] = useLocalStorage(key, LocalSettingsSchema.shape[key].parse(undefined), { deserializer: (value) => LocalSettingsSchema.shape[key].parse(JSON.parse(value)) }); @@ -218,7 +233,7 @@ export function scrollIntoViewHandler (params?: ScrollIntoViewOptions) return (focusKey: string, node: HTMLElement, details: any) => { if (details.nativeEvent instanceof PointerEvent) return; - node.scrollIntoView({ ...params, behavior: details.instant ? 'instant' : 'smooth' }); + node.scrollIntoView({ ...params, behavior: details.instant || !details.event ? 'instant' : 'smooth' }); }; } @@ -259,10 +274,13 @@ export function useJobStatus, onProgress?: (process: number, data: ExtractField, "data" | "started" | "progress" | "completed" | "ended", 'data'>) => void, + onWaiting?: () => void, onEnded?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onCompleted?: (data: ExtractField, "completed" | "ended", 'data'>) => void; onError?: (error: string) => void; - } + onClosed?: () => void; + }, + deps?: DependencyList ) { type Response = JobResponse; @@ -278,6 +296,7 @@ export function useJobStatus init?.onClosed?.()); sub.subscribe(({ data }) => { switch (data.type) @@ -298,6 +317,11 @@ export function useJobStatus) => void) +{ + const router = useRouter(); + const prevIndex = useRef(router.history.location.state.__TSR_index); + + useEffect(() => + { + const unsub = router.history.subscribe(() => + { + const currentIndex = router.history.location.state.__TSR_index; + const isBack = currentIndex < prevIndex.current; + + if (isBack) + { + callback(router.history.location.state); + } + + prevIndex.current = currentIndex; + }); + + return unsub; + }, [router]); +} + +export function showKeyboardHandler (activeControl: string | undefined, node?: HTMLInputElement) +{ + if (node && node.type !== 'checkbox' && (activeControl === 'gamepad' || activeControl === 'touch')) + { + var rect = node.getBoundingClientRect(); + systemApi.api.system.show_keyboard.post({ + XPosition: rect.x, + YPosition: rect.y, + Width: rect.width, + Height: rect.height + }); + } +}; \ No newline at end of file diff --git a/src/mainview/scripts/windowEvents.ts b/src/mainview/scripts/windowEvents.ts index c6f7ab8..88dd926 100644 --- a/src/mainview/scripts/windowEvents.ts +++ b/src/mainview/scripts/windowEvents.ts @@ -2,7 +2,7 @@ import { settingsApi } from "./clientApi"; const handleResize = () => { - settingsApi.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } }); + settingsApi.api.settings.local({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } }); }; window.addEventListener("resize", handleResize); import.meta.hot?.dispose(() => window.removeEventListener('resize', handleResize)); @@ -13,7 +13,7 @@ var screenPositionInternal: NodeJS.Timeout = setInterval(() => { if (lastWindowPosX != window.screenX || lastWindowPosY != window.screenY) { - settingsApi.api.settings({ id: 'windowPosition' }).post({ value: { x: window.screenX, y: window.screenY } }); + settingsApi.api.settings.local({ id: 'windowPosition' }).post({ value: { x: window.screenX, y: window.screenY } }); } lastWindowPosX = window.screenX; diff --git a/src/mainview/types.d.ts b/src/mainview/types.d.ts index 699ca3c..2a0c101 100644 --- a/src/mainview/types.d.ts +++ b/src/mainview/types.d.ts @@ -1,5 +1,6 @@ declare const __HOST__: string; declare const __PUBLIC__: boolean; +declare const __FLATPAK__: boolean; declare const __EMULATORS__: Record; declare module "@emulators" { const data: Record; @@ -16,16 +17,60 @@ declare global "save-scroll"?: boolean; } } + + module "@noriginmedia/norigin-spatial-navigation" { + declare interface FocusDetails + { + instant?: boolean; + sound?: string; + } + } + + module "@tanstack/react-router" { + declare interface HistoryState + { + eventType?: string; + } + } + } +declare interface FocusEventDetails +{ + focusKey: string; + instant?: boolean; + sound?: string; + nativeEvent?: any; + event?: Event; + node: HTMLElement | undefined; + focusKeyChanged: boolean; +} + +declare interface GameMeta extends FocusParams +{ + id: string, + onSelect?: () => void, + onQuickAction?: () => void, + title: string, + subtitle?: any, + previewUrls?: string | URL[]; + previewSrcset?: string; +}; + declare interface FocusParams { onFocus?: (focusKey: string, node: HTMLElement, details: Record) => void; } +declare interface InteractParamsArgs +{ + event?: Event, + focusKey?: string; +} + declare interface InteractParams { - onAction?: (e?: Event) => void; + onAction?: (ctx: InteractParamsArgs) => void; } declare interface FilterOption extends FocusParams, InteractParams @@ -33,4 +78,11 @@ declare interface FilterOption extends FocusParams, InteractParams label: string; selected: boolean; icon?: any; -} \ No newline at end of file +} + +declare type EmulatorJsMessage = { type: 'restart'; } | +{ type: 'pause'; paused: boolean; } | +{ type: 'exit'; save?: File; } | +{ type: 'save', save: File, screenshot?: File, type: string; } | +{ type: 'loaded'; } | +{ type: 'requestSave'; }; \ No newline at end of file diff --git a/src/packages/gameflow-sdk/README.md b/src/packages/gameflow-sdk/README.md new file mode 100644 index 0000000..8338233 --- /dev/null +++ b/src/packages/gameflow-sdk/README.md @@ -0,0 +1,30 @@ +# Gameflow Deck SDK + +This is the type definitions for Gameflow Deck plugins. + +## Developing a plugin + +The plugin must have a default export class of type `PluginType`. It exposes the context and all the hooks to be tapped. +Gameflow uses the [Tapable Hooks](https://github.com/webpack/tapable). + +The package must expose a main script gameflow will import and validate. It must implement the type fields on `PluginDescriptionType`. + +## Publishing + +For the plugin to show up in the UI for download. It must be published to NPM with the `gameflow-plugin` keyword. Gameflow uses bun to install plugins as packages from npmjs. +Follow publishing instruction check the [NPM Docs](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry) + +## Dependencies + +Peer dependencies will not be installed when the run adds the plugin package. They are provided by gameflow. +All peer dependencies can be marked as external as gameflow provides it. There is a helper build script that does all that for you, to run it use. + +`bunx gameflow-build --entry=index.ts` + +supported arguments are +`--entry` the entry of the app to build +`--outdir` Where to build. Default is 'dist' +`--minify` Minify the code. Default is 'false' +`--sourcemap` Include a source map. Default is 'none' + +If you want to include dependencies that gameflow does not provide you have to bundle them in. Gameflow does not load dependencies for you. diff --git a/src/packages/gameflow-sdk/build.ts b/src/packages/gameflow-sdk/build.ts new file mode 100644 index 0000000..9f18b88 --- /dev/null +++ b/src/packages/gameflow-sdk/build.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun + +import pkg from './package.json'; + +import { parseArgs } from "util"; + +const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + outdir: { type: "string", default: "dist" }, + minify: { type: "boolean", default: false }, + sourcemap: { type: "string", default: "none" }, // "none" | "inline" | "external" + entry: { type: "string", default: "src/index.ts" }, + }, + allowPositionals: true, +}); + +await Bun.build({ + entrypoints: [values.entry], + outdir: values.outdir, + minify: values.minify, + sourcemap: values.sourcemap as any, + external: [...Object.keys(pkg.peerDependencies), pkg.name], + target: "bun", +}); + +console.log(`✅ Built to ${values.outdir}`); \ No newline at end of file diff --git a/src/packages/gameflow-sdk/hooks/app.ts b/src/packages/gameflow-sdk/hooks/app.ts new file mode 100644 index 0000000..1b73daa --- /dev/null +++ b/src/packages/gameflow-sdk/hooks/app.ts @@ -0,0 +1,49 @@ +import { AsyncSeriesBailHook } from "tapable"; +import AuthHooks from "./auth"; +import EmulatorHooks from "./emulators"; +import GameHooks from "./games"; +import StoreHooks from "./store"; +import { DownloadFileEntry, ProgressStats } from "../shared"; + +export class GameflowHooks +{ + games = new GameHooks(); + emulators = new EmulatorHooks(); + auth = new AuthHooks(); + store = new StoreHooks(); + /** Download the given files and return their final paths. */ + downloadFiles = new AsyncSeriesBailHook<[ctx: { + /** Unique ID of the download */ + id: string, + /** The root download path. Each file has it's own download sub path */ + downloadPath: string, + abortSignal?: AbortSignal, + /** Authentication needed for download. Should be put in the headers. */ + auth?: string, + /** The files to download */ + files: DownloadFileEntry[]; + /** Call it to update progress in the UI */ + updateProgress: (stats: ProgressStats) => void; + + }], { + /** What downloaded the files. Will be passed to {@link postDownloadFiles} files hook. */ + source: string, + /** The file paths ot the downloaded files. */ + files: string[]; + } | undefined>(['ctx']); + /** Called after {@link downloadFiles} has finished downloading. + * @returns The modified file paths. + */ + postDownloadFiles = new AsyncSeriesBailHook<[ctx: { + /** Who downloaded the files. Passed from the {@link downloadFiles} hook. */ + source: string; + /** Can be directories or files */ + files: string[]; + /** The root downloads folder. */ + downloadPath: string, + /** The sub path where the archive should be extracted to. This will be a sub path of `path_fs` */ + extract_path?: string; + /** This is the parent path for the extracted files. */ + path_fs?: string; + }], string[] | undefined>(['ctx']); +} \ No newline at end of file diff --git a/src/bun/api/hooks/auth.ts b/src/packages/gameflow-sdk/hooks/auth.ts similarity index 71% rename from src/bun/api/hooks/auth.ts rename to src/packages/gameflow-sdk/hooks/auth.ts index 7234992..cb8dd1b 100644 --- a/src/bun/api/hooks/auth.ts +++ b/src/packages/gameflow-sdk/hooks/auth.ts @@ -1,6 +1,8 @@ -import { AsyncSeriesHook } from "tapable"; -export class AuthHooks +import { AsyncSeriesHook } from "tapable"; +import { DownloadFileEntry } from "../shared"; + +export default class AuthHooks { loginComplete = new AsyncSeriesHook<[ctx: { service: string; diff --git a/src/packages/gameflow-sdk/hooks/emulators.ts b/src/packages/gameflow-sdk/hooks/emulators.ts new file mode 100644 index 0000000..d852d06 --- /dev/null +++ b/src/packages/gameflow-sdk/hooks/emulators.ts @@ -0,0 +1,42 @@ + +import { EmulatorPostInstallContextType } from "../index"; +import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "../shared"; +import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; + +export default class EmulatorHooks +{ + /** Download emulator bios files */ + fetchBiosDownload = new AsyncSeriesBailHook<[ctx: { + emulator: string; + systems: EmulatorSystem[]; + biosFolder: string; + }], { auth?: string, files: DownloadFileEntry[]; } | undefined>(['ctx']); + + /** + * Triggered when emulator is downloaded or updated + */ + emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContextType], { emulator: string; }>(['ctx']); + /** Find locations of emulators on the system. Be it already installed ones or ones downloaded by the store. */ + findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']); + /** Match emulators for a given system */ + findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']); + + constructor() + { + this.emulatorPostInstall.intercept({ + register (tap) + { + return { + ...tap, + fn: async (ctx: EmulatorPostInstallContextType, ...rest: any[]) => + { + if (ctx.emulator === tap.emulator) + { + tap.fn(ctx, ...rest); + } + } + }; + }, + }); + } +} \ No newline at end of file diff --git a/src/packages/gameflow-sdk/hooks/games.ts b/src/packages/gameflow-sdk/hooks/games.ts new file mode 100644 index 0000000..314b138 --- /dev/null +++ b/src/packages/gameflow-sdk/hooks/games.ts @@ -0,0 +1,202 @@ + +import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots, DownloadLookupEntry, DownloadLookupDetails, DownloadsLookupFilterValues, DownloadsLookupFilter } from '../shared'; +import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; + +export default class GameHooks +{ + /** Build commands the game can be launched with. */ + buildLaunchCommands = new AsyncSeriesBailHook<[ctx: { + source: string | null; + sourceId: string | null; + id: FrontEndId; + systemSlug: string; + gamePath: string | null, + /** The glob pattern for the main executable of the game */ + mainGlob?: string | null, + }], CommandEntry[] | Error | undefined>(['ctx']); + /** override the launch command for an emulator + * @returns The argument list to be used when running the emulator. + * If no emulator bin in the command entry is found the actual command will be used as the bin. + */ + emulatorLaunch = new AsyncSeriesBailHook<[ctx: { + /** The auto generated command for example based on the ES-DE listing */ + autoValidCommand: CommandEntry; + /** Don't actually launch just see if it can be launched */ + dryRun: boolean, + game: { + /** The source of the game */ + source?: string; + /** The ID of the source. This could be for example the ROMM ID the game was */ + sourceId?: string; + id: FrontEndId; + platformSlug?: string; + }; + }], { args: string[], savesPath?: SaveSlots; env?: Record; } | undefined, { emulator: string; }>(['ctx']); + /** + * Is the given emulator for the given command supported + * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. + * + */ + emulatorLaunchSupport = new SyncBailHook<[ctx: { + emulator: string; + source?: EmulatorSourceEntryType; + }], EmulatorSupport | undefined, { emulator: string; }>(['ctx']); + /** + * Fetches and returns a list of games converted to frontend. + */ + fetchGames = new AsyncSeriesHook<[ctx: { + query: GameListFilterType; + games: FrontEndGameTypeWithIds[]; + }]>(['ctx']); + /** Return all filters the users can apply for a give source. */ + fetchFilters = new AsyncSeriesHook<[ctx: { + source?: string; + filters: FrontEndFilterSets; + }]>(['ctx']); + /** Get game metadata */ + fetchGame = new AsyncSeriesBailHook<[ctx: { + source: string; + localGame?: FrontEndGameTypeDetailed; + id: string; + }], FrontEndGameTypeDetailed | undefined>(['ctx']); + /** Search for a given game based on the igdb id or ra id. */ + searchGame = new AsyncSeriesBailHook<[ctx: { + source: string; + igdb_id?: number; + ra_id?: number; + }], FrontEndGameTypeDetailed | undefined>(['ctx']); + /** Get download file URLs */ + fetchDownloads = new AsyncSeriesBailHook<[ctx: { + source: string; + id: string; + /** If there are multiple downloads, use the one with same ID */ + downloadId?: string; + }], DownloadInfo[] | undefined>(['ctx']); + /** Get the paths to rom files. This is mainly used for emulator js. */ + fetchRomFiles = new AsyncSeriesBailHook<[ctx: { + source: string; + id: string; + }], string[] | undefined>(['ctx']); + fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: { + game: FrontEndGameTypeDetailed, + games: (FrontEndGameType & { metadata?: any; })[]; + }]>(['ctx']); + fetchRecommendedGamesForEmulator = new AsyncSeriesHook<[cts: { + emulator: EmulatorPackageType; + systems: EmulatorSystem[]; + games: FrontEndGameType[]; + }]>(['ctx']); + fetchPlatform = new AsyncSeriesBailHook<[ctx: { + source: string; + id: string; + }], FrontEndPlatformType | undefined>(['ctx']); + /** Lookup a given platform with a given slug or id. This may or may not exist. */ + platformLookup = new AsyncSeriesBailHook<[ctx: { + source?: string; + id?: string; + slug?: string; + }], { + slug: string; + url_logo?: string | null; + name?: string; + family_name?: string; + } | undefined>(['ctx']); + /** Lookup downloads based on a search pattern. + * This is just downloads. Doesn't actually have to be a game. + * This is mainly used to manually add games from outside sources */ + downloadsLookup = new AsyncSeriesWaterfallHook<[matches: Map, ctx: { + page?: number; + rows?: number; + } & DownloadsLookupFilter]>(['matches', 'ctx']); + /** List all available filters */ + downloadsLookupFilters = new AsyncSeriesHook<[ctx: { + filters: DownloadsLookupFilterValues; + }]>(['ctx']); + /** Look for the files for a download the user can pick from */ + downloadLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], DownloadLookupDetails | undefined>(['ctx']); + /** Look up game metadata based on a search */ + gameLookup = new AsyncSeriesWaterfallHook<[matches: Map, ctx: { + source?: string, + id?: string; + search?: string; + }]>(['matches', 'ctx']); + fetchPlatforms = new AsyncSeriesHook<[ctx: { + platforms: FrontEndPlatformType[]; + }]>(['ctx']); + /** Called before the game is played. */ + prePlay = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + saveFolderSlots: Record; + setProgress: (progress: number, state: string) => void, + command: CommandEntry; + gameInfo: { + platformSlug?: string; + }; + }]>(["ctx"]); + /** + * Called after the game process has finished. + */ + postPlay = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + saveFolderSlots?: SaveSlots; + /** Auto detected changed files. This is mainly used to see what changed during gameplay */ + changedSaveFiles: { subPath: string, cwd: string; }[], + /** This will be final valid changes to be saved using save integrations like rclone */ + validChangedSaveFiles: Record, + /** The command that was used to launch the game */ + command: CommandEntry; + gameInfo: { + platformSlug?: string; + }; + }]>(["ctx"]); + /** Called after game install + * This includes game being downloaded and registered in the database. + */ + postInstall = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + files: string[]; + info: DownloadInfo; + }]>(['ctx']); + fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']); + fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); + + constructor() + { + this.emulatorLaunchSupport.intercept({ + register (tap) + { + return { + ...tap, + fn: (e: any, ...rest: any[]) => + { + if (e.emulator === tap.emulator) + { + return tap.fn(e, ...rest); + } + } + }; + }, + }); + this.emulatorLaunch.intercept({ + register (tap) + { + return { + ...tap, + fn: async (e: any, ...rest: any[]) => + { + if ((e.autoValidCommand as CommandEntry).emulator === tap.emulator) + { + return tap.fn(e, ...rest); + } + } + }; + }, + }); + } +} \ No newline at end of file diff --git a/src/packages/gameflow-sdk/hooks/store.ts b/src/packages/gameflow-sdk/hooks/store.ts new file mode 100644 index 0000000..c7f43e5 --- /dev/null +++ b/src/packages/gameflow-sdk/hooks/store.ts @@ -0,0 +1,10 @@ +import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed, EmulatorDownloadInfoType } from "../shared"; +import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; + +export default class StoreHooks +{ + fetchFeaturedGames = new AsyncSeriesHook<[ctx: { games: FrontEndGameTypeDetailed[]; }]>(['ctx']); + fetchEmulators = new AsyncSeriesHook<[ctx: { emulators: FrontEndEmulator[]; search?: string; }]>(['ctx']); + fetchEmulator = new AsyncSeriesBailHook<[ctx: { id: string; }], FrontEndEmulatorDetailed | undefined>(['ctx']); + fetchDownload = new AsyncSeriesBailHook<[ctx: { id: string; }], (EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined>(['ctx']); +} \ No newline at end of file diff --git a/src/packages/gameflow-sdk/index.ts b/src/packages/gameflow-sdk/index.ts new file mode 100644 index 0000000..c78c757 --- /dev/null +++ b/src/packages/gameflow-sdk/index.ts @@ -0,0 +1,93 @@ +import z from "zod"; +import { GameflowHooks } from "./hooks/app"; +import { EmulatorDownloadInfoSchema, EmulatorPackageSchema, FrontendNotification, SettingsType } from "./shared"; +import { $ZodRegistry } from "zod/v4/core"; +import Conf from "conf"; +import { EventEmitter } from 'node:events'; +import { TaskQueue } from "./task-queue"; + +export * from "./hooks/app"; +export * from "./task-queue"; + +export interface AppEventMap +{ + exitapp: []; + notification: [FrontendNotification]; + focus: []; +} + +export const PluginContextSchema = z.object({ + hooks: z.instanceof(GameflowHooks) +}); + +export const PluginLoadingContextSchema = z.object({ + setProgress: z.function().input([z.number(), z.string()]).output(z.void()), + config: z.instanceof(Conf).describe("Per plugin config. It will use the settings schema defined in the plugin class"), + zodRegistry: z.instanceof($ZodRegistry).describe("Used by the settings to register metadata for the UI"), + app: z.object({ + config: z.instanceof(Conf), + events: z.instanceof(EventEmitter), + taskQueue: z.instanceof(TaskQueue) + }) +}).extend(PluginContextSchema.shape); + +export const PluginDescriptionSchema = z.object({ + name: z.string(), + displayName: z.string().optional(), + version: z.string(), + description: z.string().optional(), + icon: z.url().optional().describe("Can be an external URL to an image or a data url"), + keywords: z.array(z.string()).optional(), + peerDependencies: z.record(z.string(), z.string()).optional(), + category: z.string().default("other"), + main: z.string().describe("The main entry. It must export a default class implementing PluginType"), + canDisable: z.boolean().default(true).optional().describe("Can the plugin be disabled or enabled by the user"), + autoUpdate: z.boolean().optional().describe("Should the plugin auto update to latest version") +}); + +export const PluginSchema = z.object({ + load: z.function().input([PluginLoadingContextSchema]).output(z.promise(z.void())).describe("Called when the plugin is loaded or reloaded"), + cleanup: z.function().output(z.promise(z.void())).optional().describe("Called when the plugin is unloaded or before it's reloaded"), + settingsSchema: z.instanceof(z.ZodObject).optional().describe("The settings schema. Gameflow will show settings in the UI."), + settingsMigrations: z.record(z.string(), z.function().input([z.instanceof(Conf)]).output(z.void())).optional(), + eventsNames: z.object({ + id: z.string(), + title: z.string().optional(), + description: z.string().optional(), + action: z.string() + }).array().optional().describe("Events will be called when the user presses the button in plugin settings. Each event creates a button."), + onEvent: z.function().input([z.string()]).output(z.object({ + openTab: z.string().optional(), + reload: z.boolean().optional() + }).or(z.record(z.string(), z.any()))).optional() +}); + +export const ActiveGameSchema = z.object({ + process: z.any().optional(), + gameId: z.object({ id: z.string(), source: z.string() }), + source: z.string().optional(), + sourceId: z.string().optional(), + name: z.string(), + command: z.object({ command: z.string().or(z.string().array()), startDir: z.string().optional() }) +}); + +export const EmulatorPostInstallContextSchema = z.object({ + emulator: z.string(), + emulatorPackage: EmulatorPackageSchema.optional(), + path: z.string(), + update: z.boolean(), + info: EmulatorDownloadInfoSchema, +}); + +export type ActiveGameType = z.infer; +export type PluginDescriptionType = z.infer; +export type PluginContextType = z.infer; +export type PluginLoadingContextType = Record> = z.infer & { + config: Conf; +}; +export type PluginType = Record> = Omit, "load" | 'settingsMigrations'> & { + load: (ctx: PluginLoadingContextType) => Promise; + settingsMigrations?: Record) => void>; +}; +export type EmulatorPostInstallContextType = z.infer; + diff --git a/src/packages/gameflow-sdk/package.json b/src/packages/gameflow-sdk/package.json new file mode 100644 index 0000000..4e4ce28 --- /dev/null +++ b/src/packages/gameflow-sdk/package.json @@ -0,0 +1,43 @@ +{ + "name": "@simeonradivoev/gameflow-sdk", + "version": "1.6.0", + "types": "index.d.ts", + "description": "plugin SDK for the Gameflow Deck Launcher", + "exports": { + ".": "./index.ts", + "./shared": "./shared.ts" + }, + "bin": { + "gameflow-build": "build.ts" + }, + "peerDependencies": { + "7zip-bin": "^5.2.0", + "@auth/core": "^0.34.3", + "cheerio": "^1.2.0", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", + "get-folder-size": "^5.0.0", + "ini": "^6.0.0", + "jimp": "^1.6.1", + "mustache": "^4.2.0", + "node-7z": "^3.0.0", + "node-disk-info": "^1.3.0", + "node-downloader-helper": "^2.1.11", + "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", + "open": "^11.0.0", + "p-queue": "^9.2.0", + "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", + "tapable": "^2.3.3", + "unzip-stream": "^0.3.4", + "zod": "^4.4.3" + }, + "keywords": [ + "gameflow", + "sdk" + ] +} \ No newline at end of file diff --git a/src/packages/gameflow-sdk/sdk.tsconfig.json b/src/packages/gameflow-sdk/sdk.tsconfig.json new file mode 100644 index 0000000..707544a --- /dev/null +++ b/src/packages/gameflow-sdk/sdk.tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": [ + "ES2024" + ], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "emitDeclarationOnly": true, + "declaration": true, + "strict": true, + "outDir": "../../dist-sdk", + "types": [ + "node" + ] + } +} \ No newline at end of file diff --git a/src/packages/gameflow-sdk/shared.ts b/src/packages/gameflow-sdk/shared.ts new file mode 100644 index 0000000..96ddb01 --- /dev/null +++ b/src/packages/gameflow-sdk/shared.ts @@ -0,0 +1,710 @@ +import * as z from "zod"; + +export const settingRegistry = z.registry<{ + dev?: boolean; +}>(); + +export const SettingsSchema = z.object({ + rommAddress: z.url().optional(), + rommUser: z.string().default('admin').optional(), + windowSize: z.object({ width: z.number(), height: z.number() }).optional(), + windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), + downloadPath: z.string(), + launchInFullscreen: z.boolean().default(true), + disabledPlugins: z.array(z.string()).default([]), + emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'), + emulatorWidescreen: z.boolean().default(true) +}); export const LocalSettingsSchema = z.object({ + backgroundBlur: z.boolean().default(true).meta({ title: "Background Blur" }), + backgroundAnimation: z.boolean().default(true).meta({ title: "Background Animation" }), + theme: z.enum(['dark', 'light', 'auto']).default('auto').meta({ title: "Theme" }), + soundEffects: z.boolean().default(true).meta({ title: "Sounds" }), + soundEffectsVolume: z.number().min(0).max(100).default(50).meta({ title: "Sound Volume" }), + hapticsEffects: z.boolean().default(true).meta({ title: "Haptics" }), + showRouterDevOptions: z.boolean().default(false).meta({ title: "Show Router Options" }).register(settingRegistry, { dev: true }), + showQueryDevOptions: z.boolean().default(false).meta({ title: "Show Query Options" }).register(settingRegistry, { dev: true }), + useGameflowKeyboard: z.boolean().default(true).describe("Show the gameflow on screen keyboard when using a controller").meta({ title: "Use Gameflow Keyboard" }), + autoKeybaord: z.boolean().default(true).describe("Open on screen keybaord automatically").meta({ title: "Auto Keyboard" }) +}); +export const GameListFilterSchema = z.object({ + platform_source: z.string().optional(), + platform_slug: z.string().optional(), + platform_id: z.coerce.number().optional(), + collection_id: z.coerce.number().optional(), + collection_source: z.string().optional(), + limit: z.coerce.number().optional(), + search: z.string().optional(), + offset: z.coerce.number().optional(), + source: z.string().optional(), + localOnly: z.coerce.boolean().optional(), + orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(), + age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), +}); +export const DownloadSourceSchema = z.object({ + id: z.string(), + name: z.string() +}); +export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); +export type GameListFilterType = z.infer; +export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() }); +export type DirType = z.infer; +export const CustomEmulatorSchema = z.record(z.string(), z.string()); +export const GithubManifestSchema = z.object({ + sha: z.hash('sha1'), + url: z.url(), + tree: z.array(z.object({ + path: z.string(), + mode: z.string(), + type: z.enum(['blob', 'tree']), + sha: z.hash('sha1'), + url: z.url() + })) +}); +export const StoreGameSaveSchema = z.object({ + cwd: z.string(), + globs: z.string().array() +}); +export const StoreDownloadSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('direct'), + url: z.url(), + name: z.string().optional(), + system: z.string(), + main: z.string().optional(), + saves: z.record(z.string(), StoreGameSaveSchema).optional() + }), + z.object({ + type: z.literal("itch"), + path: z.string(), + name: z.string().optional(), + system: z.string(), + saves: z.record(z.string(), StoreGameSaveSchema).optional() + }) +]); +export const NewGameSchema = z.object({ + name: z.string(), + summary: z.string(), + genres: z.string().regex(/^$|^(\s*\S[^,]*)(\s*,\s*\S[^,]*)*\s*$/, { + message: "Must be a comma-separated list", + }) +}); +export const StoreGameSchema = z.object({ + name: z.string(), + description: z.string(), + version: z.string(), + homepage: z.string().optional(), + keywords: z.string().array().optional(), + genres: z.string().array().optional(), + companies: z.string().array().optional(), + screenshots: z.string().array().optional(), + covers: z.string().array().optional(), + igdb_id: z.number().optional(), + ra_id: z.number().optional(), + sgdb_id: z.number().optional(), + first_release_date: z.union([z.number(), z.date()]).optional(), + player_count: z.string().optional(), + saves: z.record(z.string(), z.record(z.string(), StoreGameSaveSchema)).optional(), + downloads: z.record(z.string(), StoreDownloadSchema) +}); +export const EmulatorPackageSchema = z.object({ + name: z.string(), + description: z.string(), + homepage: z.url(), + logo: z.url(), + type: z.enum(['emulator']), + os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])), + keywords: z.array(z.string()).optional(), + downloads: z.record(z.string(), z.array(z.discriminatedUnion('type', [ + z.object({ + type: z.literal(['github', 'gitlab']), + pattern: z.string(), + path: z.string(), + bin: z.string().optional() + }), + z.object({ + type: z.literal('direct'), + url: z.url(), + bin: z.string().optional() + }), + z.object({ + type: z.literal('scoop'), + url: z.url(), + bin: z.string().optional() + }) + ]))).optional(), + systems: z.array(z.string()), + bios: z.literal(["required", "optional"]).optional() +}); +export const ScoopPackageSchema = z.object({ + version: z.string(), + url: z.url().optional(), + description: z.string(), + bin: z.string().optional(), + architecture: z.record(z.string(), z.object({ + url: z.url(), + hash: z.string().optional(), + extract_dir: z.string().optional() + })).optional() +}); +export const SystemInfoSchema = z.object({ + battery: z.object({ + percent: z.number(), + isCharging: z.boolean(), + acConnected: z.boolean(), + hasBattery: z.boolean() + }), + wifiConnections: z.array(z.object({ signalLevel: z.number() })), + bluetoothDevices: z.array(z.object({ connected: z.boolean() })) +}); +export const GithubReleaseSchema = z.object({ + id: z.number(), + tag_name: z.string().optional(), + url: z.url(), + body: z.string(), + assets: z.array(z.object({ + name: z.string(), + browser_download_url: z.url(), + content_type: z.string().optional() + })) +}); +export const EmulatorDownloadInfoSchema = z.object({ + id: z.string(), + version: z.string().optional(), + url: z.url().optional(), + description: z.string().optional(), + downloadDate: z.coerce.date(), + type: z.string() +}); +export const PluginEntrySchema = z.object({ + downloads: z.object({ + monthly: z.number(), + weekly: z.number() + }), + searchScore: z.number(), + installed: z.boolean(), + update: z.object({ from: z.string() }).optional(), + package: z.object({ + name: z.string(), + keywords: z.string().array(), + version: z.string(), + description: z.string().optional(), + sanitized_name: z.string(), + license: z.string().optional(), + publisher: z.object({ + email: z.string(), + username: z.string(), + trustedPublisher: z.object({ + id: z.string(), + oidcConfigId: z.string() + }).optional() + }), + date: z.coerce.date(), + links: z.object({ + homepage: z.string().optional(), + repository: z.string().optional(), + bugs: z.string().optional(), + npm: z.url() + }) + }) +}); +export const PluginBunDetailsSchema = z.object({ + name: z.string(), + keywords: z.string().array(), + version: z.string(), + author: z.object({ name: z.string().optional() }).optional(), + license: z.string().optional(), + devDependencies: z.record(z.string(), z.string()).optional(), + dependencies: z.record(z.string(), z.string()).optional(), + maintainers: z.object({ name: z.string() }).array().optional(), + dist: z.object({ unpackedSize: z.number() }), + description: z.string().optional(), + _npmUser: z.object({ name: z.string() }).optional() +}); +export type EmulatorPackageType = z.infer; +export type StoreGameType = z.infer; +export type StoreDownloadType = z.infer; +export type SettingsType = z.infer; +export type LocalSettingsType = z.infer; +export const PlatformSchema = z.object({ slug: z.string() }); +export type SystemInfoType = z.infer; +export type EmulatorDownloadInfoType = z.infer; +export type DownloadSourceType = z.infer; +export type PluginEntryType = z.infer; +export type PluginBunDetailsType = z.infer; + +export interface SaveFileChange +{ + subPath: string | string[]; + isGlob?: true; + cwd: string; + shared: boolean; + fixedSize?: boolean; +} + +export type EmulatorSourceType = 'custom' | 'store' | 'registry' | 'system' | 'static' | 'embedded'; + +export interface EmulatorSourceEntryType +{ + binPath: string; + rootPath?: string; + type: EmulatorSourceType; + /** Does the emulator exist in the file system */ + exists: boolean; +} + +export interface FrontEndEmulator +{ + name: string; + source: string; + logo: string; + systems: EmulatorSystem[]; + description?: string; + gameCount: number; + validSources: EmulatorSourceEntryType[]; + integrations: EmulatorSupport[]; +} + +export interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; } + +export interface FrontEndEmulatorDetailedDownload +{ + name: string; + type: string | undefined; + version?: string; +} + +export interface FrontEndEmulatorDetailed extends FrontEndEmulator +{ + homepage: string; + description: string; + downloads: FrontEndEmulatorDetailedDownload[]; + keywords?: string[]; + screenshots: string[]; + biosRequirement?: "required" | "optional"; + bios?: string[]; + storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; description?: string; }; +} + +export interface FrontEndGameTypeDetailedAchievement +{ + id: string; + title: string; + description?: string; + date?: Date; + date_hardcode?: Date; + badge_url?: string; + display_order: number; + type?: string; +} + +export interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator +{ + +} + +export interface FrontEndGameTypeDetailed extends Exclude +{ + summary: string | null; + fs_size_bytes: number | null; + missing: boolean; + local: boolean; + version?: string | null; + version_system?: string | null; + version_source?: string | null; + metadata: FrontEndGameMetadataDetailed, + emulators?: FrontEndGameTypeDetailedEmulator[], + achievements?: { + unlocked: number; + total: number; + entires: FrontEndGameTypeDetailedAchievement[]; + }; +}; + +export interface Drive +{ + parent: string | null; + device: string; + label: string; + mountPoint: string | null; + type: string; + size: number; + used: number; + isRemovable: boolean; + interfaceType: string | null; + hasWriteAccess: boolean; + hasReadAccess: boolean; +} + +export interface DownloadsDrive +{ + device: string; + label: string; + mountPoint: string | null; + isRemovable: boolean; + size: number; + used: number; + isCurrentlyUsed: boolean; + unusableReason: 'not_enough_space' | 'already_used' | null; +} + +export interface FrontendNotification +{ + title?: string; + message: string; + type: 'success' | 'error' | 'info' | 'custom'; + icon?: "save" | "upload" | "clock"; + duration?: number; +} + +export interface CommandEntry +{ + /** The ID of the command. Could be just an index or a string */ + id: string | number; + /** The front end label for the command. Mainly gotten from ES-DE list */ + label?: string; + /** Compiled command to be executed */ + command: string | string[]; + /** Environment variables */ + env?: Record, + /** The path the spawned process will start at */ + startDir?: string; + /** Is the command valid, for example does the executable exists */ + valid: boolean; + /** Run the command as shell. Defaults is true */ + shell?: boolean; + /** For what emulator is the command */ + emulator?: string; + /** Where the emulator came from */ + emulatorSource?: EmulatorSourceType; + /** Metadata for the command */ + metadata: { + romPath?: string; + emulatorBin?: string; + /** The root directory of the emulator */ + emulatorDir?: string; + }; +} + +export interface FrontEndId +{ + id: string; + source: string; +} + +// Stuff stored in the local sqlite metadata field +export interface LocalGameMetadata +{ + genres?: string[], + companies?: string[], + game_modes?: string[], + age_ratings?: string[]; + player_count?: string; + first_release_date?: number; + average_rating?: number; +} + +export interface FrontEndPlatformType +{ + id: FrontEndId; + slug: string; + name: string; + family_name?: string | null; + path_cover: string | null; + game_count: number; + updated_at: Date; + hasLocal: boolean; + paths_screenshots: string[]; +} + +export interface FrontEndGameTypeWithIds extends FrontEndGameType +{ + igdb_id: number | null; + ra_id: number | null; +} + +export interface FrontEndFilterSets +{ + age_ratings: Set, + player_counts: Set, + languages: Set, + companies: Set, + genres: Set; +} + +export interface FrontEndFilterLists +{ + age_ratings: string[], + player_counts: string[], + languages: string[], + companies: string[], + genres: string[]; +} + +export interface FrontEndGameMetadata +{ + first_release_date: Date | null; +} + +export interface FrontEndGameMetadataDetailed extends FrontEndGameMetadata +{ + genres: string[], + companies: string[], + game_modes: string[], + age_ratings: string[]; + player_count: string | null; + average_rating: number | null; +} + +export interface FrontEndGameType +{ + platform_display_name: string | null, + path_platform_cover: string | null; + id: FrontEndId, + source: string | null, + source_id: string | null, + path_fs: string | null, + path_covers: string[], + last_played: Date | null, + updated_at: Date, + metadata: FrontEndGameMetadata, + slug: string | null, + name: string | null, + platform_id: number | null, + platform_slug: string | null, + paths_screenshots: string[]; +}; + +export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued'; + +export interface GameInstallProgress +{ + progress?: number; + status?: GameStatusType; + details?: string; + commands?: CommandEntry[]; + error?: any; +} + +export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted'; +export type GameInstallProgressEvent = 'refresh'; + +export interface FrontEndJob +{ + id: string; + data: any; + progress: number; + state?: string; + status: string; +} + +export interface FrontendPlugin +{ + name: string; + displayName?: string; + description?: string; + category: string; + enabled: boolean; + canDisable: boolean; + canUninstall: boolean; + source: PluginSourceType; + hasSettings: boolean; + version: string; + icon?: string; + update?: PluginUpdateCheck; +} + +export interface PluginUpdateCheck +{ + current: string; + new: string; +} + +export type PluginSourceType = "builtin" | "store"; + +export type KeysWithValueAssignableTo = { + [K in keyof T]: Exclude extends Value ? K : never; +}[keyof T]; + +export interface DownloadInfo +{ + id: string; + screenshotUrls: string[]; + coverUrl: string; + platform?: DownloadPlatform; + slug?: string; + path_fs?: string; + main_glob?: string; + summary?: string; + name: string; + last_played?: Date; + igdb_id?: number; + ra_id?: number; + source_id: string; + system_slug: string; + extract_path?: string; + metadata?: any; + files: DownloadFileEntry[]; + auth?: string; + version?: string; + version_source?: string; + version_system?: string; +} + +export interface DownloadPlatform +{ + id: string; + source: string; + igdb_id?: number; + igdb_slug?: string; + ra_id?: number; + moby_id?: number; + slug: string; + name: string; + /** Like Sony or Nintendo */ + family_name?: string; +} + +export interface DownloadFileEntry +{ + url: URL; + /** The path of the file, excluding the name */ + file_path: string; + /** Just the name of the file including the extension */ + file_name: string; + /** Checksum of the file */ + sha1?: string; + /** Size in bytes */ + size?: number; +} + +export interface LocalDownloadFileEntry extends DownloadFileEntry +{ + /** Exists on the file system */ + exists: boolean; + /** Matches the checksum */ + matches: boolean; +} + +export interface FrontEndCollection +{ + id: FrontEndId; + name: string; + description: string; + path_platform_cover: string | null; + game_count: number; +} + +export type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config"; + +export interface EmulatorSupport +{ + id: string; + source?: EmulatorSourceEntryType; + supportLevel?: "partial" | "full"; + capabilities?: EmulatorCapabilities[]; +} + +export interface GameLookup +{ + source: string; + id: string; + coverUrl: string | null | undefined; + slug: string | null | undefined; + screenshotUrls: string[]; + name: string; + summary: string | null | undefined; + genres: string[]; + companies: string[]; + game_modes: string[]; + age_ratings: string[]; + player_count: string | undefined; + first_release_date: number | undefined; + average_rating: number | undefined; + keywords: string[]; + igdb_id: number | undefined; + platforms: { + id: number; + name?: string | null; + displayName: string; + slug: string; + }[]; +} + +export interface DownloadLookupEntry +{ + source: string; + id: string; + cover_url: string | null | undefined; + name: string; + summary: string | null | undefined; + size: number | null | undefined; + date: Date | null | undefined; + rating: number | null | undefined; + view_count: number | null | undefined; + download_count: number | null | undefined; + comment_count: number | null | undefined; +} + +export interface DownloadLookupDetailsFile +{ + id: string; + format: string | null | undefined; + mtime: Date | null | undefined; + size: number | null | undefined; + download_url: string; +} + +export interface DownloadLookupDetails +{ + source: string; + id: string; + cover_url: string | null | undefined; + name: string; + summary: string | null | undefined; + date: Date | null | undefined; + files: DownloadLookupDetailsFile[]; +} + +export interface AutoSaveChange +{ + subPath: string; + cwd: string; +} + +export interface AppInfoContext +{ + activeTaskProgress: number | null; +} + +export type SaveSlots = Record; + +/** Jobs that are downloading stuff can implement this data interface to show up in the downloads screen */ +export interface DownloadJobData extends Partial> +{ + preview_url?: string | null; + name?: string; +} + +export interface ProgressStats +{ + progress: number; + speed: number; + total: number; + downloaded: number; +} + +export interface DownloadsLookupFilter +{ + source?: string, + orderBy?: string, + search?: string; + sortDirection?: "desc" | "asc"; +} + +export interface DownloadsLookupFilterValues +{ + orderBy: string[], + source: string[]; +} \ No newline at end of file diff --git a/src/bun/api/task-queue.ts b/src/packages/gameflow-sdk/task-queue.ts similarity index 60% rename from src/bun/api/task-queue.ts rename to src/packages/gameflow-sdk/task-queue.ts index 2ef241c..e86cebc 100644 --- a/src/bun/api/task-queue.ts +++ b/src/packages/gameflow-sdk/task-queue.ts @@ -1,7 +1,7 @@ - import EventEmitter from 'node:events'; import z from 'zod'; +import { JobStatus } from './shared'; export class TaskQueue { @@ -9,14 +9,33 @@ export class TaskQueue private queue?: JobContext, any, string>[] = []; private events?: EventEmitter = new EventEmitter(); - public enqueue (id: string, job: T): T extends IJob + constructor() + { + // we need a default error listener or app crashes + this.events?.addListener('error', e => + { + console.error(e); + }); + } + + public enqueue (id: string, job: T, options?: { throwOnCancel?: boolean; }): T extends IJob ? Promise : never { this.disposeSafeguard(); if (!this.queue || !this.events) throw new Error("Queue disposed"); - const context = new JobContext(id, this.events, job); + if (this.activeQueue.some(j => j.id === id)) throw new Error(`Job with ID ${id} already active`); + if (this.queue.some(j => j.id === id)) throw new Error(`Job with ${id} already queued`); + const context = new JobContext(id, this.events, job, options); this.queue.push(context as any); + context.abortSignal.addEventListener('abort', () => + { + const queueIndex = this.queue?.findIndex(c => c === context); + if (queueIndex !== undefined && queueIndex >= 0) + { + this.queue?.splice(queueIndex, 1); + } + }); this.events?.emit('queued', { id: context.id, job: context }); this.processQueue(); return context.promise.promise as any; @@ -26,7 +45,24 @@ export class TaskQueue { if (!this.queue) return Promise.resolve(); - const next = this.queue.filter(j => !j.job.group || !this.activeQueue.some(a => a.job.group === j.job.group)).map((job, i) => ({ i, job })); + let activeGroupsSet = new Set(this.activeQueue.filter(j => j.job.group).map(j => j.job.group)); + const next = this.queue.filter(j => + { + if (j.job.group) + { + // Only take one task per group to be active + if (!activeGroupsSet.has(j.job.group)) + { + activeGroupsSet.add(j.job.group); + return true; + } + } else + { + return true; + } + + return false; + }).map((job, i) => ({ i, job })); next.reverse().forEach(({ i }) => this.queue!.splice(i, 1)); @@ -34,7 +70,7 @@ export class TaskQueue { job.job.start(); this.activeQueue.push(job.job); - job.job.promise.promise.finally(() => + job.job.promise.promise.catch(e => { }).finally(() => { const index = this.activeQueue.indexOf(job.job); this.activeQueue.splice(index, 1); @@ -55,6 +91,11 @@ export class TaskQueue return this.activeQueue.length > 0; } + public hasQueued () + { + return this.queue && this.queue.length > 0; + } + public hasActiveOfType (type: any) { for (const entry of this.activeQueue) @@ -73,6 +114,38 @@ export class TaskQueue return job?.promise.promise ?? Promise.resolve(); } + public waitForAll () + { + return new Promise((resolve) => + { + if (!this.hasActive()) + { + resolve(true); + return; + } + + const handleEnded = () => + { + if (!this.hasActive() && !this.hasQueued()) + { + resolve(true); + this.events?.removeListener('ended', handleEnded); + this.events?.removeListener('abort', handleEnded); + } + }; + this.events?.on('ended', handleEnded); + this.events?.on('abort', handleEnded); + }); + } + + public cancelJob (id: string) + { + const job = this.queue?.find(j => j.id === id) + ?? this.activeQueue?.find(j => j.id === id); + + job?.abort('cancel'); + } + public findJob ( id: string, type: new (...args: any[]) => T @@ -90,6 +163,16 @@ export class TaskQueue return undefined as any; } + public getActiveJobs () + { + return this.activeQueue; + } + + public getQueuedJobs () + { + return this.queue; + } + public on (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void { this.events?.on(event, listener); @@ -105,7 +188,18 @@ export class TaskQueue { this.queue = []; this.activeQueue.forEach(c => c.abort()); - return Promise.all(this.activeQueue.map(c => c.promise.promise)); + 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); + }); + })); } } @@ -121,35 +215,36 @@ export interface EventsList queued: [e: BaseEvent]; } -interface BaseEvent +export interface BaseEvent { id: string; job: IPublicJob; } -interface ErrorEvent extends BaseEvent +export interface ErrorEvent extends BaseEvent { error: unknown; } -interface AbortEvent extends BaseEvent +export interface AbortEvent extends BaseEvent { reason?: any; } -interface ProgressEvent extends BaseEvent +export interface ProgressEvent extends BaseEvent { progress: number; state?: string; } -interface CompletedEvent extends BaseEvent +export interface CompletedEvent extends BaseEvent { } export interface IJob { + /** What group does the job belong to. Grouped jobs can only have 1 active job per group */ group?: string; start (context: JobContext, TData, TState>): Promise; exposeData?(): TData; @@ -190,12 +285,14 @@ export class JobContext, TData, TState extends str private events: EventEmitter; private abortController: AbortController; private m_promise: PromiseWithResolvers; + private throwOnCancel: boolean; private readonly m_job: T; - constructor(id: string, events: EventEmitter, job: T) + constructor(id: string, events: EventEmitter, job: T, options?: { throwOnCancel?: boolean; }) { this.m_id = id; this.m_job = job; + this.throwOnCancel = options?.throwOnCancel ?? false; this.abortController = new AbortController(); this.abortController.signal.addEventListener('abort', () => { @@ -212,20 +309,40 @@ export class JobContext, TData, TState extends str { this.events.emit('started', { id: this.m_id, job: this }); await this.m_job.start(this); - this.completed = true; - this.events.emit('completed', { id: this.m_id, job: this }); - this.m_promise.resolve(this.m_job.exposeData?.()); - + if (!this.abortSignal.aborted) + { + this.completed = true; + this.events.emit('completed', { id: this.m_id, job: this }); + this.m_promise.resolve(this.m_job.exposeData?.()); + } else + { + this.m_promise.resolve(undefined); + } } catch (error) { - if (error !== 'cancel') + if (error instanceof Event) { - console.error(error); + if (error.target instanceof AbortSignal) + { + if (this.throwOnCancel) + { + this.m_promise.reject(this.abortSignal.reason); + } else + { + this.m_promise.resolve(undefined); + } + } else + { + console.error(error); + this.m_promise.reject(error); + } + } else + { + this.events.emit('error', { id: this.m_id, job: this, error }); + this.error = error; + this.m_promise.reject(error); } - this.events.emit('error', { id: this.m_id, job: this, error }); - this.error = error; - this.m_promise.reject(error); } finally { this.running = false; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 66e3a78..3c9d776 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,9 +1,3 @@ - - -import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation'; -import { JSX } from 'react'; -import * as z from 'zod'; - export const LOGIN_PORT = 5196; export const OAUTH_REDIRECT_PORT = 5194; export const SERVER_PORT = 5173; @@ -14,128 +8,6 @@ export const RPC_PORT = 8787; export const RPC_URL = (host: string) => `http://${host}:${RPC_PORT}`; export const EMULATORJS_URL = (host: string) => `http://${host}:${EMULATORJS_PORT}`; export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`; -export const STORE_VERSION = "^0"; export const DefaultRommStaleTime = 60 * 1000; // A minute -export interface GameMeta -{ - id: string, - onSelect?: () => void, - onFocus?: (details: FocusDetails) => void, - title: string, - subtitle: string | JSX.Element, - previewUrl?: string; - previewSrcset?: string; -}; - -export const SettingsSchema = z.object({ - rommAddress: z.url().optional(), - rommUser: z.string().default('admin').optional(), - windowSize: z.object({ width: z.number(), height: z.number() }).optional(), - windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), - downloadPath: z.string(), - launchInFullscreen: z.boolean().default(true), - disabledPlugins: z.array(z.string()).default([]) -}); - -export const LocalSettingsSchema = z.object({ - backgroundBlur: z.stringbool().or(z.boolean()).default(true), - backgroundAnimation: z.stringbool().or(z.boolean()).default(true), - theme: z.enum(['dark', 'light', 'auto']).default('auto') -}); - -export const GameListFilterSchema = z.object({ - platform_source: z.string().optional(), - platform_slug: z.string().optional(), - platform_id: z.coerce.number().optional(), - collection_id: z.coerce.number().optional(), - collection_source: z.string().optional(), - limit: z.coerce.number().optional(), - offset: z.coerce.number().optional(), - source: z.string().optional(), - orderBy: z.literal(['added', 'activity', 'name']).optional() -}); - -export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); - -export type GameListFilterType = z.infer; - -export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() }); -export type DirType = z.infer; - -export const CustomEmulatorSchema = z.record(z.string(), z.string()); - -export const GithubManifestSchema = z.object({ - sha: z.hash('sha1'), - url: z.url(), - tree: z.array(z.object({ - path: z.string(), - mode: z.string(), - type: z.enum(['blob', 'tree']), - sha: z.hash('sha1'), - url: z.url() - })) -}); - -export const StoreGameSchema = z.object({ - system: z.string(), - title: z.string(), - url: z.string().optional(), - file: z.url(), - description: z.string(), - pictures: z.object({ - screenshots: z.array(z.string()), - titlescreens: z.array(z.string()) - }), - tags: z.array(z.string()) -}); - -export const EmulatorPackageSchema = z.object({ - name: z.string(), - description: z.string(), - homepage: z.url(), - logo: z.url(), - type: z.enum(['emulator']), - os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])), - keywords: z.array(z.string()).optional(), - downloads: z.record(z.string(), z.array(z.discriminatedUnion('type', [ - z.object({ - type: z.literal(['github', 'gitlab']), - pattern: z.string(), - path: z.string() - }), - z.object({ - type: z.literal('direct'), - url: z.url(), - }) - ]))).optional(), - systems: z.array(z.string()), - bios: z.literal(["required", "optional"]).optional() -}); - -export const SystemInfoSchema = z.object({ - battery: z.object({ - percent: z.number(), - isCharging: z.boolean(), - acConnected: z.boolean(), - hasBattery: z.boolean() - - }), - wifiConnections: z.array(z.object({ signalLevel: z.number() })), - bluetoothDevices: z.array(z.object({ connected: z.boolean() })) -}); - -export const GithubReleaseSchema = z.object({ - assets: z.array(z.object({ - name: z.string(), - browser_download_url: z.url(), - content_type: z.string().optional() - })) -}); - -export type EmulatorPackageType = z.infer; -export type StoreGameType = z.infer; -export type SettingsType = z.infer; -export type LocalSettingsType = z.infer; -export const PlatformSchema = z.object({ slug: z.string() }); -export type SystemInfoType = z.infer; +export const PluginRegistry = process.env.STORE_REGISTRY ?? "https://registry.npmjs.org"; \ No newline at end of file diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts deleted file mode 100644 index 760fc7f..0000000 --- a/src/shared/types..d.ts +++ /dev/null @@ -1,267 +0,0 @@ -declare type EmulatorSourceType = 'custom' | 'store' | 'registry' | 'system' | 'static' | 'embedded'; - -declare interface EmulatorSourceEntryType -{ - binPath: string; - rootPath?: string; - type: EmulatorSourceType; - exists: boolean; -} - -declare interface FrontEndEmulator -{ - name: string; - logo: string; - systems: EmulatorSystem[]; - description?: string; - gameCount: number; - validSources: EmulatorSourceEntryType[]; - integration?: { - name: string; - version: string; - }; -} - -declare interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; } - -declare interface FrontEndEmulatorDetailedDownload -{ - name: string; - type: string | undefined; -} - -declare interface FrontEndEmulatorDetailed extends FrontEndEmulator -{ - homepage: string; - description: string; - downloads: FrontEndEmulatorDetailedDownload[]; - keywords?: string[]; - screenshots: string[]; - sources: EmulatorSourceEntryType[]; - biosRequirement?: "required" | "optional"; - bios?: string[]; -} - -declare interface FrontEndGameTypeDetailedAchievement -{ - id: string; - title: string; - description?: string; - date?: Date; - date_hardcode?: Date; - badge_url?: string; - display_order: number; - type?: string; -} - -declare interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator -{ - -} - -declare interface FrontEndGameTypeDetailed extends FrontEndGameType -{ - summary: string | null; - fs_size_bytes: number | null; - missing: boolean; - local: boolean; - genres?: string[]; - companies?: string[]; - release_date?: Date; - emulators?: FrontEndGameTypeDetailedEmulator[], - achievements?: { - unlocked: number; - total: number; - entires: FrontEndGameTypeDetailedAchievement[]; - }; -}; - -declare interface Drive -{ - parent: string | null; - device: string; - label: string; - mountPoint: string | null; - type: string; - size: number; - used: number; - isRemovable: boolean; - interfaceType: string | null; - hasWriteAccess: boolean; - hasReadAccess: boolean; -} - -declare interface DownloadsDrive -{ - device: string; - label: string; - mountPoint: string | null; - isRemovable: boolean; - size: number; - used: number; - isCurrentlyUsed: boolean; - unusableReason: 'not_enough_space' | 'already_used' | null; -} - -declare interface FrontendNotification -{ - title?: string; - message: string; - type: 'success' | 'error' | 'info'; - duration?: number; -} - -declare interface CommandEntry -{ - /** The ID of the command. Could be just an index or a string */ - id: string | number; - /** The front end label for the command. Mainly gotten from ES-DE list */ - label?: string; - /** Compiled command to be executed */ - command: string; - /** The path the spawned process will start at */ - startDir?: string; - /** Is the command valid, for example does the executable exists */ - valid: boolean; - /** For what emulator is the command */ - emulator?: string; - /** Where the emulator came from */ - emulatorSource?: EmulatorSourceType; - /** Metadata for the command */ - metadata: { - romPath: string; - emulatorBin?: string; - /** The root directory of the emulator */ - emulatorDir?: string; - }; -} - -declare interface FrontEndId -{ - id: string; - source: string; -} - -declare interface FrontEndPlatformType -{ - id: FrontEndId; - slug: string; - name: string; - family_name?: string | null; - path_cover: string | null; - game_count: number; - updated_at: Date; - hasLocal: boolean; - paths_screenshots: string[]; -} - -declare interface FrontEndGameType -{ - platform_display_name: string | null, - path_platform_cover: string | null; - id: FrontEndId, - source: string | null, - source_id: string | null, - path_fs: string | null, - path_cover: string | null, - last_played: Date | null, - updated_at: Date, - slug: string | null, - name: string | null, - platform_id: number | null, - platform_slug: string | null, - paths_screenshots: string[]; -}; - -declare type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued'; - -declare interface GameInstallProgress -{ - progress?: number; - status?: GameStatusType; - details?: string; - commands?: CommandEntry[]; - error?: any; -} - -declare type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted'; -declare type GameInstallProgressEvent = 'refresh'; - -declare interface FrontendPlugin -{ - name: string; - displayName: string; - description: string; - enabled: boolean; - source: PluginSourceType; - version: string; - icon?: string; -} - -declare type PluginSourceType = "builtin"; - -declare type KeysWithValueAssignableTo = { - [K in keyof T]: Exclude extends Value ? K : never; -}[keyof T]; - -declare interface DownloadInfo -{ - screenshotUrls: string[]; - coverUrl: string; - platform?: DownloadPlatform; - slug?: string; - path_fs?: string; - summary?: string; - name: string; - last_played?: Date; - igdb_id?: number; - ra_id?: number; - source_id: string; - system_slug: string; - extract_path?: string; - metadata?: any; - files: DownloadFileEntry[]; - auth?: string; -} - -declare interface DownloadPlatform -{ - igdb_id?: number; - igdb_slug?: string; - ra_id?: number; - moby_id?: number; - slug: string; - name: string; - /** Like Sony or Nintendo */ - family_name?: string; -} - -declare interface DownloadFileEntry -{ - url: URL; - /** The path of the file, excluding the name */ - file_path: string; - /** Just the name of the file including the extension */ - file_name: string; - /** Checksum of the file */ - sha1?: string; - /** Size in bytes */ - size?: number; -} - -declare interface LocalDownloadFileEntry extends DownloadFileEntry -{ - /** Exists on the file system */ - exists: boolean; - /** Matches the checksum */ - matches: boolean; -} - -declare interface FrontEndCollection -{ - id: FrontEndId; - name: string; - description: string; - path_platform_cover: string | null; - game_count: number; -} \ No newline at end of file diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 1e128cf..2b3623d 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -22,4 +22,12 @@ export async function delay (delay: number | Date, signal?: AbortSignal) } }); -}; \ No newline at end of file +}; + +const urlRegex = /^https?:\/\//; + +export function isUrl (value: string | undefined) +{ + if (!value) return false; + return urlRegex.test(value); +} \ No newline at end of file diff --git a/src/sounds/Classic UI SFX - Chords #1.wav b/src/sounds/Classic UI SFX - Chords #1.wav new file mode 100644 index 0000000..eef68fe --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #1.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cce497234f22db3e9d1c0e720c36242b3e0d68a8e5ebed0d99df9dbfb5a7ac85 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #10.wav b/src/sounds/Classic UI SFX - Chords #10.wav new file mode 100644 index 0000000..dff9ef9 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #10.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f5e37d4692690781115bef69f033c371c89fc5e3be3415c01bcfb47f5b03c9f +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #11.wav b/src/sounds/Classic UI SFX - Chords #11.wav new file mode 100644 index 0000000..975f24d --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #11.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61c21f7ec7440719aef486730104cc1110c881de5a38e58151101088b7f63e89 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #12.wav b/src/sounds/Classic UI SFX - Chords #12.wav new file mode 100644 index 0000000..b6db6ee --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #12.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80bbc3cddf2f040a96a989e4febf66885a031a42438e705267ecad60e69caf23 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #13.wav b/src/sounds/Classic UI SFX - Chords #13.wav new file mode 100644 index 0000000..bdd6474 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #13.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:532526fee3cea70f093cceef20d1a2587e334f13c293461c152b9e4faf5c8ab5 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #14.wav b/src/sounds/Classic UI SFX - Chords #14.wav new file mode 100644 index 0000000..86c4cf1 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #14.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca7fadf4951a5beace5c5db4a24fd86f8907d0071a7f9a3bc600e973399b001f +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #15.wav b/src/sounds/Classic UI SFX - Chords #15.wav new file mode 100644 index 0000000..40c31fb --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #15.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54ed638f5ecca2615e0ac4bd42031efa89de810f18b0b3c0b0f2920bfaf021f6 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #16.wav b/src/sounds/Classic UI SFX - Chords #16.wav new file mode 100644 index 0000000..c019363 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #16.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e67390430ce0b19689f5322ad6ff78cd0a7f01cea6b55d02fef19c72ea77a653 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #17.wav b/src/sounds/Classic UI SFX - Chords #17.wav new file mode 100644 index 0000000..e3f84cb --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #17.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90fe98d297a58235bda0a9a4bfc367914f052a550a7289fe652617f30f0b5e32 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #18.wav b/src/sounds/Classic UI SFX - Chords #18.wav new file mode 100644 index 0000000..8883284 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #18.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb19e2996301ccfcfdf328c2f2b553ff9aa0dfe1ff4982ae1c54c9d6a2ba2438 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #19.wav b/src/sounds/Classic UI SFX - Chords #19.wav new file mode 100644 index 0000000..0edb657 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #19.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31630542c8751e4fcdd0a9ab211816f09aea6c4bf6069694e291b18b0774d7df +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #2.wav b/src/sounds/Classic UI SFX - Chords #2.wav new file mode 100644 index 0000000..4b38c6b --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #2.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5dc42fc6845b2ef1ee3db50d81335e93e97902cde85fcf8fe3e5ee29c83163e9 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #20.wav b/src/sounds/Classic UI SFX - Chords #20.wav new file mode 100644 index 0000000..7d61fa7 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #20.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52d3abeb064f3e749d0f4fd29f92b19e54e1477ec80155aca906ae8975e2709f +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #3.wav b/src/sounds/Classic UI SFX - Chords #3.wav new file mode 100644 index 0000000..a9778fb --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #3.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1295f494ec9d295710e7bb1cf467fb118e102891dc9009b86b0ae9960d32e382 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #4.wav b/src/sounds/Classic UI SFX - Chords #4.wav new file mode 100644 index 0000000..b2f258e --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #4.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5e387f33652387eda2189fc9f6c2194b36a9aac272b36ccc64c4e468b77e214 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #5.wav b/src/sounds/Classic UI SFX - Chords #5.wav new file mode 100644 index 0000000..6f2dc11 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #5.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:739c571b6b1ab60a002b9b357878ae91bb9df576d52e5480f07c17886a53f805 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #6.wav b/src/sounds/Classic UI SFX - Chords #6.wav new file mode 100644 index 0000000..fc36385 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #6.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d80b6464291c88b7f33748d4a4e93ed07ee1756d5c623b3862bd3847767d7b54 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #7.wav b/src/sounds/Classic UI SFX - Chords #7.wav new file mode 100644 index 0000000..33f80af --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #7.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:796918fa72911e50c7d762f0fb599427c5924d53b627a1b735f16ca1a9f54fe3 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #8.wav b/src/sounds/Classic UI SFX - Chords #8.wav new file mode 100644 index 0000000..047aa68 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #8.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0439d8fbe9c4734acd7b40e440027d2a7a1c1ae20218f1ca2e3dacd318d776f9 +size 769182 diff --git a/src/sounds/Classic UI SFX - Chords #9.wav b/src/sounds/Classic UI SFX - Chords #9.wav new file mode 100644 index 0000000..7368828 --- /dev/null +++ b/src/sounds/Classic UI SFX - Chords #9.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe8e930484f41092b1518f0f3839b617561f1c62ce4b7532158c0cf484fd4d6f +size 769182 diff --git a/src/sounds/Classic UI SFX - Short - High #1.wav b/src/sounds/Classic UI SFX - Short - High #1.wav new file mode 100644 index 0000000..a29dbce --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #1.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa96866433176c69bf23254badee5abd87741816fe833a07a26d6031020464a2 +size 489182 diff --git a/src/sounds/Classic UI SFX - Short - High #10.wav b/src/sounds/Classic UI SFX - Short - High #10.wav new file mode 100644 index 0000000..8c9c510 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #10.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e58d993037d6c05797c1ecadd738f484d2c1428db6d96381522ef1254b6f794f +size 490182 diff --git a/src/sounds/Classic UI SFX - Short - High #11.wav b/src/sounds/Classic UI SFX - Short - High #11.wav new file mode 100644 index 0000000..558786a --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #11.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d648dc7030de8bc59abc70a0153e37b08cf4267228aab41a321eeaa3f03fb2fc +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - High #12.wav b/src/sounds/Classic UI SFX - Short - High #12.wav new file mode 100644 index 0000000..cbd310c --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #12.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b4c11124a6a9542a3e672e4707d9dd67ea5f805ec239c310218c53d61634d4e +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - High #13.wav b/src/sounds/Classic UI SFX - Short - High #13.wav new file mode 100644 index 0000000..fa8a598 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #13.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5911be904de2655606bd16834391d21f56313d6fce28ed5cfaaf560331a4be80 +size 576182 diff --git a/src/sounds/Classic UI SFX - Short - High #14.wav b/src/sounds/Classic UI SFX - Short - High #14.wav new file mode 100644 index 0000000..e7e21ca --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #14.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:815699bd8283ecd8af0cdfcf384a97a6a61a8aa6ab9400d0fa079267eee0567e +size 538182 diff --git a/src/sounds/Classic UI SFX - Short - High #15.wav b/src/sounds/Classic UI SFX - Short - High #15.wav new file mode 100644 index 0000000..142bc6f --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #15.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95b2b932aade06ea1575755fa516aec836224397145d6b22270096ae74078b7c +size 523182 diff --git a/src/sounds/Classic UI SFX - Short - High #16.wav b/src/sounds/Classic UI SFX - Short - High #16.wav new file mode 100644 index 0000000..d37ab0c --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #16.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8340972a758f262ff71b319a43bcdebb685d3c7036a169276b095b726c22000b +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - High #17.wav b/src/sounds/Classic UI SFX - Short - High #17.wav new file mode 100644 index 0000000..112b03e --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #17.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fbe6ca64226581e8bafa0891fabfd465d9045b395c4a21534a04af435342971 +size 553182 diff --git a/src/sounds/Classic UI SFX - Short - High #18.wav b/src/sounds/Classic UI SFX - Short - High #18.wav new file mode 100644 index 0000000..34ef574 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #18.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95539682288858aaf0b37bc35715b71060e76bf15b81db3898a0f739f062b243 +size 453182 diff --git a/src/sounds/Classic UI SFX - Short - High #19.wav b/src/sounds/Classic UI SFX - Short - High #19.wav new file mode 100644 index 0000000..b165ece --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #19.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d312f707829b975fb625bed15879118479a7927d9e29bd62d2c1c716d35b367f +size 586182 diff --git a/src/sounds/Classic UI SFX - Short - High #2.wav b/src/sounds/Classic UI SFX - Short - High #2.wav new file mode 100644 index 0000000..f6d5fa2 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #2.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89c235075af8db515f97c2bfd50f5350c28c2ba2079e6fad8fdfb963f24a12a1 +size 546182 diff --git a/src/sounds/Classic UI SFX - Short - High #20.wav b/src/sounds/Classic UI SFX - Short - High #20.wav new file mode 100644 index 0000000..7e23a1e --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #20.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61a585819286d0f11314813ee89bc8f121ca4b362d1721e2edfe14172a6358e7 +size 387182 diff --git a/src/sounds/Classic UI SFX - Short - High #21.wav b/src/sounds/Classic UI SFX - Short - High #21.wav new file mode 100644 index 0000000..49cb131 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #21.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:480c9e31c1a033c959888bd2a2691c772ef80bbec1c5f8dc3fccb7e86da6c153 +size 385182 diff --git a/src/sounds/Classic UI SFX - Short - High #22.wav b/src/sounds/Classic UI SFX - Short - High #22.wav new file mode 100644 index 0000000..acced97 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #22.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ab262921ec2763a84c25d3ec00e1ade68e96393fbd475c28659aa661af9d41f +size 478182 diff --git a/src/sounds/Classic UI SFX - Short - High #23.wav b/src/sounds/Classic UI SFX - Short - High #23.wav new file mode 100644 index 0000000..4d106a7 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #23.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0f09d67685a7cf0b82b66f082ad77cf0b536557306fbf6862ba3ef0b92b8280 +size 472182 diff --git a/src/sounds/Classic UI SFX - Short - High #24.wav b/src/sounds/Classic UI SFX - Short - High #24.wav new file mode 100644 index 0000000..e3d0dc8 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #24.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd3520550af14b8d19b275bba18fb7f2610f156e6eea780837748a068d6a858d +size 402182 diff --git a/src/sounds/Classic UI SFX - Short - High #25.wav b/src/sounds/Classic UI SFX - Short - High #25.wav new file mode 100644 index 0000000..6632b69 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #25.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f18ca1455d31a7c430572c64b85c0c600a0c77986e6411a0f9cff783445393c3 +size 385182 diff --git a/src/sounds/Classic UI SFX - Short - High #3.wav b/src/sounds/Classic UI SFX - Short - High #3.wav new file mode 100644 index 0000000..767c819 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #3.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:140b58fa531bb20dcb284e8babea35ef55fa5d58aff16e8d98b505ccf02115e5 +size 550182 diff --git a/src/sounds/Classic UI SFX - Short - High #4.wav b/src/sounds/Classic UI SFX - Short - High #4.wav new file mode 100644 index 0000000..5eb2d78 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #4.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d11499b350e8e95990d298dc96ce01026973ccf5bd58891c9b505d3ba32b1a82 +size 582182 diff --git a/src/sounds/Classic UI SFX - Short - High #5.wav b/src/sounds/Classic UI SFX - Short - High #5.wav new file mode 100644 index 0000000..8cc1019 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #5.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e92d3e60c23ea4927444a28174a53fcb668d83f850df2494f53d5870613143a +size 499182 diff --git a/src/sounds/Classic UI SFX - Short - High #6.wav b/src/sounds/Classic UI SFX - Short - High #6.wav new file mode 100644 index 0000000..3e496cb --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #6.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ec68d1b1549fed1bccaf62ea4fd80c5c59b9b50750fa324b858e1c370344270 +size 466182 diff --git a/src/sounds/Classic UI SFX - Short - High #7.wav b/src/sounds/Classic UI SFX - Short - High #7.wav new file mode 100644 index 0000000..8fae195 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #7.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14f2dd1c42c354343f80c631721c8dceadb80b8e03ecec4e93294c8ffb21cfcf +size 474182 diff --git a/src/sounds/Classic UI SFX - Short - High #8.wav b/src/sounds/Classic UI SFX - Short - High #8.wav new file mode 100644 index 0000000..a25618d --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #8.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:683fc09fbf580efa6990504bd17861f48495555ac81f4ed2b9e85f14628348fe +size 560182 diff --git a/src/sounds/Classic UI SFX - Short - High #9.wav b/src/sounds/Classic UI SFX - Short - High #9.wav new file mode 100644 index 0000000..9ee3b5f --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - High #9.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b60cd47d7591fc5e2cf49c8139781479d3833d621bba129903d78843d7929380 +size 432182 diff --git a/src/sounds/Classic UI SFX - Short - Low #1.wav b/src/sounds/Classic UI SFX - Short - Low #1.wav new file mode 100644 index 0000000..dba2e40 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #1.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:419e970729e384fcba3a9c4bb3c55cec906dfd20f969c1cf0cbf2ffcbc00638e +size 386182 diff --git a/src/sounds/Classic UI SFX - Short - Low #10.wav b/src/sounds/Classic UI SFX - Short - Low #10.wav new file mode 100644 index 0000000..4eebb5b --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #10.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9749c07cacebb0bfe9924bbf8f0142630c96c0d4d5d0e727f5daeb354f9505a8 +size 580182 diff --git a/src/sounds/Classic UI SFX - Short - Low #11.wav b/src/sounds/Classic UI SFX - Short - Low #11.wav new file mode 100644 index 0000000..d4cbb0d --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #11.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4b7c87005eae8d0b0d329329135a67cd318a775cf2c75dd5ae68974537f9b7c +size 472182 diff --git a/src/sounds/Classic UI SFX - Short - Low #12.wav b/src/sounds/Classic UI SFX - Short - Low #12.wav new file mode 100644 index 0000000..65b3517 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #12.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96018d0ee84d1e27e0fc655c03d4afd3fa0839aecca09fe7ef2f0f104f424e60 +size 557182 diff --git a/src/sounds/Classic UI SFX - Short - Low #13.wav b/src/sounds/Classic UI SFX - Short - Low #13.wav new file mode 100644 index 0000000..ac2b8bf --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #13.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a3de21306c039abc81ace6a9e2e18f581c4993c532c8120126df055be7c9767 +size 546182 diff --git a/src/sounds/Classic UI SFX - Short - Low #14.wav b/src/sounds/Classic UI SFX - Short - Low #14.wav new file mode 100644 index 0000000..ce57f07 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #14.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:848195440e0a64566d175a62aa13685872fdb012bf491e5742984d5059524f5f +size 602182 diff --git a/src/sounds/Classic UI SFX - Short - Low #15.wav b/src/sounds/Classic UI SFX - Short - Low #15.wav new file mode 100644 index 0000000..e8218d5 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #15.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aee89025f7fb8517deb610d2814b941136fda09ebcbbacc78cddfe834c5b348a +size 519182 diff --git a/src/sounds/Classic UI SFX - Short - Low #16.wav b/src/sounds/Classic UI SFX - Short - Low #16.wav new file mode 100644 index 0000000..187b096 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #16.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a0bafde7f6a2b75589faac13b7d2b929239189ebc554c338fd2741e3ea46e78 +size 552182 diff --git a/src/sounds/Classic UI SFX - Short - Low #17.wav b/src/sounds/Classic UI SFX - Short - Low #17.wav new file mode 100644 index 0000000..4a4608d --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #17.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b2daee3db271eb4244dd40ea3abb5a7f036e69e637222f7a611c89a5f0a9a3e +size 562182 diff --git a/src/sounds/Classic UI SFX - Short - Low #18.wav b/src/sounds/Classic UI SFX - Short - Low #18.wav new file mode 100644 index 0000000..efbf4cc --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #18.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3bda2874577cb666819141de8eba8375bddcb07bf1e813d33ad718f6828227f1 +size 587182 diff --git a/src/sounds/Classic UI SFX - Short - Low #19.wav b/src/sounds/Classic UI SFX - Short - Low #19.wav new file mode 100644 index 0000000..6efa7e3 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #19.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f577ca8f4b9d172c1e8d10553451128ea424a0cd4e8fc2ba217f1703de74eedc +size 475182 diff --git a/src/sounds/Classic UI SFX - Short - Low #2.wav b/src/sounds/Classic UI SFX - Short - Low #2.wav new file mode 100644 index 0000000..07e9d9a --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #2.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36527b664e371bc952fa2ef6cd6f5479fcfd183144812091caf234be45dd8d3f +size 496182 diff --git a/src/sounds/Classic UI SFX - Short - Low #20.wav b/src/sounds/Classic UI SFX - Short - Low #20.wav new file mode 100644 index 0000000..c8d717c --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #20.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:598800de963759dbe0bca69f66facbe31546c0e39f76f0fe59950ac3dd8f1c08 +size 483182 diff --git a/src/sounds/Classic UI SFX - Short - Low #21.wav b/src/sounds/Classic UI SFX - Short - Low #21.wav new file mode 100644 index 0000000..ea90758 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #21.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca62b2181585cbc11f1c66ba1e1d83b2987e50f9a5adb236c9c80dc29a43675b +size 500182 diff --git a/src/sounds/Classic UI SFX - Short - Low #22.wav b/src/sounds/Classic UI SFX - Short - Low #22.wav new file mode 100644 index 0000000..3505329 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #22.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49ad78c33edc1c0bcfdb5d9d2d6d559b7530403cec0d7ccfb770fb4fb3356527 +size 582182 diff --git a/src/sounds/Classic UI SFX - Short - Low #23.wav b/src/sounds/Classic UI SFX - Short - Low #23.wav new file mode 100644 index 0000000..5cac1d4 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #23.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3c1f824ac3a97213b56c1493b4493ef11d3292bff84eac4eeb9134b9546941c +size 564182 diff --git a/src/sounds/Classic UI SFX - Short - Low #24.wav b/src/sounds/Classic UI SFX - Short - Low #24.wav new file mode 100644 index 0000000..d3b0245 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #24.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb1948113b38e53a46ca720b3096a6b991c4dce76f9b3dbdec2268566587242b +size 501182 diff --git a/src/sounds/Classic UI SFX - Short - Low #25.wav b/src/sounds/Classic UI SFX - Short - Low #25.wav new file mode 100644 index 0000000..aa73af8 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #25.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44dc28db05d46c9eba8710a3f9fade5722551f7bbbd4318f391a78cfc2995538 +size 504182 diff --git a/src/sounds/Classic UI SFX - Short - Low #3.wav b/src/sounds/Classic UI SFX - Short - Low #3.wav new file mode 100644 index 0000000..9d62309 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #3.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b78146a7c13a9dc54279b0d6909ce7d33bd8521f53d8f70d5cc35941b04be055 +size 543182 diff --git a/src/sounds/Classic UI SFX - Short - Low #4.wav b/src/sounds/Classic UI SFX - Short - Low #4.wav new file mode 100644 index 0000000..42499ed --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #4.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfcb2172fc599b189deecc57f0ceaa8fa54059442194829e5ae5ef78bb4960a5 +size 502182 diff --git a/src/sounds/Classic UI SFX - Short - Low #5.wav b/src/sounds/Classic UI SFX - Short - Low #5.wav new file mode 100644 index 0000000..6092153 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #5.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d120f6502806f3b7c1259d89aa90a0cf851f7f4826d454bd34b740f40b73f59 +size 607182 diff --git a/src/sounds/Classic UI SFX - Short - Low #6.wav b/src/sounds/Classic UI SFX - Short - Low #6.wav new file mode 100644 index 0000000..8708e29 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #6.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70ba897bce8a0ec2ce6c0c2b16dfb2fd1b5cefa3eb00c152a489de35188bbf83 +size 448182 diff --git a/src/sounds/Classic UI SFX - Short - Low #7.wav b/src/sounds/Classic UI SFX - Short - Low #7.wav new file mode 100644 index 0000000..64a7cce --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #7.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d465d28fc3c3c9a1dc2992724b3a674ff4d31aa9721c222cd00434ba53f4086 +size 487182 diff --git a/src/sounds/Classic UI SFX - Short - Low #8.wav b/src/sounds/Classic UI SFX - Short - Low #8.wav new file mode 100644 index 0000000..0ed12c7 --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #8.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62dc712e485e785716d944f0764c0588f00dd64185e4a205393f7a7df651ccfb +size 505182 diff --git a/src/sounds/Classic UI SFX - Short - Low #9.wav b/src/sounds/Classic UI SFX - Short - Low #9.wav new file mode 100644 index 0000000..18c0b1a --- /dev/null +++ b/src/sounds/Classic UI SFX - Short - Low #9.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2b0db41db5cddc7e8d6884c856b86f0b77e771958a1da9867455a6212407074 +size 518182 diff --git a/src/sounds/UI SFX_InGameMenu_Open.ogg b/src/sounds/UI SFX_InGameMenu_Open.ogg new file mode 100644 index 0000000..bc0c8b8 --- /dev/null +++ b/src/sounds/UI SFX_InGameMenu_Open.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95a5270d6d8be097815eb5dfe8db69c48b15b4202be308ea627fe73405ac6304 +size 47490 diff --git a/src/sounds/UI_Flourish Down_Set 14_01.wav b/src/sounds/UI_Flourish Down_Set 14_01.wav new file mode 100644 index 0000000..f7e23e4 --- /dev/null +++ b/src/sounds/UI_Flourish Down_Set 14_01.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81e6141d86e6f8baacc7767840861b5868b9bf4f3ea23e2c05136f44440c39ac +size 182602 diff --git a/src/sounds/UI_Flourish Up_Set 14_01.wav b/src/sounds/UI_Flourish Up_Set 14_01.wav new file mode 100644 index 0000000..3b9616d --- /dev/null +++ b/src/sounds/UI_Flourish Up_Set 14_01.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:862a1190a73b987f928cbff1d03c197ae8f25d1acfb2e4052210ee0564394d2c +size 182602 diff --git a/src/sounds/UI_Single_Set 11_01.wav b/src/sounds/UI_Single_Set 11_01.wav new file mode 100644 index 0000000..6aae99a --- /dev/null +++ b/src/sounds/UI_Single_Set 11_01.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d285c7ef4c2624c83deed49249b4123f7491485673bfda244fd850c8b65dc6c7 +size 16690 diff --git a/src/sounds/UI_Single_Set 11_02.wav b/src/sounds/UI_Single_Set 11_02.wav new file mode 100644 index 0000000..8b4c034 --- /dev/null +++ b/src/sounds/UI_Single_Set 11_02.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7aed91f6dfacc52821f0ab664fc6002090925ebdfde7dd30ebc1b2319742cc4 +size 16690 diff --git a/src/sounds/UI_Single_Set 11_03.wav b/src/sounds/UI_Single_Set 11_03.wav new file mode 100644 index 0000000..daaa072 --- /dev/null +++ b/src/sounds/UI_Single_Set 11_03.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:003320de3e37afd144c16248464e728f28a2140121d042b0b241329236b5ae0a +size 16690 diff --git a/src/sounds/UI_Single_Set 16_01.ogg b/src/sounds/UI_Single_Set 16_01.ogg new file mode 100644 index 0000000..b6c70e0 --- /dev/null +++ b/src/sounds/UI_Single_Set 16_01.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d04ded8bae2d120e79e7969910c7016dc00ec8b22680ab4ed5842d257616c348 +size 6983 diff --git a/src/sounds/UI_Single_Set 16_02.ogg b/src/sounds/UI_Single_Set 16_02.ogg new file mode 100644 index 0000000..1cdb93a --- /dev/null +++ b/src/sounds/UI_Single_Set 16_02.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dee515a756b38ea169ebfabd91320a5d50873993765095920ec2f9e142dfd00d +size 7069 diff --git a/src/sounds/UI_Single_Set 16_03.ogg b/src/sounds/UI_Single_Set 16_03.ogg new file mode 100644 index 0000000..4bdfd18 --- /dev/null +++ b/src/sounds/UI_Single_Set 16_03.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8d972d72b3570f0638e094e84791ad0aa8a39b9d4806b8a5b441802a73bf3ac +size 7156 diff --git a/src/sounds/UI_Single_Set 5_01.wav b/src/sounds/UI_Single_Set 5_01.wav new file mode 100644 index 0000000..fa2df4b --- /dev/null +++ b/src/sounds/UI_Single_Set 5_01.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7dfe353747423192f9f92c3427d4f14d79b1889f0c3396f15c3072164c7d9883 +size 155042 diff --git a/src/sounds/UI_Single_Set 5_02.wav b/src/sounds/UI_Single_Set 5_02.wav new file mode 100644 index 0000000..f3628cc --- /dev/null +++ b/src/sounds/UI_Single_Set 5_02.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d589b9af08bb0e18efbd04f262d009fb3ee9d73374921af6578d92d49899632 +size 155042 diff --git a/src/sounds/UI_Single_Set 5_03.wav b/src/sounds/UI_Single_Set 5_03.wav new file mode 100644 index 0000000..07a0a76 --- /dev/null +++ b/src/sounds/UI_Single_Set 5_03.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d7df23402238e2bb14cc7cc225d4b3997fd0647ec9119d6e08d27444039d4d3 +size 94402 diff --git a/src/sounds/UI_Single_Set 5_04.wav b/src/sounds/UI_Single_Set 5_04.wav new file mode 100644 index 0000000..e5f50d1 --- /dev/null +++ b/src/sounds/UI_Single_Set 5_04.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5912777aae29c0ffadac28527d05699f690d10e0be634f6fedd0b1ce97954081 +size 94402 diff --git a/src/sounds/UI_TwoNote Down_Set 11_01.wav b/src/sounds/UI_TwoNote Down_Set 11_01.wav new file mode 100644 index 0000000..350a1a7 --- /dev/null +++ b/src/sounds/UI_TwoNote Down_Set 11_01.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:344e7d622e651e1b969c793585665a823ed14343cc0cebbd053428d00d01719c +size 77342 diff --git a/src/sounds/UI_TwoNote Down_Set 14_01.wav b/src/sounds/UI_TwoNote Down_Set 14_01.wav new file mode 100644 index 0000000..53b56e8 --- /dev/null +++ b/src/sounds/UI_TwoNote Down_Set 14_01.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f40883f32c6b5de586299a123e800cb3fa32d65d20a5bb50d69d3fcd8ed661f3 +size 94402 diff --git a/src/sounds/UI_TwoNote Up_Set 11_01.wav b/src/sounds/UI_TwoNote Up_Set 11_01.wav new file mode 100644 index 0000000..4040311 --- /dev/null +++ b/src/sounds/UI_TwoNote Up_Set 11_01.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a61d664dee7dd1e796fa16c0e2b5265bdd3741956d4b73b9c76271b3a4a90400 +size 22946 diff --git a/src/sounds/UI_TwoNote Up_Set 11_02.wav b/src/sounds/UI_TwoNote Up_Set 11_02.wav new file mode 100644 index 0000000..e1d9377 --- /dev/null +++ b/src/sounds/UI_TwoNote Up_Set 11_02.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c1851531d1a0bf95f78a8ce8acfb02aa8e726cca3c934f165f1727340f2e1b2 +size 44262 diff --git a/src/sounds/UI_TwoNote Up_Set 11_03.wav b/src/sounds/UI_TwoNote Up_Set 11_03.wav new file mode 100644 index 0000000..ac9a7a4 --- /dev/null +++ b/src/sounds/UI_TwoNote Up_Set 11_03.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f5a95e063e3368d24e44a4952f39e2949fdf278791b505ce2400ed1f6f7c05e +size 71826 diff --git a/src/sounds/UI_TwoNote Up_Set 14_01.wav b/src/sounds/UI_TwoNote Up_Set 14_01.wav new file mode 100644 index 0000000..bc92f9a --- /dev/null +++ b/src/sounds/UI_TwoNote Up_Set 14_01.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3131ea87c8893a87f6964f07728154e91115093313e55fa6c04f1028000af50a +size 88890 diff --git a/src/sounds/UI_TwoNote_Set 15_01.ogg b/src/sounds/UI_TwoNote_Set 15_01.ogg new file mode 100644 index 0000000..7d8a3c2 --- /dev/null +++ b/src/sounds/UI_TwoNote_Set 15_01.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad955c552cc1a111a9885fcb09f460e03f6b31ef9ecd1637a49970c0edc5fef2 +size 7624 diff --git a/src/sounds/UI_TwoNote_Set 15_02.ogg b/src/sounds/UI_TwoNote_Set 15_02.ogg new file mode 100644 index 0000000..6a5a4c9 --- /dev/null +++ b/src/sounds/UI_TwoNote_Set 15_02.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78e1b6c3fcff1f9f4a3a2247b8b835d26585b089ea36f21ba212d99ea6f60ba2 +size 7596 diff --git a/src/tests/downloads.test.ts b/src/tests/downloads.test.ts index 3c68d35..7be5dd0 100644 --- a/src/tests/downloads.test.ts +++ b/src/tests/downloads.test.ts @@ -1,9 +1,10 @@ -import { expect, test, describe, beforeEach, afterAll, beforeAll, jest } from 'bun:test'; +import { expect, test, describe, afterAll, beforeAll, jest } from 'bun:test'; import { client } from './client'; import * as app from '@/bun/api/app'; import fs from 'node:fs/promises'; import path from "node:path"; import AdmZip from "adm-zip"; +import { DownloadInfo } from '@simeonradivoev/gameflow-sdk/shared'; describe("Download Tests", () => { @@ -50,17 +51,18 @@ describe("Download Tests", () => { const mock = jest.fn(); app.plugins.hooks.games.fetchDownloads.tap('test2', mock); - app.plugins.hooks.games.fetchDownloads.tapPromise('test', async ({ source, id }) => + app.plugins.hooks.games.fetchDownloads.tapPromise('test', async ({ source }) => { if (source !== 'test') return; - return { + return [{ files: [{ file_name: "Test File.txt", file_path: 'test/files', url: new URL(`${server.url.href}download/single_file.txt`) }], coverUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/SIPI_Jelly_Beans_4.1.07.tiff/lossy-page1-256px-SIPI_Jelly_Beans_4.1.07.tiff.jpg", name: "Test Game", screenshotUrls: [], system_slug: 'ps2', - source_id: "0" - }; + source_id: "0", + id: 'test' + } satisfies DownloadInfo]; }); const res = await client.rommApi.api.romm.game({ source: 'test' })({ id: '0' }).install.post(); @@ -77,7 +79,7 @@ describe("Download Tests", () => app.plugins.hooks.games.fetchDownloads.tapPromise('test', async ({ source, id }) => { if (source !== 'test') return; - return { + return [{ files: [ { file_name: "Test File.txt", file_path: 'test/files', url: new URL(`${server.url.href}download/single_file.txt`) }, { file_name: "Test File 2.txt", file_path: 'test/files', url: new URL(`${server.url.href}download/single_file_2.txt`) }], @@ -85,8 +87,9 @@ describe("Download Tests", () => name: "Test Game", screenshotUrls: [], system_slug: 'ps2', - source_id: "0" - }; + source_id: "0", + id: 'test' + } satisfies DownloadInfo]; }); const res = await client.rommApi.api.romm.game({ source: 'test' })({ id: '0' }).install.post(); @@ -104,7 +107,7 @@ describe("Download Tests", () => app.plugins.hooks.games.fetchDownloads.tapPromise('test', async ({ source, id }) => { if (source !== 'test') return; - return { + return [{ files: [ { file_name: "zip_file_with_single_file.zip", file_path: 'test', url: new URL(`${server.url.href}download/zip_file_with_single_file.zip`) }], coverUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/SIPI_Jelly_Beans_4.1.07.tiff/lossy-page1-256px-SIPI_Jelly_Beans_4.1.07.tiff.jpg", @@ -112,8 +115,9 @@ describe("Download Tests", () => screenshotUrls: [], system_slug: 'ps2', source_id: "0", - extract_path: 'test/files' - }; + extract_path: 'test/files', + id: 'test' + } satisfies DownloadInfo]; }); const res = await client.rommApi.api.romm.game({ source: 'test' })({ id: '0' }).install.post(); diff --git a/src/tests/game-launching.test.ts b/src/tests/game-launching.test.ts index 3ac7d8a..a84e6d5 100644 --- a/src/tests/game-launching.test.ts +++ b/src/tests/game-launching.test.ts @@ -1,24 +1,35 @@ import { expect, test } from 'bun:test'; import path, { resolve } from 'node:path'; import * as app from '@/bun/api/app'; +import * as appSchema from '@/bun/api/schema/app'; +import { } from 'node:test'; test("uses custom emulator", async () => { app.customEmulators.set('PCSX2', resolve("./src/tests/mock-roms/mock-emulator.exe")); - - const { getValidLaunchCommands: getLaunchCommands } = await import('@/bun/api/games/services/launchGameService'); - const commands = await getLaunchCommands({ - systemSlug: 'ps2', - gamePath: './mock-rom.iso' - }); + const mockPlatform: typeof appSchema.platforms.$inferInsert = { + name: 'Test', + slug: 'ps2', + }; + await app.db.insert(appSchema.platforms).values(mockPlatform); + const mockGame: typeof appSchema.games.$inferInsert = { + platform_id: 1, + path_fs: './mock-rom.iso' + }; + await app.db.insert(appSchema.games).values(mockGame); await Bun.write(path.join(app.config.get('downloadPath'), 'mock-rom.iso'), "This is a mock Rom"); await Bun.write(path.join(app.config.get('downloadPath'), 'mock-emulator.exe'), "This is a mock Emulator"); + const { getValidLaunchCommandsForGame } = await import('@/bun/api/games/services/statusService'); + const commands = await getValidLaunchCommandsForGame('local', '1'); + expect(commands) .toSatisfy((d) => { - const validCommand = d.find(c => + if (d instanceof Error) return false; + if (!d) return false; + const validCommand = d.commands.find(c => c?.command.includes("mock-rom.iso") && c.command.includes("mock-emulator.exe") ); diff --git a/src/tests/mock-roms/mock-emulator.exe b/src/tests/mock-roms/mock-emulator.exe new file mode 100644 index 0000000..8a4beb5 --- /dev/null +++ b/src/tests/mock-roms/mock-emulator.exe @@ -0,0 +1 @@ +This is a mock Emulator \ No newline at end of file diff --git a/src/tests/mock-roms/mock-rom.iso b/src/tests/mock-roms/mock-rom.iso new file mode 100644 index 0000000..802b307 --- /dev/null +++ b/src/tests/mock-roms/mock-rom.iso @@ -0,0 +1 @@ +This is a mock Rom \ No newline at end of file diff --git a/src/tests/preload.ts b/src/tests/preload.ts index b71e8e0..40cf49d 100644 --- a/src/tests/preload.ts +++ b/src/tests/preload.ts @@ -1,19 +1,20 @@ -import { afterAll, beforeAll, beforeEach, afterEach } from 'bun:test'; +import { beforeAll, beforeEach, afterEach } from 'bun:test'; import { resolve } from 'node:path'; import * as app from '@/bun/api/app'; -import { remove } from 'fs-extra'; -import { spawnSync } from "child_process"; +import { ensureDir, remove } from 'fs-extra'; export async function LoadApp () { console.log("Loading App"); await app.load(); + await app.taskQueue.waitForAll(); } export async function CleanupApp () { console.log("Cleaning Up App"); await app.cleanup(); + await app.resetCleanup(); } beforeAll(async () => @@ -21,6 +22,7 @@ beforeAll(async () => process.env.CUSTOM_STORE_PATH = resolve('./src/tests/mock-store'); process.env.CONFIG_CWD = resolve('./src/tests/mock-config'); process.env.DEFAULT_DOWNLOAD_PATH = resolve('./src/tests/mock-roms'); + process.env.PLUGIN_BLACKLIST = 'com.simeonradivoev.gameflow.rclone,@simeonradivoev/gameflow-store,com.simeonradivoev.gameflow.romm,com.simeonradivoev.gameflow.igdb,@simeonradivoev/gameflow-sdk'; }); async function FileCleanup () diff --git a/tsconfig.json b/tsconfig.json index 49e40c5..500e8f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "noUnusedLocals": true, "noFallthroughCasesInSwitch": true, "paths": { + "@simeonradivoev/gameflow-sdk/*": ["./src/packages/gameflow-sdk/*"], "@/*": [ "./src/*" ], @@ -41,6 +42,7 @@ }, "include": [ "src", + "scripts", "vite.config.ts", "vite-env-override.d.ts" ] diff --git a/vendors/es-de/emulators.darwin.x64.sqlite b/vendors/es-de/emulators.darwin.x64.sqlite index b669d19..ca00b69 100644 Binary files a/vendors/es-de/emulators.darwin.x64.sqlite and b/vendors/es-de/emulators.darwin.x64.sqlite differ diff --git a/vendors/es-de/emulators.haiku.x64.sqlite b/vendors/es-de/emulators.haiku.x64.sqlite index 556e0a8..3f406ab 100644 Binary files a/vendors/es-de/emulators.haiku.x64.sqlite and b/vendors/es-de/emulators.haiku.x64.sqlite differ diff --git a/vendors/es-de/emulators.linux.arm.sqlite b/vendors/es-de/emulators.linux.arm.sqlite index 349ec0f..289e182 100644 Binary files a/vendors/es-de/emulators.linux.arm.sqlite and b/vendors/es-de/emulators.linux.arm.sqlite differ diff --git a/vendors/es-de/emulators.linux.x64.sqlite b/vendors/es-de/emulators.linux.x64.sqlite index 56fe557..f08e28f 100644 Binary files a/vendors/es-de/emulators.linux.x64.sqlite and b/vendors/es-de/emulators.linux.x64.sqlite differ diff --git a/vendors/es-de/emulators.win32.x64.sqlite b/vendors/es-de/emulators.win32.x64.sqlite index 02a6f03..f9eb11d 100644 Binary files a/vendors/es-de/emulators.win32.x64.sqlite and b/vendors/es-de/emulators.win32.x64.sqlite differ diff --git a/vendors/romm/custom-overrides.json b/vendors/romm/custom-overrides.json index af1795e..e7380fe 100644 --- a/vendors/romm/custom-overrides.json +++ b/vendors/romm/custom-overrides.json @@ -1,11 +1,15 @@ { - "atari-st": "atarist", - "browser": "flash", - "dragon-32-slash-64": "dragon32", - "epoch-cassette-vision": "scv", - "mega-duck-slash-cougar-boy": "megaduck", - "odyssey2": "odyssey-2", - "pico": "pico8", - "sms": "mark3", - "windows-apps": "windows" + "atarist": "atari-st", + "flash ": "browser", + "dragon32": "dragon-32-slash-64", + "scv": "epoch-cassette-vision", + "megaduck": "mega-duck-slash-cougar-boy", + "odyssey-2": "odyssey2", + "pico8": "pico", + "mark3": "sms", + "windows": [ + "windows-apps", + "win" + ], + "linux": "desktop" } \ 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