From 9141fb35d48ae272e5ba73f28683d13ba5ca49a3 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 15 May 2026 13:50:55 +0300 Subject: [PATCH 1/3] feat: Implemented link game importing feat: Implemented download page for downloading roms from various sources using plugins. Added support for internet archive external plugin. feat: Added tasks page to track running tasks/downloads feat: Added tanstack caching feat: Added quick play action Fixes #6 feat: Added quick emulator launch action fix: Made task queue only support 1 task per group and task ID should now be unique --- bun.lock | 102 +++---- package.json | 20 +- scripts/dev.ts | 7 + src/bun/api/controls/windows.ts | 1 - src/bun/api/games/games.ts | 66 ++++- src/bun/api/games/services/utils.ts | 38 ++- src/bun/api/jobs/bios-download-job.ts | 36 ++- src/bun/api/jobs/emulator-download-job.ts | 52 ++-- src/bun/api/jobs/import-job.ts | 77 ++++-- src/bun/api/jobs/install-job.ts | 158 ++--------- src/bun/api/jobs/jobs.ts | 95 ++++++- src/bun/api/jobs/launch-game-job.ts | 2 +- src/bun/api/jobs/login-job.ts | 2 +- src/bun/api/jobs/test-download-job.ts | 30 ++ .../com.simeonradivoev.gameflow.romm/romm.ts | 5 +- .../services.ts | 3 +- .../store.ts | 134 ++++++++- src/bun/api/settings/settings.ts | 6 + src/bun/api/system.ts | 8 + src/bun/utils.ts | 7 + src/bun/utils/downloader.ts | 22 +- src/mainview/components/AppCommunication.tsx | 32 ++- src/mainview/components/CardList.tsx | 34 ++- src/mainview/components/ContextDialog.tsx | 6 +- src/mainview/components/Filters.tsx | 2 +- src/mainview/components/GameList.tsx | 12 +- src/mainview/components/GamepadKeyboard.tsx | 4 - .../components/GlobalContextDialog.tsx | 28 ++ src/mainview/components/Header.tsx | 44 ++- src/mainview/components/HeaderSearchField.tsx | 4 +- src/mainview/components/RoundButton.tsx | 3 +- src/mainview/components/Screenshots.tsx | 7 +- src/mainview/components/SelectMenu.tsx | 4 +- src/mainview/components/SideFilters.tsx | 260 ++++++++++++------ .../components/game/ActionButtons.tsx | 6 +- src/mainview/components/game/MainActions.tsx | 155 +++++------ src/mainview/components/options/Button.tsx | 17 +- .../components/store/StoreEmulatorCard.tsx | 34 ++- src/mainview/gen/routeTree.gen.ts | 64 +++++ src/mainview/index.css | 1 + src/mainview/index.tsx | 42 ++- src/mainview/routes/__root.tsx | 9 +- src/mainview/routes/game/$source.$id.tsx | 4 +- src/mainview/routes/game/add.tsx | 39 ++- src/mainview/routes/games.tsx | 2 +- src/mainview/routes/index.tsx | 59 +++- src/mainview/routes/platform.$source.$id.tsx | 14 +- src/mainview/routes/settings/emulators.tsx | 3 +- src/mainview/routes/settings/route.tsx | 7 + src/mainview/routes/settings/tasks.tsx | 123 +++++++++ .../store/details.download.$source.$id.tsx | 129 +++++++++ .../routes/store/details.emulator.$id.tsx | 17 +- src/mainview/routes/store/tab/download.tsx | 109 ++++++++ src/mainview/routes/store/tab/emulators.tsx | 14 +- src/mainview/routes/store/tab/games.tsx | 5 +- src/mainview/routes/store/tab/plugins.tsx | 9 +- src/mainview/routes/store/tab/route.tsx | 3 +- src/mainview/scripts/contexts.ts | 12 +- src/mainview/scripts/queries/romm.ts | 36 ++- src/mainview/scripts/types.ts | 4 +- src/mainview/types.d.ts | 1 + src/packages/gameflow-sdk/hooks/app.ts | 37 +++ src/packages/gameflow-sdk/hooks/emulators.ts | 3 + src/packages/gameflow-sdk/hooks/games.ts | 50 +++- src/packages/gameflow-sdk/package.json | 10 +- src/packages/gameflow-sdk/shared.ts | 79 ++++++ src/packages/gameflow-sdk/task-queue.ts | 64 ++++- src/shared/types.schema.ts | 0 src/shared/types.ts | 0 src/shared/utils.ts | 10 +- 70 files changed, 1922 insertions(+), 560 deletions(-) create mode 100644 src/bun/api/jobs/test-download-job.ts create mode 100644 src/mainview/components/GlobalContextDialog.tsx create mode 100644 src/mainview/routes/settings/tasks.tsx create mode 100644 src/mainview/routes/store/details.download.$source.$id.tsx create mode 100644 src/mainview/routes/store/tab/download.tsx delete mode 100644 src/shared/types.schema.ts delete mode 100644 src/shared/types.ts diff --git a/bun.lock b/bun.lock index c4a2b79..5fbc781 100644 --- a/bun.lock +++ b/bun.lock @@ -25,13 +25,13 @@ "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", - "npm-check-updates": "^22.1.1", + "npm-check-updates": "^22.2.0", "open": "^11.0.0", "p-queue": "^9.2.0", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", - "systeminformation": "^5.31.5", + "systeminformation": "^5.31.6", "tapable": "^2.3.3", "tough-cookie": "^6.0.1", "tough-cookie-file-store": "^3.3.0", @@ -46,10 +46,11 @@ "@hey-api/openapi-ts": "^0.91.1", "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.4", - "@tanstack/react-form": "^1.29.1", - "@tanstack/react-query": "^5.100.9", - "@tanstack/react-query-devtools": "^5.100.9", + "@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", @@ -82,6 +83,7 @@ "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", "pretty-ms": "^9.3.0", @@ -92,30 +94,26 @@ "react-markdown": "^10.1.0", "react-qr-code": "^2.0.21", "sass-embedded": "^1.99.0", - "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.4", + "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.3", - "vite-plugin-svg-icons-ng": "^1.9.0", + "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.5.3", + "version": "1.6.0", "bin": { "gameflow-build": "build.ts", }, "peerDependencies": { "7zip-bin": "^5.2.0", "@auth/core": "^0.34.3", - "@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.1.0", "drizzle-orm": "^0.45.2", @@ -135,12 +133,8 @@ "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", - "systeminformation": "^5.31.5", "tapable": "^2.3.3", - "tough-cookie": "^6.0.1", - "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", - "webview-bun": "^2.4.0", "zod": "^4.4.3", }, }, @@ -566,55 +560,59 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], - "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], + "@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.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], + "@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.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="], + "@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.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="], + "@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.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="], + "@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.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="], + "@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.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="], + "@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.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="], + "@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.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="], + "@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.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="], + "@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.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="], + "@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.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="], + "@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.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="], + "@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.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], + "@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/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=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], + "@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/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], - "@tanstack/form-core": ["@tanstack/form-core@1.29.1", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-NIYPO36eEu7nSWvMpbFDQaBWyVtnH/C8fsZ3/XpJUT4uOWgmxsiUvHGbTbDNIQTXAKIkhwEl0sUrqBNn2SfUnw=="], + "@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.100.9", "", {}, "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="], - "@tanstack/query-devtools": ["@tanstack/query-devtools@5.100.9", "", {}, "sha512-gqiptrTIhbK2PuCaPRHmWXfJG1NGYVFpAr0HqogEqiSBNB5xDz6fmesQt7w4WgMOqOQPnPHJ3ZDMuhDaXvNO8g=="], + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.100.10", "", {}, "sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw=="], - "@tanstack/react-form": ["@tanstack/react-form@1.29.1", "", { "dependencies": { "@tanstack/form-core": "1.29.1", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-hVHk4g0phd0HxRsv2ry6Xt8BqmalT55Q3cokhJBCC1St0hcGZhgwJJbohm9atao45BPG9e55DGvtbwExqZe35g=="], + "@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.100.9", "", { "dependencies": { "@tanstack/query-core": "5.100.9" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A=="], + "@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.100.9", "", { "dependencies": { "@tanstack/query-devtools": "5.100.9" }, "peerDependencies": { "@tanstack/react-query": "^5.100.9", "react": "^18 || ^19" } }, "sha512-mM3slaVGXJmz+pOLgXdANj75ikgQCyudyl3kmFvm6brI1JyVeY/+IeD17uDHIvZrD8hfoO2sdZ54RFsHdYAuhA=="], + "@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-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-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": ["@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=="], @@ -658,7 +656,7 @@ "@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.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@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=="], @@ -790,7 +788,7 @@ "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "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=="], @@ -1158,6 +1156,8 @@ "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=="], @@ -1436,7 +1436,7 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], - "npm-check-updates": ["npm-check-updates@22.1.1", "", { "bin": { "npm-check-updates": "build/cli.js", "ncu": "build/cli.js" } }, "sha512-uWSxJW25dy5ZM4SdLsi0VBgPSJlo7u+jARQ6Xql+85YYCoqXU2ZaympAZ6237/oybCq/I4nXddE9S9BTwBfBXA=="], + "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=="], @@ -1752,13 +1752,13 @@ "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.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], + "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=="], @@ -1866,7 +1866,7 @@ "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.0", "", { "dependencies": { "svg-icon-baker": "2.0.1", "tinyglobby": "^0.2.16" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-vIyinFqjR5gEJiDt1MTFGewAJnwyB7tkZ9fjKQ9m9Wa7XmxTTAcj8h1l3C4zA02K6y/4ZuPYCLzHLovoUPDW6w=="], + "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=="], @@ -1998,13 +1998,15 @@ "@node-minify/core/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], - "@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/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@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/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/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/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@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/@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.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=="], diff --git a/package.json b/package.json index 99a3e52..d770d67 100644 --- a/package.json +++ b/package.json @@ -76,13 +76,13 @@ "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", - "npm-check-updates": "^22.1.1", + "npm-check-updates": "^22.2.0", "open": "^11.0.0", "p-queue": "^9.2.0", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", - "systeminformation": "^5.31.5", + "systeminformation": "^5.31.6", "tapable": "^2.3.3", "tough-cookie": "^6.0.1", "tough-cookie-file-store": "^3.3.0", @@ -97,10 +97,11 @@ "@hey-api/openapi-ts": "^0.91.1", "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.4", - "@tanstack/react-form": "^1.29.1", - "@tanstack/react-query": "^5.100.9", - "@tanstack/react-query-devtools": "^5.100.9", + "@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", @@ -133,6 +134,7 @@ "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", "pretty-ms": "^9.3.0", @@ -143,13 +145,13 @@ "react-markdown": "^10.1.0", "react-qr-code": "^2.0.21", "sass-embedded": "^1.99.0", - "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.4", + "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.3", - "vite-plugin-svg-icons-ng": "^1.9.0", + "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/dev.ts b/scripts/dev.ts index ffc843c..1331f36 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -85,6 +85,13 @@ watch("./src/bun", { recursive: true }, (event, filename) => 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) { 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/games/games.ts b/src/bun/api/games/games.ts index 3c2575c..f06f71f 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -5,7 +5,7 @@ import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; import { SERVER_URL } from "@shared/constants"; -import { GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared'; +import { CommandEntry, DownloadLookupEntry, DownloadsLookupFilterValues, GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; @@ -512,7 +512,25 @@ export default new Elysia() await plugins.hooks.games.gameLookup.promise(matches, { source, id }); return Array.from(matches.values()).flatMap(m => m); }) - .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => + .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) @@ -525,7 +543,7 @@ 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. @@ -676,7 +694,10 @@ export default new Elysia() .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.id, new ImportJob(source, id, gamePath, platformId), true); + 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({ @@ -685,4 +706,41 @@ export default new Elysia() 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/services/utils.ts b/src/bun/api/games/services/utils.ts index aaac97b..9bef2f4 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -8,7 +8,7 @@ import { RPC_URL } from "@shared/constants"; import { hashFile } from "@/bun/utils"; import { host } from "@/bun/utils/host"; import * as emulatorSchema from "@schema/emulators"; -import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared"; +import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared"; export async function calculateSize (installPath: string | null) { @@ -467,4 +467,40 @@ export async function createLocalGame (info: { }); 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/jobs/bios-download-job.ts b/src/bun/api/jobs/bios-download-job.ts index 64537c1..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 "../../../packages/gameflow-sdk/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; isUpdate: boolean; + data: EmulatorDownloadJobData = { + name: "Download Emulator", + emulator: "" + }; 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"); + this.data.name = this.emulatorPackage.name; + this.data.preview_url = this.emulatorPackage.logo; const { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource); - const emulatorsFolder = getEmulatorPath(this.emulator); + const emulatorsFolder = getEmulatorPath(this.data.emulator); if (this.dryRun) { @@ -49,29 +57,33 @@ 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') || destinationPaths[0].endsWith('.tar'); + 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]) { @@ -120,10 +132,10 @@ export class EmulatorDownloadJob implements IJob e.type === 'store')?.binPath ?? emulatorsFolder, info, @@ -136,7 +148,7 @@ export class EmulatorDownloadJob implements IJob, string> +interface ImportJobData extends DownloadJobData +{ + localId: number | null; +} + +export class ImportJob implements IJob { static id = "import-job" as const; - static dataSchema = z.object({ localId: z.number().nullable() }); + 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; - localId: number | null = null; constructor(source: string, id: string, gamePath: string, platformId: number) { @@ -25,18 +36,20 @@ export class ImportJob implements IJob, str this.platformId = platformId; } - exposeData (): z.infer + exposeData () { - return { localId: this.localId }; + return this.data; } - async start (context: JobContext, string>, z.infer, string>): Promise + 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; @@ -50,24 +63,56 @@ export class ImportJob implements IJob, str } } + 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(eq(schema.games.path_fs, this.gamePath)); + 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"); - const platformMatch = match.platforms.find(p => p.id === this.platformId); - - this.localId = await createLocalGame({ + this.data.localId = await createLocalGame({ name: match.name, system_slug: platformMatch?.slug, source: undefined, source_id: undefined, slug: match.slug, - path_fs: this.gamePath, + path_fs: finalFiles[0], summary: match.summary, igdb_id: match.igdb_id, ra_id: undefined, diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index a9433d4..3d3c867 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -1,17 +1,12 @@ -import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import fs from 'node:fs/promises'; import path from 'node:path'; 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, createLocalGame } from "../games/services/utils"; -import { ensureDir, move } from "fs-extra"; -import { path7za } from "7zip-bin"; -import StreamZip from 'node-stream-zip'; -import { which } from "bun"; -import { DownloadInfo } from "@simeonradivoev/gameflow-sdk/shared"; +import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils"; +import { ensureDir } from "fs-extra"; +import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared"; interface JobConfig { @@ -22,7 +17,7 @@ interface JobConfig 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}`; @@ -34,6 +29,9 @@ export class InstallJob implements IJob public localGameId?: number; public group = InstallJob.id; public localPath?: string; + data: DownloadJobData = { + name: "Install Game" + }; constructor(id: string, source: string, config?: JobConfig) { @@ -42,7 +40,7 @@ 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 }); @@ -58,131 +56,31 @@ export class InstallJob implements IJob if (!info) throw new Error(`Could not find downloader for source ${this.source}`); - const files = await checkFiles(info.files, !!info.extract_path); + 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 (!downloadedFiles) - { - return; - } - - if (info.extract_path && downloadedFiles) - { - let progress = 0; - const progressDelta = 1 / downloadedFiles.length; - const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); - - for (const filePath of downloadedFiles) - { - 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 => - { - cx.setProgress(progress + p.percent * progressDelta, "extract"); - }); - 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')) - { - cx.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--; - cx.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 }); - } - } - - finalFiles.push(extractPath); - - } else - { + if (downloadedFiles) finalFiles.push(...downloadedFiles); - } } if (this.config?.dryDownload === true && info.extract_path) @@ -193,7 +91,7 @@ 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(); this.localGameId = await createLocalGame({ cover, diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index f75605c..e7e20a2 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -6,19 +6,21 @@ import TwitchLoginJob from "./twitch-login-job"; import UpdateStoreJob from "./update-store"; import { EmulatorDownloadJob } from "./emulator-download-job"; import { getErrorMessage } from "@/bun/utils"; -import { IJob } from "../../../packages/gameflow-sdk/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', [ @@ -30,9 +32,9 @@ 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() }) ]), @@ -42,7 +44,7 @@ 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' }); @@ -102,6 +104,83 @@ 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)) diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index f5072e9..3ce0e83 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; +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"; diff --git a/src/bun/api/jobs/login-job.ts b/src/bun/api/jobs/login-job.ts index dd112ad..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 "../../../packages/gameflow-sdk/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/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/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 93c6fbe..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 @@ -6,7 +6,7 @@ import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiC 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"; @@ -254,8 +254,7 @@ export default class RommIntegration implements PluginType let path_fs = path.join(rom.fs_path, rom.fs_name); if (files.length === 1) { - const name = files[0].file_name.toLocaleLowerCase(); - if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar')) + if (isArchive(files[0].file_name)) { extract_path = '.'; path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext); 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 index 17c5f12..4935dd7 100644 --- 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 @@ -12,6 +12,7 @@ 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; }) { @@ -39,7 +40,7 @@ export async function getStoreGame (id: string) function convertStoreMediaToPath (c: string) { - if (c.startsWith('http')) + if (isUrl(c)) { return `/api/romm/image?url=${encodeURIComponent(c)}`; } else 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 index 9b514a2..118eb11 100644 --- 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 @@ -2,7 +2,7 @@ import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-s import desc from './package.json'; import path, { } from 'node:path'; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; -import { Glob, pathToFileURL } from "bun"; +import { Glob, pathToFileURL, which } from "bun"; import { and, eq } from "drizzle-orm"; import * as emulatorSchema from '@schema/emulators'; @@ -13,6 +13,12 @@ import UpdateStoreJob from "@/bun/api/jobs/update-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 RommIntegration implements PluginType { @@ -295,7 +301,7 @@ export default class RommIntegration implements PluginType const info: DownloadInfo = { id: validDownload.id, - coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", + 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), @@ -325,5 +331,129 @@ export default class RommIntegration implements PluginType 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/settings/settings.ts b/src/bun/api/settings/settings.ts index e4e2da1..ebd5b91 100644 --- a/src/bun/api/settings/settings.ts +++ b/src/bun/api/settings/settings.ts @@ -10,6 +10,8 @@ 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 () => @@ -112,6 +114,10 @@ export const settings = new Elysia({ prefix: '/api/settings' }) { 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)]; diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 5408a6d..2124144 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -86,6 +86,7 @@ export const system = new Elysia({ prefix: '/api/system' }) z.object({ type: z.literal('info'), data: SystemInfoSchema }), 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) @@ -94,6 +95,8 @@ export const system = new Elysia({ prefix: '/api/system' }) 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(); @@ -116,6 +119,8 @@ export const system = new Elysia({ prefix: '/api/system' }) 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 }); @@ -127,6 +132,8 @@ export const system = new Elysia({ prefix: '/api/system' }) })); 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) @@ -134,6 +141,7 @@ export const system = new Elysia({ prefix: '/api/system' }) })); 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" }); })); diff --git a/src/bun/utils.ts b/src/bun/utils.ts index fe44ad2..a3868e4 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -5,6 +5,8 @@ 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) { try @@ -178,4 +180,9 @@ 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); } \ No newline at end of file diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index f0f30ca..920e7c8 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -5,12 +5,7 @@ import fs from 'node:fs/promises'; import { createWriteStream } from "node:fs"; import { config, jar } from "../api/app"; import { moveAllFiles } from "../utils"; -import { DownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared"; - -export interface ProgressStats -{ - progress: number; -} +import { DownloadFileEntry, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared"; interface TmpDownloadMetadata { @@ -32,6 +27,7 @@ export class Downloader id: string; tmpPath: string; tmpPathMeta: string; + downloadSpeed: number = 0; /** * @@ -163,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(); @@ -181,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(); } } @@ -194,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; } diff --git a/src/mainview/components/AppCommunication.tsx b/src/mainview/components/AppCommunication.tsx index 3dec17a..df727ad 100644 --- a/src/mainview/components/AppCommunication.tsx +++ b/src/mainview/components/AppCommunication.tsx @@ -1,13 +1,14 @@ import { useEffect, useRef, useState } from "react"; -import { SystemInfoContext } from "../scripts/contexts"; +import { AppContext, SystemInfoContext } from "../scripts/contexts"; import { systemApi } from "../scripts/clientApi"; -import { SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared'; +import { AppInfoContext, SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared'; import LoadingScreen from "./LoadingScreen"; import { GamepadKeyboard } from "./GamepadKeyboard"; export default function AppCommunication (data: { children: any; }) { const [systemInfo, setSystemInfo] = useState(); + const [appContext, setAppContext] = useState({} as AppInfoContext); const [loadingInfo, setLoadingInfo] = useState(undefined); const [loading, setLoading] = useState(true); const loadingProgressBarRef = useRef(null); @@ -25,6 +26,9 @@ 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) @@ -45,17 +49,19 @@ export default function AppCommunication (data: { children: any; }) }, []); return - {loading ? - -
-
- - {loadingInfo} + + {loading ? + +
+
+ + {loadingInfo} +
+
- -
- - : data.children} - + + : data.children} + + ; } \ No newline at end of file diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index d05ce7b..8511374 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -6,7 +6,7 @@ import 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,7 +16,7 @@ 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.previewUrls) @@ -31,7 +31,28 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara oneShot('click'); }; - useShortcuts(data.game.focusKey, () => [{ label: "Details", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]); + 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 ( {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/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 353f429..54babea 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -64,7 +64,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class className={ twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}> -
@@ -166,7 +166,7 @@ export function ContextDialog (data: { }] : [], [data.open]); return @@ -174,7 +174,7 @@ export function ContextDialog (data: {
-
    +
      {!!data.rootFocusKey && (data.showShortcuts ?? true) &&
    • } diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index 80c4944..1075a9f 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -2,13 +2,15 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; import { DefaultRommStaleTime, RPC_URL } from "@shared/constants"; import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useRouter } from "@tanstack/react-router"; import { HardDrive } from "lucide-react"; import { JSX, useContext } from "react"; 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 extends FocusParams { @@ -17,6 +19,7 @@ export interface GameListParams extends FocusParams grid?: boolean, setBackground?: (url: string) => void; onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; + onQuickAction?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; focus?: string; className?: string; finalElement?: JSX.Element | JSX.Element[]; @@ -97,7 +100,7 @@ export function GameList (data: GameListParams) const previewUrls = g.path_covers.map(c => { - const url = c.startsWith("http") ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`); + const url = isUrl(c) ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`); url.searchParams.delete('ts'); return url; }); @@ -105,13 +108,13 @@ export function GameList (data: GameListParams) let platformUrl: URL | undefined = undefined; if (g.path_platform_cover) { - platformUrl = g.path_platform_cover.startsWith("http") ? new URL(g.path_platform_cover) : new URL(`${RPC_URL(__HOST__)}${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: `${data.id}-${g.id.source}@${g.id.id}`, + focusKey: FOCUS_KEYS.GAME_LIST_CARD(data.id, g.id), title: g.name ?? "", subtitle: (
      @@ -122,6 +125,7 @@ export function GameList (data: GameListParams) 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 index 37e533b..75005a1 100644 --- a/src/mainview/components/GamepadKeyboard.tsx +++ b/src/mainview/components/GamepadKeyboard.tsx @@ -387,10 +387,6 @@ export function GamepadKeyboard () const magnitudeSqr = (x * x) + (y * y); const magnitude = Math.sqrt(magnitudeSqr); - const elementPos = keyIndex < 0 ? undefined : elements[side].positions[keyIndex]; - //const lerpX = (element?.left ?? 0); - //const lerpY = (element?.top ?? 0); - const size = 12; circle.style.left = `calc(50% + ${50 * x}% - 16px)`; circle.style.top = `calc(50% + ${50 * y}% - 16px)`; circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`; 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 932dada..d38ef5b 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -29,10 +29,11 @@ import { twMerge } from "tailwind-merge"; import { TwitchIcon } from "../scripts/brandIcons"; import { rommLoggedInQuery } from "../scripts/queries/romm"; import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; -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 +74,7 @@ export interface HeaderButton external?: boolean; action?: () => void; className?: string; + shortcutLabel?: string; } export interface HeaderAccount @@ -111,14 +113,22 @@ function NotificationStatus () 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' }); } } @@ -142,7 +152,16 @@ function ClockStatus () return () => clearTimeout(timeout); }, []); - return
      ; + useShortcuts(focusKey, () => [{ + label: "Downloads", button: GamePadButtonCode.A, action (e) + { + handleTaskClick(); + }, + }]); + + return
      + + {activeTaskProgress ?
      : }
      ; } function BluetoothStatus () @@ -288,6 +307,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement {data.buttonElements} {data.buttons?.map(b => {data.title} - , id: "header-settings-btn", action: goToSettings, external: true }]} /> + , + id: "header-settings-btn", + action: goToSettings, + external: true, + shortcutLabel: "Settings" + } + ]} /> diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx index 823af58..198c552 100644 --- a/src/mainview/components/HeaderSearchField.tsx +++ b/src/mainview/components/HeaderSearchField.tsx @@ -96,10 +96,10 @@ export default function HeaderSearchField (data: { isFocusBoundary: data.compact && showInput }); - return
      + return
      {(!data.compact || showInput) && } - {data.compact && !showInput && setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} >} + {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/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 42d76d3..e65a965 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -8,6 +8,7 @@ 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) { @@ -21,8 +22,9 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n 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" /> + 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) diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx index fa10743..42d21c8 100644 --- a/src/mainview/components/SelectMenu.tsx +++ b/src/mainview/components/SelectMenu.tsx @@ -2,7 +2,7 @@ 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, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; +import { DoorOpen, Gamepad2, Home, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; import { systemApi } from "../scripts/clientApi"; import { FOCUS_KEYS } from "../scripts/types"; @@ -15,7 +15,7 @@ export default function SelectMenu (data: { rootFocusKey: string; }) const options: DialogEntry[] = [ { content: "Home", - icon: , + icon: , action (ctx) { setOpen(false); diff --git a/src/mainview/components/SideFilters.tsx b/src/mainview/components/SideFilters.tsx index 6f99336..930bf2b 100644 --- a/src/mainview/components/SideFilters.tsx +++ b/src/mainview/components/SideFilters.tsx @@ -1,25 +1,25 @@ -import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; +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 } from "lucide-react"; +import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; import { sourceIconMap } from "./Constants"; -import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog"; +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: { - setToggle: (focNewSourceFocusKey?: string | undefined) => void; - }; + dialog: (focNewSourceFocusKey: string) => void; isActive: boolean; }) { - const handleAction = () => data.dialog.setToggle(data.id); + const handleAction = () => data.dialog(data.id); useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]); return
      ; } +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; @@ -42,96 +125,107 @@ export default function SideFilters (data: { { const { ref, focusKey } = useFocusable({ focusKey: data.id }); + const globalDialog = useContext(GlobalDialogContext); - const orderByDialog = useContextDialog('order-by-dialog', { - 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}`, + 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) { - data.setLocalFilter({ ...data.localFilter, orderBy: o.stat }); + 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(); }, - }))} />, - preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}` - }); + }))} /> + }, focusKey); + }; - const sourceFilterDialog = useContextDialog('source-filter-dialog', { - content: (o => ({ - content: o, - icon: sourceIconMap[o], - selected: data.localFilter.source === o, - id: `source-filter-${o}`, + 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, source: undefined }); - else data.setLocalFilter({ ...data.localFilter, source: o }); + 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(); }, - })).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}` - }); - - const genreFilterDialog = useContextDialog('genre-filter-dialog', { - 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(); - }, - }))} /> - }); - - const ageRatingFilterDialog = useContextDialog('age-rating-filter-dialog', { - 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={} /> + } /> + 0} icon={} /> + 0} icon={} /> {!data.filters?.source && - } /> + } /> } {Object.values(data.localFilter).some(v => v !== undefined) && <> @@ -139,10 +233,6 @@ export default function SideFilters (data: { data.setLocalFilter({})} className='p-3 drop-shadow-md!' > } - {orderByDialog.dialog} - {sourceFilterDialog.dialog} - {genreFilterDialog.dialog} - {ageRatingFilterDialog.dialog}
      ; } \ No newline at end of file diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index 1a60c93..d37ea00 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -30,7 +30,11 @@ function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractP ; } -export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) +export default function ActionButtons (data: { + game?: FrontEndGameTypeDetailed, + source: string, + id: string; +}) { const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); const navigate = useNavigate(); diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 20bb27b..6f772af 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -1,21 +1,20 @@ 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 { 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 { useRouter } from "@tanstack/react-router"; +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 router = useRouter(); const playMut = useMutation({ ...playMutation, onError (error) { @@ -23,9 +22,36 @@ 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 } }); + 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); @@ -42,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(); @@ -99,32 +125,33 @@ 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(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command); - router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()) }); - } else - { - 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') { - if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey); - mainAction = () => handlePlay(validDefaultCommand); + 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 =
      1) { - showInstallSource(true, 'mainAction'); + 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({}); @@ -222,55 +260,21 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so return shortcuts; }, [showAllCommandsAction, mainAction]); - 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) - }); - - const { dialog: installOptionsDialog, setOpen: showInstallOptions } = useContextDialog('install-options-dialog', { - content: - }); - - const { dialog: installSourcesDialog, setOpen: showInstallSource } = useContextDialog('install-source-dialog', { - content: ({ - content: s.name, - action (ctx) - { - installMut.mutate({ downloadId: s.id }); - ctx.close(); - }, - type: 'primary', - id: s.id - } satisfies DialogEntry)) ?? []} /> - }); - 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} @@ -278,8 +282,5 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
      } - {installSourcesDialog} - {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 de07bdf..e131123 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -12,7 +12,7 @@ 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", @@ -22,6 +22,17 @@ 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, @@ -64,9 +75,9 @@ export function Button (data: { className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 not-disabled:cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:not-disabled:bg-base-content control-mouse:hover:not-disabled:text-base-100 active:not-disabled:transition-none active:not-disabled:ring-offset-4", styles[data.style ?? 'base'], focused ? data.focusClassName : undefined, + data.external ? `focusable focusable-hover ${externalStyles[data.style as keyof typeof externalStyles]}` : '', classNames({ - "btn-accent": focused, - "focusable focusable-primary focusable-hover": data.external + "btn-accent": focused }, data.className))} type={data.type ?? 'button'} > diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 8645a01..3479579 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -6,11 +6,15 @@ import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; 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: , @@ -28,6 +32,7 @@ export function StoreEmulatorCard (data: { className?: string; }) { + const navigate = useNavigate(); const handleSelect = () => { data.onSelect?.(data.emulator.name, focusKey); @@ -45,7 +50,32 @@ export function StoreEmulatorCard (data: { const { data: updateInfo } = useQuery(getUpdateInfoForEmulator(data.emulator.name)); - useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]); + 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 (
      SettingsRouteRoute, } as any) +const SettingsTasksRoute = SettingsTasksRouteImport.update({ + id: '/tasks', + path: '/tasks', + getParentRoute: () => SettingsRouteRoute, +} as any) const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ id: '/plugins', path: '/plugins', @@ -115,6 +123,11 @@ 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', @@ -160,6 +173,12 @@ const GameUpdateSourceIdRoute = GameUpdateSourceIdRouteImport.update({ 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 @@ -173,6 +192,7 @@ export interface FileRoutesByFullPath { '/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 @@ -180,6 +200,7 @@ export interface FileRoutesByFullPath { '/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 @@ -187,6 +208,7 @@ export interface FileRoutesByFullPath { '/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 @@ -199,6 +221,7 @@ export interface FileRoutesByTo { '/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 @@ -206,6 +229,7 @@ export interface FileRoutesByTo { '/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 @@ -213,6 +237,7 @@ export interface FileRoutesByTo { '/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 @@ -227,6 +252,7 @@ export interface FileRoutesById { '/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 @@ -234,6 +260,7 @@ export interface FileRoutesById { '/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 @@ -241,6 +268,7 @@ export interface FileRoutesById { '/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 @@ -256,6 +284,7 @@ export interface FileRouteTypes { | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/tasks' | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' @@ -263,6 +292,7 @@ export interface FileRouteTypes { | '/launcher/$source/$id' | '/platform/$source/$id' | '/settings/plugin/$source' + | '/store/tab/download' | '/store/tab/emulators' | '/store/tab/games' | '/store/tab/plugins' @@ -270,6 +300,7 @@ export interface FileRouteTypes { | '/game/update/$source/$id' | '/store/details/emulator/$id' | '/store/details/plugin/$id' + | '/store/details/download/$source/$id' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -282,6 +313,7 @@ export interface FileRouteTypes { | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/tasks' | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' @@ -289,6 +321,7 @@ export interface FileRouteTypes { | '/launcher/$source/$id' | '/platform/$source/$id' | '/settings/plugin/$source' + | '/store/tab/download' | '/store/tab/emulators' | '/store/tab/games' | '/store/tab/plugins' @@ -296,6 +329,7 @@ export interface FileRouteTypes { | '/game/update/$source/$id' | '/store/details/emulator/$id' | '/store/details/plugin/$id' + | '/store/details/download/$source/$id' id: | '__root__' | '/' @@ -309,6 +343,7 @@ export interface FileRouteTypes { | '/settings/emulators' | '/settings/interface' | '/settings/plugins' + | '/settings/tasks' | '/settings/update' | '/collection/$source/$id' | '/embedded/$source/$id' @@ -316,6 +351,7 @@ export interface FileRouteTypes { | '/launcher/$source/$id' | '/platform/$source/$id' | '/settings/plugin/$source' + | '/store/tab/download' | '/store/tab/emulators' | '/store/tab/games' | '/store/tab/plugins' @@ -323,6 +359,7 @@ export interface FileRouteTypes { | '/game/update/$source/$id' | '/store/details/emulator/$id' | '/store/details/plugin/$id' + | '/store/details/download/$source/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -339,6 +376,7 @@ export interface RootRouteChildren { GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute StoreDetailsPluginIdRoute: typeof StoreDetailsPluginIdRoute + StoreDetailsDownloadSourceIdRoute: typeof StoreDetailsDownloadSourceIdRoute } declare module '@tanstack/react-router' { @@ -371,6 +409,13 @@ declare module '@tanstack/react-router' { 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' @@ -455,6 +500,13 @@ 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' @@ -518,6 +570,13 @@ declare module '@tanstack/react-router' { 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 + } } } @@ -528,6 +587,7 @@ interface SettingsRouteRouteChildren { SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute SettingsInterfaceRoute: typeof SettingsInterfaceRoute SettingsPluginsRoute: typeof SettingsPluginsRoute + SettingsTasksRoute: typeof SettingsTasksRoute SettingsUpdateRoute: typeof SettingsUpdateRoute SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute } @@ -539,6 +599,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { SettingsEmulatorsRoute: SettingsEmulatorsRoute, SettingsInterfaceRoute: SettingsInterfaceRoute, SettingsPluginsRoute: SettingsPluginsRoute, + SettingsTasksRoute: SettingsTasksRoute, SettingsUpdateRoute: SettingsUpdateRoute, SettingsPluginSourceRoute: SettingsPluginSourceRoute, } @@ -548,6 +609,7 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( ) interface StoreTabRouteRouteChildren { + StoreTabDownloadRoute: typeof StoreTabDownloadRoute StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute StoreTabGamesRoute: typeof StoreTabGamesRoute StoreTabPluginsRoute: typeof StoreTabPluginsRoute @@ -555,6 +617,7 @@ interface StoreTabRouteRouteChildren { } const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = { + StoreTabDownloadRoute: StoreTabDownloadRoute, StoreTabEmulatorsRoute: StoreTabEmulatorsRoute, StoreTabGamesRoute: StoreTabGamesRoute, StoreTabPluginsRoute: StoreTabPluginsRoute, @@ -579,6 +642,7 @@ const rootRouteChildren: RootRouteChildren = { GameUpdateSourceIdRoute: GameUpdateSourceIdRoute, StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute, StoreDetailsPluginIdRoute: StoreDetailsPluginIdRoute, + StoreDetailsDownloadSourceIdRoute: StoreDetailsDownloadSourceIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/mainview/index.css b/src/mainview/index.css index 4c82b71..332862e 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -9,6 +9,7 @@ @theme { --breakpoint-sm: 0px; --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 f5639f9..166cc4f 100644 --- a/src/mainview/index.tsx +++ b/src/mainview/index.tsx @@ -8,7 +8,7 @@ import RouterProvider, } from "@tanstack/react-router"; import { routeTree } from "./gen/routeTree.gen"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient } from "@tanstack/react-query"; import "./scripts/gamepads"; import "./scripts/windowEvents"; import "./scripts/spatialNavigation"; @@ -16,6 +16,16 @@ import NotFound from "./components/NotFound"; import Error from "./components/Error"; import serviceWorker from './scripts/serviceWorker?worker&url'; 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) { @@ -24,7 +34,31 @@ if ('serviceWorker' in navigator) const hashHistory = createHashHistory({}); -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60 * 24 * 5, // 5 days + } + } +}); + +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 { @@ -74,9 +108,9 @@ if (!rootElement.innerHTML) root.render( - + - + , ); diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index fbe2f26..cafbab4 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -8,6 +8,7 @@ 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, @@ -39,9 +40,11 @@ function RootComponent () return (
      - - - + + + + + {queryDevOptions && } diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 761e9ea..13ea8ac 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -33,7 +33,9 @@ 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" diff --git a/src/mainview/routes/game/add.tsx b/src/mainview/routes/game/add.tsx index 3a6a2f8..6399cd0 100644 --- a/src/mainview/routes/game/add.tsx +++ b/src/mainview/routes/game/add.tsx @@ -8,15 +8,18 @@ import { PathSettingsOptionBase } from '@/mainview/components/options/PathSettin 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, FileSearch, FolderOpen, HardDrive } from 'lucide-react'; +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'; @@ -39,6 +42,7 @@ export const Route = createFileRoute('/game/add')({ 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; @@ -95,6 +101,17 @@ 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({ @@ -105,7 +122,7 @@ function Overview (data: {}) }, async onSuccess (data, variables, onMutateResult, context) { - if (data.id === null) return; + if (data.id === null || isUrl(state.gameLocation)) return; await context.client.invalidateQueries(allGamesInvalidateQuery); navigate({ to: '/game/$source/$id', params: { @@ -136,7 +153,13 @@ function Overview (data: {})
      {platform?.match.type}
      -
      {state.gameLocation}
      +
      {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
      @@ -150,6 +173,11 @@ function Overview (data: {}) gamePath: state.gameLocation, platformId: state.platformId }); + if (isUrl(state.gameLocation)) + { + navigate({ to: '/settings/tasks' }); + } + }} > Add Game
      ; } diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index cd1fe45..bd0fc8e 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -31,7 +31,7 @@ function RouteComponent () return + [ { navigate({ to: '/game/add' }); }} >, diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index a9d33c1..8ae562a 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -14,6 +14,7 @@ import import { createFileRoute, + useNavigate, useRouter, } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -40,7 +41,7 @@ import z from "zod"; import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; import { mobileCheck, scrollIntoViewHandler, useDragScroll } from "../scripts/utils"; -import { AnimatedBackgroundContext } from "../scripts/contexts"; +import { AnimatedBackgroundContext, GlobalDialogContext } from "../scripts/contexts"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; import { gameQuery } from "../scripts/queries/romm"; @@ -51,6 +52,10 @@ 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, @@ -152,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) => { @@ -169,6 +177,52 @@ function HomeList (data: { 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) { @@ -190,6 +244,7 @@ function HomeList (data: { activeList = <> { @@ -203,7 +258,7 @@ function HomeList (data: { setBackground={bg.setBackground} filters={{ limit: 12, orderBy: 'activity' }} finalElement={[ - , + , ]} emptyElement={[ diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index f4df81d..bc35faf 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -8,8 +8,10 @@ import { zodValidator } from "@tanstack/zod-adapter"; import z from "zod"; import { useLocalStorage } from "usehooks-ts"; import { RefreshCcw, Settings2 } from "lucide-react"; -import { ContextList, DialogEntry, useContextDialog } from "../components/ContextDialog"; +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, @@ -45,6 +47,7 @@ function RouteComponent () context.client.invalidateQueries(localPlatformFilter(id)); }, }); + const globalDialog = useContext(GlobalDialogContext); const deletePlatform = useMutation({ ...deletePlatformMutation(id), onError (error, variables, onMutateResult, context) @@ -77,7 +80,7 @@ function RouteComponent () if (source === 'local') { settingsOptions.push({ - id: 'update-platform', + id: 'delete-platform', type: "error", content: "Delete", icon: deletePlatform.isPending ? : , @@ -88,10 +91,6 @@ function RouteComponent () }); } - const { dialog: platformSettingsDialog, setOpen: setPlatformSettingsOpen } = useContextDialog('platform-settings-dialog', { - content: - }); - return (
      , action () { - setPlatformSettingsOpen(true, 'open-platform-settings-btn'); + globalDialog.openContext({ content: }, 'open-platform-settings-btn'); }, }]} countHint={countHint} title={} filters={{ platform_id: Number(id), platform_source: source }} /> - {platformSettingsDialog}
      ); } diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 9abddb4..17344df 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -24,6 +24,7 @@ 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, @@ -238,7 +239,7 @@ function EmulatorBadge (data: { let logoUrl: string | undefined = undefined; if (data.emulator.logo) { - if (data.emulator.logo.startsWith('http')) + if (isUrl(data.emulator.logo)) { logoUrl = data.emulator.logo; } else diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index fd83578..625e884 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -16,6 +16,7 @@ import classNames from "classnames"; import { ArrowBigLeft, + Cog, FingerprintPattern, HardDrive, Info, @@ -155,6 +156,12 @@ function SettingsMenu (data: {}) label="Plugins" icon={} /> + } + /> ([]); + 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/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 3383ea8..d8c8372 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useContext, useRef, useState } from "react"; import { useFocusable, @@ -11,7 +11,7 @@ import { AnimatedBackground } from "@/mainview/components/AnimatedBackground"; import { rommApi, systemApi } from "@/mainview/scripts/clientApi"; import { Button } from "@/mainview/components/options/Button"; import { ChevronDown, CircleFadingArrowUp, CloudUpload, Cpu, Download, Fullscreen, Gamepad2, Info, Monitor, Puzzle, Settings, Settings2, Terminal, Trash2, TriangleAlert, WandSparkles } from "lucide-react"; -import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog"; +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"; @@ -30,6 +30,7 @@ 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, @@ -65,6 +66,7 @@ function TitleArea (data: { onUpdate: (source: string) => void; }) { + const globalDialog = useContext(GlobalDialogContext); const navigation = useNavigate(); const queryClient = useQueryClient(); const deleteMutation = useMutation({ @@ -253,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) return false; - setOpen(true, 'install-btn'); + openOptionsDialog('install-btn'); }; return
      @@ -294,10 +294,10 @@ function TitleArea (data: {
      {(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion &&
      - +
      } {(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore &&
      - +
      }
      - {installOptionsDialog}
      ; } 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 0d4ba0e..1fb831f 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -11,10 +11,15 @@ 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 + errorComponent: InvalidStoreError, + validateSearch: zodValidator(z.object({ + search: z.string().optional() + })) }); function RouteComponent () @@ -26,7 +31,11 @@ function RouteComponent () preferredChildFocusKey: focus }); const storeContext = useContext(StoreContext); - const { data: emulators } = useQuery({ ...storeEmulatorsQuery({ search }), retry: false, throwOnError: true }); + const { data: emulators } = useQuery({ + ...storeEmulatorsQuery({ search }), + retry: false, + throwOnError: true + }); useEffect(() => { @@ -62,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 21e059f..9176c20 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -15,6 +15,7 @@ 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, @@ -68,7 +69,7 @@ function RouteComponent () Games
      -
      +
      {data.plugin.package.description}
      -
        {data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map(k =>
      • {k}
      • )}
      +
        {data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map((k, i) =>
      • {k}
      • )}
      • {data.plugin.package.publisher.username}
      • diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index 05b4c1e..81a5a01 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -14,7 +14,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; -import { Gamepad2, Home, Joystick, Puzzle } from 'lucide-react'; +import { DownloadCloud, Gamepad2, Home, Joystick, Puzzle } from 'lucide-react'; import { useRef } from 'react'; import { useSessionStorage } from 'usehooks-ts'; import z from 'zod'; @@ -97,6 +97,7 @@ function RouteComponent () 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 [search, setSearch] = useSessionStorage(`${router.history.location.pathname}-search`, undefined); diff --git a/src/mainview/scripts/contexts.ts b/src/mainview/scripts/contexts.ts index 3b33a01..0c958b1 100644 --- a/src/mainview/scripts/contexts.ts +++ b/src/mainview/scripts/contexts.ts @@ -1,4 +1,4 @@ -import { SystemInfoType, Drive } from '@simeonradivoev/gameflow-sdk/shared'; +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"; @@ -45,6 +45,16 @@ export const ShortcutsContext = createContext({} as { 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/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 63f8623..5c76d2b 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -1,7 +1,7 @@ import { DefaultRommStaleTime } from "@/shared/constants"; -import { GameListFilterType, RommLoginDataSchema, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared'; +import { GameListFilterType, RommLoginDataSchema, FrontEndId, DownloadLookupEntry, DownloadsLookupFilter } from '@simeonradivoev/gameflow-sdk/shared'; import { rommApi, settingsApi } from "../clientApi"; -import { InvalidateQueryFilters, mutationOptions, QueryClient, 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"; @@ -293,4 +293,36 @@ export const addManualGameMutation = mutationOptions({ 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/types.ts b/src/mainview/scripts/types.ts index 6ab944a..fbfcb36 100644 --- a/src/mainview/scripts/types.ts +++ b/src/mainview/scripts/types.ts @@ -12,7 +12,9 @@ export const FOCUS_KEYS = { 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}` + 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/types.d.ts b/src/mainview/types.d.ts index 1a1246e..2a0c101 100644 --- a/src/mainview/types.d.ts +++ b/src/mainview/types.d.ts @@ -50,6 +50,7 @@ declare interface GameMeta extends FocusParams { id: string, onSelect?: () => void, + onQuickAction?: () => void, title: string, subtitle?: any, previewUrls?: string | URL[]; diff --git a/src/packages/gameflow-sdk/hooks/app.ts b/src/packages/gameflow-sdk/hooks/app.ts index d8fef7a..1b73daa 100644 --- a/src/packages/gameflow-sdk/hooks/app.ts +++ b/src/packages/gameflow-sdk/hooks/app.ts @@ -1,7 +1,9 @@ +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 { @@ -9,4 +11,39 @@ export class GameflowHooks 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/packages/gameflow-sdk/hooks/emulators.ts b/src/packages/gameflow-sdk/hooks/emulators.ts index 768e56f..d852d06 100644 --- a/src/packages/gameflow-sdk/hooks/emulators.ts +++ b/src/packages/gameflow-sdk/hooks/emulators.ts @@ -5,6 +5,7 @@ import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; export default class EmulatorHooks { + /** Download emulator bios files */ fetchBiosDownload = new AsyncSeriesBailHook<[ctx: { emulator: string; systems: EmulatorSystem[]; @@ -15,7 +16,9 @@ export default class EmulatorHooks * 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() diff --git a/src/packages/gameflow-sdk/hooks/games.ts b/src/packages/gameflow-sdk/hooks/games.ts index 9083e47..314b138 100644 --- a/src/packages/gameflow-sdk/hooks/games.ts +++ b/src/packages/gameflow-sdk/hooks/games.ts @@ -1,30 +1,32 @@ -import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '../shared'; +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 - * @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: { + /** 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; @@ -41,34 +43,36 @@ export default class GameHooks }], EmulatorSupport | undefined, { emulator: string; }>(['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: 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 - * @param ctx.checksum Check if file already exists using checksums - */ + /** 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; @@ -86,6 +90,7 @@ export default class GameHooks 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; @@ -96,6 +101,23 @@ export default class GameHooks 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; @@ -104,6 +126,7 @@ export default class GameHooks fetchPlatforms = new AsyncSeriesHook<[ctx: { platforms: FrontEndPlatformType[]; }]>(['ctx']); + /** Called before the game is played. */ prePlay = new AsyncSeriesHook<[ctx: { source: string, id: string; @@ -115,20 +138,25 @@ export default class GameHooks }; }]>(["ctx"]); /** - * @param changedSaveFiles Auto detected changed files. This is mainly used to see what changed during gameplay - * @param validChangedSaveFiles This will be final valid changes to be saved using save integrations like rclone + * 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; diff --git a/src/packages/gameflow-sdk/package.json b/src/packages/gameflow-sdk/package.json index 09354fe..4e4ce28 100644 --- a/src/packages/gameflow-sdk/package.json +++ b/src/packages/gameflow-sdk/package.json @@ -13,10 +13,6 @@ "peerDependencies": { "7zip-bin": "^5.2.0", "@auth/core": "^0.34.3", - "@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.1.0", "drizzle-orm": "^0.45.2", @@ -36,16 +32,12 @@ "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", - "systeminformation": "^5.31.5", "tapable": "^2.3.3", - "tough-cookie": "^6.0.1", - "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", - "webview-bun": "^2.4.0", "zod": "^4.4.3" }, "keywords": [ "gameflow", "sdk" ] -} +} \ No newline at end of file diff --git a/src/packages/gameflow-sdk/shared.ts b/src/packages/gameflow-sdk/shared.ts index 147db36..96ddb01 100644 --- a/src/packages/gameflow-sdk/shared.ts +++ b/src/packages/gameflow-sdk/shared.ts @@ -250,6 +250,7 @@ export interface EmulatorSourceEntryType binPath: string; rootPath?: string; type: EmulatorSourceType; + /** Does the emulator exist in the file system */ exists: boolean; } @@ -489,6 +490,15 @@ export interface GameInstallProgress 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; @@ -622,10 +632,79 @@ export interface GameLookup }[]; } +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/packages/gameflow-sdk/task-queue.ts b/src/packages/gameflow-sdk/task-queue.ts index b86aab6..9ed555e 100644 --- a/src/packages/gameflow-sdk/task-queue.ts +++ b/src/packages/gameflow-sdk/task-queue.ts @@ -18,14 +18,24 @@ export class TaskQueue }); } - public enqueue (id: string, job: T, throwOnError?: boolean): T extends IJob + 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; @@ -35,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)); @@ -82,6 +109,14 @@ export class TaskQueue return job?.promise.promise ?? Promise.resolve(); } + 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 @@ -99,6 +134,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); @@ -170,6 +215,7 @@ 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; @@ -210,12 +256,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', () => { @@ -247,7 +295,13 @@ export class JobContext, TData, TState extends str { if (error.target instanceof AbortSignal) { - this.m_promise.resolve(undefined); + if (this.throwOnCancel) + { + this.m_promise.reject(this.abortSignal.reason); + } else + { + this.m_promise.resolve(undefined); + } } else { console.error(error); diff --git a/src/shared/types.schema.ts b/src/shared/types.schema.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/shared/types.ts b/src/shared/types.ts deleted file mode 100644 index e69de29..0000000 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 From 55939858842eed0bc328ea68de6cf4ca565fb8b6 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 15 May 2026 15:07:51 +0300 Subject: [PATCH 2/3] chore: Fixed tests --- .gitignore | 1 + src/bun/api/app.ts | 7 ++ src/bun/api/games/games.ts | 6 +- .../jobs/{update-store.ts => ensure-store.ts} | 27 +++--- src/bun/api/jobs/jobs.ts | 4 +- .../store.ts | 10 +-- src/bun/api/plugins/register-plugins.ts | 89 ++++++++++--------- src/bun/api/plugins/services.ts | 2 + src/bun/api/store/store.ts | 6 +- src/bun/utils.ts | 15 ++++ src/packages/gameflow-sdk/index.ts | 3 +- src/packages/gameflow-sdk/task-queue.ts | 29 ++++++ src/tests/preload.ts | 6 +- 13 files changed, 139 insertions(+), 66 deletions(-) rename src/bun/api/jobs/{update-store.ts => ensure-store.ts} (60%) diff --git a/.gitignore b/.gitignore index e7e5c74..880e27d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ downloads gameflow-deck.code-workspace .env.local src/tests/mock-roms/db.sqlite +src/tests/mock-roms/store src/tests/mock-config bin .config/flatpak/repo diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index 68ec287..4695726 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -116,6 +116,13 @@ export async function cleanup () 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 () { await ensureDir(config.get('downloadPath')); diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index f06f71f..d922b36 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -454,18 +454,18 @@ export default new Elysia() }, { params: z.object({ id: z.string(), source: z.string() }), }) - .post('/game/:source/:id/install', async ({ params: { id, source }, body: { downloadId } }) => + .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, { downloadId })); + 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() }), + body: z.object({ downloadId: z.string().optional() }).optional(), response: z.any() }) .delete('/game/:source/:id/install', async ({ params: { id, source } }) => diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/ensure-store.ts similarity index 60% rename from src/bun/api/jobs/update-store.ts rename to src/bun/api/jobs/ensure-store.ts index 697fb3a..bce028b 100644 --- a/src/bun/api/jobs/update-store.ts +++ b/src/bun/api/jobs/ensure-store.ts @@ -6,8 +6,9 @@ import { runBunPackageCommand } from "../plugins/services"; import { PluginRegistry } from "@/shared/constants"; import path from "node:path"; import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json'; +import { IsPluginAllowed } from "@/bun/utils"; -export default class UpdateStoreJob implements IJob +export default class EnsureStore implements IJob { static id = "update-store" as const; static dataSchema = z.never(); @@ -20,7 +21,7 @@ export default class UpdateStoreJob implements IJob this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0"; } - async start (context: JobContext) + async start (context: JobContext) { const storeFolder = getStoreRootFolder(); await ensureDir(storeFolder); @@ -32,17 +33,23 @@ export default class UpdateStoreJob implements IJob const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json(); - if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) + if (IsPluginAllowed(sdkPkg.name)) { - let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']); - console.log(response); - } + 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) + // 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 { - let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']); - console.log(response); + console.log("Ignoring SDK package"); } if (process.env.CUSTOM_STORE_PATH) return; diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index e7e20a2..5471e56 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -3,7 +3,7 @@ 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 { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue"; @@ -184,7 +184,7 @@ export const jobs = new Elysia({ prefix: '/api/jobs' }) .use(registerJob(LaunchGameJob)) .use(registerJob(LoginJob)) .use(registerJob(TwitchLoginJob)) - .use(registerJob(UpdateStoreJob)) + .use(registerJob(EnsureStore)) .use(registerJob(BiosDownloadJob)) .use(registerJob(InstallJob)) .use(registerJob(ReloadPluginsJob)) 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 index 118eb11..90d1cbc 100644 --- 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 @@ -2,14 +2,14 @@ import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-s import desc from './package.json'; import path, { } from 'node:path'; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; -import { Glob, pathToFileURL, which } from "bun"; +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 UpdateStoreJob from "@/bun/api/jobs/update-store"; +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"; @@ -20,7 +20,7 @@ import StreamZip from "node-stream-zip"; import { path7za } from "7zip-bin"; import Seven from 'node-7z'; -export default class RommIntegration implements PluginType +export default class StoreIntegration implements PluginType { eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }]; @@ -29,7 +29,7 @@ export default class RommIntegration implements PluginType switch (e) { case 'updateStore': - await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); return { reload: true }; } } @@ -38,7 +38,7 @@ export default class RommIntegration implements PluginType { console.log("Store Directory is ", getStoreFolder()); ctx.setProgress(0, "Updating Store"); - await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); } async load (ctx: PluginLoadingContextType) diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 1275740..a4b5666 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -14,10 +14,12 @@ import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.j import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk"; import path from 'node:path'; import { getStoreRootFolder } from "../store/services/gamesService"; -import { getUpdates } from "./services"; +import { getUpdates, runBunPackageCommand } from "./services"; import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared"; import { taskQueue } from "../app"; -import UpdateStoreJob from "../jobs/update-store"; +import EnsureStore from "../jobs/ensure-store"; +import { PluginRegistry } from "@/shared/constants"; +import { IsPluginAllowed } from "@/bun/utils"; type PluginEntry = PluginDescriptionType & { load: () => Promise; }; @@ -58,15 +60,9 @@ export async function unregisterPlugin (id: string, pluginManager: PluginManager export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager) { - if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(plugin.name)) + if (!IsPluginAllowed(plugin.name)) { - console.log("Skipping", plugin.name, "missing in whitelist"); - return; - } - - if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(plugin.name)) - { - console.log("Skipping", plugin.name, "found in whitelist"); + console.log("Skipping", plugin.name, "plugin not allowed"); return; } @@ -101,39 +97,52 @@ export default async function register (pluginManager: PluginManager) await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager))); - const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json'); - if (!await Bun.file(storePackageFilePath).exists()) + if (IsPluginAllowed('@simeonradivoev/gameflow-store')) { - console.log("Store is missing. Updating it."); - await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); - 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 => + const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json'); + if (!await Bun.file(storePackageFilePath).exists()) { - return getPlugin(p, pluginManager); - })); - - console.log("Checking for outdated packages"); - const outdated = await getUpdates(); - - const validPlugins = storePlugins.filter(p => !!p); - - if (outdated) - { - validPlugins.forEach(p => - { - const newVersion = outdated[p.name]; - if (newVersion) - { - console.log("Plugin", p.name, "has update", p.version, "=>", newVersion); - } - }); + 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(); - await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager))); + 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 index 9452b7e..0ba40b3 100644 --- a/src/bun/api/plugins/services.ts +++ b/src/bun/api/plugins/services.ts @@ -3,6 +3,7 @@ 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) { @@ -15,6 +16,7 @@ export function canDisable (description: PluginDescriptionType) 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; } diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 39d5630..7706699 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -188,16 +188,16 @@ export const store = new Elysia({ prefix: '/api/store' }) emulator.integrations = integrations; return emulator; }, { params: z.object({ id: z.string() }) }) - .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => + .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, { isUpdate }); + const job = new EmulatorDownloadJob(id, source, body); return taskQueue.enqueue(EmulatorDownloadJob.id, job); }, { - body: z.object({ isUpdate: z.boolean().optional() }) + body: z.object({ isUpdate: z.boolean().optional() }).optional() }) .delete('/emulator/:id', async ({ params: { id } }) => { diff --git a/src/bun/utils.ts b/src/bun/utils.ts index a3868e4..6fbc630 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -185,4 +185,19 @@ export function getAppVersion () 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/packages/gameflow-sdk/index.ts b/src/packages/gameflow-sdk/index.ts index 4149891..c78c757 100644 --- a/src/packages/gameflow-sdk/index.ts +++ b/src/packages/gameflow-sdk/index.ts @@ -41,7 +41,8 @@ export const PluginDescriptionSchema = z.object({ 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") + 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({ diff --git a/src/packages/gameflow-sdk/task-queue.ts b/src/packages/gameflow-sdk/task-queue.ts index 9ed555e..e86cebc 100644 --- a/src/packages/gameflow-sdk/task-queue.ts +++ b/src/packages/gameflow-sdk/task-queue.ts @@ -91,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) @@ -109,6 +114,30 @@ 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) diff --git a/src/tests/preload.ts b/src/tests/preload.ts index e9a17b9..40cf49d 100644 --- a/src/tests/preload.ts +++ b/src/tests/preload.ts @@ -1,18 +1,20 @@ 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 { 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 () => @@ -20,7 +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'; + 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 () From 641eb2fcd550129a61c3ead60b8e26092da291a2 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 15 May 2026 17:38:38 +0300 Subject: [PATCH 3/3] fix: Moved to manual plugin version checking and fixed some steam deck issues. --- bun.lock | 3 - package.json | 8 +- src/bun/api/auth.ts | 6 + .../com.simeonradivoev.gameflow.romm/romm.ts | 2 +- .../store.ts | 2 +- src/bun/api/plugins/plugin-manager.ts | 4 +- src/bun/api/plugins/register-plugins.ts | 23 ++- src/bun/api/plugins/services.ts | 8 +- src/bun/api/plugins/update-check.ts | 169 ++++++++++++++++++ .../routes/store/details.plugin.$id.tsx | 4 +- src/mainview/routes/store/tab/download.tsx | 9 +- src/mainview/scripts/queries/romm.ts | 9 +- src/packages/gameflow-sdk/build.ts | 0 13 files changed, 219 insertions(+), 28 deletions(-) create mode 100644 src/bun/api/plugins/update-check.ts mode change 100644 => 100755 src/packages/gameflow-sdk/build.ts diff --git a/bun.lock b/bun.lock index 5fbc781..f005808 100644 --- a/bun.lock +++ b/bun.lock @@ -25,7 +25,6 @@ "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", @@ -1436,8 +1435,6 @@ "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=="], diff --git a/package.json b/package.json index d770d67..913be66 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "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", @@ -90,6 +89,11 @@ "webview-bun": "^2.4.0", "zod": "^4.4.3" }, + "overrides": { + "@tanstack/router-generator": { + "zod": "^3.23.8" + } + }, "devDependencies": { "@ap0nia/eden": "^1.6.1", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", @@ -155,4 +159,4 @@ "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1" } -} +} \ No newline at end of file diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index 47ea019..2cd6e3f 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -138,6 +138,12 @@ export async function checkLoginAndRefreshTwitch () export async function checkLoginAndRefreshRomm () { + //TODO: move to plugin logic + if (plugins.plugins['com.simeonradivoev.gameflow.romm'].config?.get('clientApiToken')) + { + return { hasLogin: true }; + } + const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' }); if (!access_token) { 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 2e63269..2be6e68 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 @@ -44,7 +44,7 @@ export default class RommIntegration implements PluginType async getAccessToken (config: Conf) { if (process.env.ROMM_CLIENT_TOKEN) return process.env.ROMM_CLIENT_TOKEN; - const client_token = await config.get('clientApiToken'); + const client_token = config.get('clientApiToken'); if (client_token) return client_token; return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined; } 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 index 90d1cbc..92ce1f9 100644 --- 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 @@ -2,7 +2,7 @@ import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-s 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 { Glob, pathToFileURL, which } from "bun"; import { and, eq } from "drizzle-orm"; import * as emulatorSchema from '@schema/emulators'; diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts index 1fab907..8b2a1b5 100644 --- a/src/bun/api/plugins/plugin-manager.ts +++ b/src/bun/api/plugins/plugin-manager.ts @@ -91,7 +91,7 @@ export class PluginManager return true; } - private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }, update: string | undefined) + private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }, update: string | undefined | null) { const plugin = this.plugins[name]; if (plugin) @@ -149,7 +149,7 @@ export class PluginManager for await (const id of Object.keys(this.plugins)) { ctx.setProgress(0, `Loading ${id}`); - await this.reload(id, ctx, outdated?.[id]); + await this.reload(id, ctx, outdated.find(i => i.package === id)?.update); } } diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index a4b5666..5746947 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -122,19 +122,24 @@ export default async function register (pluginManager: PluginManager) if (outdated) { - for await (const plugin of validPlugins) + for (let i = 0; i < validPlugins.length; i++) { - const newVersion = outdated[plugin.name]; + const plugin = validPlugins[i]; + const newVersion = outdated.find(i => i.package === plugin.name); if (newVersion) { - console.log("Plugin", plugin.name, "has update", plugin.version, "=>", newVersion); - } + console.log("Plugin", plugin.name, "has update", plugin.version, "=>", newVersion.update); - 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); + if (plugin.autoUpdate || plugin.name === '@simeonradivoev/gameflow-store') + { + console.log("Auto Updating Plugin", plugin.name); + let response = await runBunPackageCommand(["add", `${plugin.name}@${newVersion?.update}`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + // Update plugin package + const newPlugin = await getPlugin(plugin.name, pluginManager); + if (newPlugin) + validPlugins[i] = newPlugin; + } } } } diff --git a/src/bun/api/plugins/services.ts b/src/bun/api/plugins/services.ts index 0ba40b3..8878809 100644 --- a/src/bun/api/plugins/services.ts +++ b/src/bun/api/plugins/services.ts @@ -2,8 +2,8 @@ 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'; +import { checkOutdated } from './update-check'; export function canDisable (description: PluginDescriptionType) { @@ -16,9 +16,9 @@ export function canDisable (description: PluginDescriptionType) 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; + if (!existsSync(getStoreRootFolder())) return []; + const results = await checkOutdated(getStoreRootFolder()); + return results; } export function canUninstall (description: PluginDescriptionType, source: string) diff --git a/src/bun/api/plugins/update-check.ts b/src/bun/api/plugins/update-check.ts new file mode 100644 index 0000000..66cf381 --- /dev/null +++ b/src/bun/api/plugins/update-check.ts @@ -0,0 +1,169 @@ +import { semver } from "bun"; +import { readFile } from "fs/promises"; +import { join } from "path"; +import { getOrCached } from "../cache"; +import { PluginRegistry } from "@/shared/constants"; +import sdkPkg from '@/packages/gameflow-sdk/package.json'; + +interface UpdateInfo +{ + package: string, + current: string, + update: string | null, + latest: string, + sdkConstrained: boolean, + sdkRange: string, + note: string | null; +} + +function parseBunOutdated (cwd: string) +{ + const proc = Bun.spawnSync([process.execPath, "outdated"], { + stderr: "inherit", env: { + BUN_BE_BUN: "1", + NO_COLOR: "1", + }, cwd: cwd + }); + const output = proc.stdout.toString(); + const lines = output.split("\n").filter(Boolean); + + const headerIndex = lines.findIndex( + (l) => l.includes("Package") && l.includes("Current") + ); + if (headerIndex === -1) return []; + + return lines + .slice(headerIndex + 1) + .filter((line) => !/^[-─╌| ]+$/.test(line)) + .map((line) => + { + const [, pkg, current, , latest] = line.split("|").map((c) => c.trim()); + return pkg ? { package: pkg, current, latest } : null; + }) + .filter(p => p !== null); +} + +async function getInstalledVersion (cwd: string, pkg: string) +{ + try + { + const raw = await readFile(join(cwd, "node_modules", pkg, "package.json"), "utf8"); + return JSON.parse(raw).version ?? null; + } catch + { + return null; + } +} + +async function fetchAllVersions (pkg: string) +{ + const res = await fetch(`${PluginRegistry}/${pkg}`); + if (!res.ok) return []; + const data = await res.json(); + return Object.keys(data.versions ?? {}); +} + +async function fetchPeerDeps (pkg: string, version: string) +{ + const peerDependencies = await getOrCached(`npm-${pkg}-${version}`, async () => + { + const res = await fetch(`${PluginRegistry}/${pkg}/${version}`); + if (!res.ok) + { + throw new Error(`Error while fetching peer deps for ${pkg} ${version} ${res.status} ${res.statusText}`); + } + const data = await res.json(); + return data.peerDependencies ?? {}; + }, { + //5 days + expireMs: 1000 * 60 * 60 * 24 * 5 + }); + + + return peerDependencies; +} + +async function findBestVersion (pkg: string, allVersions: string[], sdkVersion: string) +{ + // Sort descending so we find the highest compatible version first + const sorted = [...allVersions].sort((a, b) => semver.order(b, a)); + + for (const version of sorted) + { + const peers = await fetchPeerDeps(pkg, version); + const sdkRange = peers[sdkPkg.name]; + + if (!sdkRange) + { + // No peer dep on SDK — compatible by default + return { version, sdkRange: null }; + } + + if (semver.satisfies(sdkVersion, sdkRange)) + { + return { version, sdkRange }; + } + } + + return null; +} + +export async function checkOutdated (cwd: string) +{ + const outdated = parseBunOutdated(cwd); + + if (outdated.length === 0) + { + return []; + } + + const sdkVersion = await getInstalledVersion(cwd, sdkPkg.name); + if (!sdkVersion) + { + console.error(`Could not find installed version of ${sdkPkg.name} in node_modules.`); + process.exit(1); + } + + const results = await Promise.all( + outdated.map(async ({ package: pkg, current, latest }) => + { + const allVersions = await fetchAllVersions(pkg); + + // Check if the outright latest is already SDK compatible + const latestPeers = await fetchPeerDeps(pkg, latest); + const latestSdkRange = latestPeers[sdkPkg.name]; + + const latestCompatible = + !latestSdkRange || semver.satisfies(sdkVersion, latestSdkRange); + + if (latestCompatible) + { + return { + package: pkg, + current, + update: latest, + latest, + sdkConstrained: false, + sdkRange: latestSdkRange ?? null, + note: null + } satisfies UpdateInfo as UpdateInfo; + } + + const best = await findBestVersion(pkg, allVersions, sdkVersion); + + return { + package: pkg, + current, + update: best?.version ?? null, + latest, + sdkConstrained: true, + sdkRange: best?.sdkRange ?? null, + note: best + ? `Latest (${latest}) requires incompatible SDK range; best compatible: ${best.version}` + : `No version of ${pkg} is compatible with ${sdkPkg.name}@${sdkVersion}`, + } satisfies UpdateInfo as UpdateInfo; + }) + ); + + return results; +} \ No newline at end of file diff --git a/src/mainview/routes/store/details.plugin.$id.tsx b/src/mainview/routes/store/details.plugin.$id.tsx index 5e5168c..9e35cdb 100644 --- a/src/mainview/routes/store/details.plugin.$id.tsx +++ b/src/mainview/routes/store/details.plugin.$id.tsx @@ -112,10 +112,10 @@ function Details () {!!data.update && } - - diff --git a/src/mainview/routes/store/tab/download.tsx b/src/mainview/routes/store/tab/download.tsx index 062698a..a1f0172 100644 --- a/src/mainview/routes/store/tab/download.tsx +++ b/src/mainview/routes/store/tab/download.tsx @@ -1,5 +1,6 @@ import DotsLoading from '@/mainview/components/backgrounds/dots'; import LoadMoreButton from '@/mainview/components/LoadMoreButton'; +import { Button } from '@/mainview/components/options/Button'; import { SideDownloadFilters } from '@/mainview/components/SideFilters'; import { downloadLookupFiltersQuery, downloadsLookupQuery } from '@/mainview/scripts/queries/romm'; import { scrollIntoViewHandler } from '@/mainview/scripts/utils'; @@ -7,7 +8,7 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga 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 { ArrowRight, DownloadIcon, Eye, MessageCircle, Puzzle, Save, Star } from 'lucide-react'; import prettyBytes from 'pretty-bytes'; import { useSessionStorage } from 'usehooks-ts'; @@ -49,6 +50,7 @@ function Downloads (data: { pages: { data: DownloadLookupEntry[]; totalCount: number; + hadMatchers: boolean; nextPage: number; }[]; hasNextPage: boolean, @@ -58,12 +60,15 @@ function Downloads (data: { error: string | undefined; }) { + const navigate = useNavigate(); const { ref, focusKey } = useFocusable({ focusKey: 'downloads-list' }); return
          {data.pages.flatMap((page, p) => page.data.map((match, i) => ))} - {data.hasNextPage &&
      } + {data.hasNextPage && data.pages[0].hadMatchers &&