Compare commits

..

No commits in common. "master" and "v1.6.0" have entirely different histories.

83 changed files with 631 additions and 2257 deletions

1
.gitignore vendored
View file

@ -28,7 +28,6 @@ downloads
gameflow-deck.code-workspace gameflow-deck.code-workspace
.env.local .env.local
src/tests/mock-roms/db.sqlite src/tests/mock-roms/db.sqlite
src/tests/mock-roms/store
src/tests/mock-config src/tests/mock-config
bin bin
.config/flatpak/repo .config/flatpak/repo

101
bun.lock
View file

@ -25,12 +25,13 @@
"node-downloader-helper": "^2.1.11", "node-downloader-helper": "^2.1.11",
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"node-unrar-js": "^2.0.2", "node-unrar-js": "^2.0.2",
"npm-check-updates": "^22.1.1",
"open": "^11.0.0", "open": "^11.0.0",
"p-queue": "^9.2.0", "p-queue": "^9.2.0",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"slugify": "^1.6.9", "slugify": "^1.6.9",
"smol-toml": "^1.6.1", "smol-toml": "^1.6.1",
"systeminformation": "^5.31.6", "systeminformation": "^5.31.5",
"tapable": "^2.3.3", "tapable": "^2.3.3",
"tough-cookie": "^6.0.1", "tough-cookie": "^6.0.1",
"tough-cookie-file-store": "^3.3.0", "tough-cookie-file-store": "^3.3.0",
@ -45,11 +46,10 @@
"@hey-api/openapi-ts": "^0.91.1", "@hey-api/openapi-ts": "^0.91.1",
"@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@noriginmedia/norigin-spatial-navigation": "^3.1.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.2.4",
"@tanstack/react-form": "^1.32.0", "@tanstack/react-form": "^1.29.1",
"@tanstack/react-query": "^5.100.10", "@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.10", "@tanstack/react-query-devtools": "^5.100.9",
"@tanstack/react-query-persist-client": "^5.100.10",
"@tanstack/react-router": "^1.169.2", "@tanstack/react-router": "^1.169.2",
"@tanstack/react-router-devtools": "^1.166.13", "@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/react-router-ssr-query": "^1.166.12", "@tanstack/react-router-ssr-query": "^1.166.12",
@ -82,7 +82,6 @@
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"eden-tanstack-query": "^0.0.9", "eden-tanstack-query": "^0.0.9",
"howler": "^2.2.4", "howler": "^2.2.4",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0", "pretty-ms": "^9.3.0",
@ -93,26 +92,30 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-qr-code": "^2.0.21", "react-qr-code": "^2.0.21",
"sass-embedded": "^1.99.0", "sass-embedded": "^1.99.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.2.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"vite": "^7.3.3", "vite": "^7.3.3",
"vite-plugin-svg-icons-ng": "^1.9.1", "vite-plugin-svg-icons-ng": "^1.9.0",
"vite-static-assets-plugin": "^1.2.2", "vite-static-assets-plugin": "^1.2.2",
"vite-tsconfig-paths": "^6.1.1", "vite-tsconfig-paths": "^6.1.1",
}, },
}, },
"src/packages/gameflow-sdk": { "src/packages/gameflow-sdk": {
"name": "@simeonradivoev/gameflow-sdk", "name": "@simeonradivoev/gameflow-sdk",
"version": "1.6.0", "version": "1.5.3",
"bin": { "bin": {
"gameflow-build": "build.ts", "gameflow-build": "build.ts",
}, },
"peerDependencies": { "peerDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"@auth/core": "^0.34.3", "@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", "cheerio": "^1.2.0",
"conf": "^15.1.0", "conf": "^15.1.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
@ -132,8 +135,12 @@
"pathe": "^2.0.3", "pathe": "^2.0.3",
"slugify": "^1.6.9", "slugify": "^1.6.9",
"smol-toml": "^1.6.1", "smol-toml": "^1.6.1",
"systeminformation": "^5.31.5",
"tapable": "^2.3.3", "tapable": "^2.3.3",
"tough-cookie": "^6.0.1",
"tough-cookie-file-store": "^3.3.0",
"unzip-stream": "^0.3.4", "unzip-stream": "^0.3.4",
"webview-bun": "^2.4.0",
"zod": "^4.4.3", "zod": "^4.4.3",
}, },
}, },
@ -559,59 +566,55 @@
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@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/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/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": ["@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-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], "@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-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="],
"@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-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="],
"@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-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="],
"@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-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="],
"@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-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-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="],
"@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/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
"@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/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.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=="], "@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=="],
"@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], "@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.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/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/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], "@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/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.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="], "@tanstack/query-core": ["@tanstack/query-core@5.100.9", "", {}, "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ=="],
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.100.10", "", {}, "sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.100.9", "", {}, "sha512-gqiptrTIhbK2PuCaPRHmWXfJG1NGYVFpAr0HqogEqiSBNB5xDz6fmesQt7w4WgMOqOQPnPHJ3ZDMuhDaXvNO8g=="],
"@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-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/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": ["@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-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.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-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=="], "@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=="],
@ -655,7 +658,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], "@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.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@ -787,7 +790,7 @@
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@ -1155,8 +1158,6 @@
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "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=="], "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=="], "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
@ -1435,6 +1436,8 @@
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "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=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], "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=="], "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=="],
@ -1749,13 +1752,13 @@
"sync-message-port": ["sync-message-port@1.2.0", "", {}, "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg=="], "sync-message-port": ["sync-message-port@1.2.0", "", {}, "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg=="],
"systeminformation": ["systeminformation@5.31.6", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="], "systeminformation": ["systeminformation@5.31.5", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
@ -1863,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": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="],
"vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.9.1", "", { "dependencies": { "svg-icon-baker": "2.0.1", "tinyglobby": "^0.2.16" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-g00nlit2havo0VRxpLiPkeJfMYt0DL/RO8X5HHop72rbMEZB5H1Fk7qXLWbTIO2/PkwJ8zSq0+h28ItaE1YQHQ=="], "vite-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-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=="], "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=="],
@ -1995,15 +1998,13 @@
"@node-minify/core/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], "@node-minify/core/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],

View file

@ -76,12 +76,13 @@
"node-downloader-helper": "^2.1.11", "node-downloader-helper": "^2.1.11",
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"node-unrar-js": "^2.0.2", "node-unrar-js": "^2.0.2",
"npm-check-updates": "^22.1.1",
"open": "^11.0.0", "open": "^11.0.0",
"p-queue": "^9.2.0", "p-queue": "^9.2.0",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"slugify": "^1.6.9", "slugify": "^1.6.9",
"smol-toml": "^1.6.1", "smol-toml": "^1.6.1",
"systeminformation": "^5.31.6", "systeminformation": "^5.31.5",
"tapable": "^2.3.3", "tapable": "^2.3.3",
"tough-cookie": "^6.0.1", "tough-cookie": "^6.0.1",
"tough-cookie-file-store": "^3.3.0", "tough-cookie-file-store": "^3.3.0",
@ -89,11 +90,6 @@
"webview-bun": "^2.4.0", "webview-bun": "^2.4.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"overrides": {
"@tanstack/router-generator": {
"zod": "^3.23.8"
}
},
"devDependencies": { "devDependencies": {
"@ap0nia/eden": "^1.6.1", "@ap0nia/eden": "^1.6.1",
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
@ -101,11 +97,10 @@
"@hey-api/openapi-ts": "^0.91.1", "@hey-api/openapi-ts": "^0.91.1",
"@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@noriginmedia/norigin-spatial-navigation": "^3.1.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.2.4",
"@tanstack/react-form": "^1.32.0", "@tanstack/react-form": "^1.29.1",
"@tanstack/react-query": "^5.100.10", "@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.10", "@tanstack/react-query-devtools": "^5.100.9",
"@tanstack/react-query-persist-client": "^5.100.10",
"@tanstack/react-router": "^1.169.2", "@tanstack/react-router": "^1.169.2",
"@tanstack/react-router-devtools": "^1.166.13", "@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/react-router-ssr-query": "^1.166.12", "@tanstack/react-router-ssr-query": "^1.166.12",
@ -138,7 +133,6 @@
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"eden-tanstack-query": "^0.0.9", "eden-tanstack-query": "^0.0.9",
"howler": "^2.2.4", "howler": "^2.2.4",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0", "pretty-ms": "^9.3.0",
@ -149,13 +143,13 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-qr-code": "^2.0.21", "react-qr-code": "^2.0.21",
"sass-embedded": "^1.99.0", "sass-embedded": "^1.99.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.2.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"vite": "^7.3.3", "vite": "^7.3.3",
"vite-plugin-svg-icons-ng": "^1.9.1", "vite-plugin-svg-icons-ng": "^1.9.0",
"vite-static-assets-plugin": "^1.2.2", "vite-static-assets-plugin": "^1.2.2",
"vite-tsconfig-paths": "^6.1.1" "vite-tsconfig-paths": "^6.1.1"
} }

View file

@ -85,13 +85,6 @@ watch("./src/bun", { recursive: true }, (event, filename) =>
restart(); restart();
}); });
watch("./src/packages", { recursive: true }, (event, filename) =>
{
if (restarting) return;
console.log(`[watcher] ${event}: ${filename} — restarting...`);
restart();
});
let server: Bun.Subprocess | undefined = spawnServer(); let server: Bun.Subprocess | undefined = spawnServer();
if (!process.env.HEADLESS) if (!process.env.HEADLESS)
{ {

View file

@ -116,13 +116,6 @@ export async function cleanup ()
cleannedUp = true; cleannedUp = true;
} }
/** Reset the cleanup flags. This is mainly used by tests since they run the same app. */
export async function resetCleanup ()
{
cleaningUp = false;
cleannedUp = false;
}
export async function reloadDatabase () export async function reloadDatabase ()
{ {
await ensureDir(config.get('downloadPath')); await ensureDir(config.get('downloadPath'));

View file

@ -138,12 +138,6 @@ export async function checkLoginAndRefreshTwitch ()
export async function checkLoginAndRefreshRomm () 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' }); const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' });
if (!access_token) if (!access_token)
{ {

View file

@ -72,6 +72,7 @@ export class GamepadWindows implements IGamepadBackend
private index: number; private index: number;
private buffer = new ArrayBuffer(16); private buffer = new ArrayBuffer(16);
private view = new DataView(this.buffer); private view = new DataView(this.buffer);
private prevButtons = 0;
private currButtons = 0; private currButtons = 0;
constructor(index = 0) { this.index = index; } constructor(index = 0) { this.index = index; }

View file

@ -5,7 +5,7 @@ import z from "zod";
import * as schema from "@schema/app"; import * as schema from "@schema/app";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { SERVER_URL } from "@shared/constants"; import { SERVER_URL } from "@shared/constants";
import { CommandEntry, DownloadLookupEntry, DownloadsLookupFilterValues, GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared';
import { InstallJob } from "../jobs/install-job"; import { InstallJob } from "../jobs/install-job";
import path from "node:path"; import path from "node:path";
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
@ -454,18 +454,18 @@ export default new Elysia()
}, { }, {
params: z.object({ id: z.string(), source: z.string() }), params: z.object({ id: z.string(), source: z.string() }),
}) })
.post('/game/:source/:id/install', async ({ params: { id, source }, body }) => .post('/game/:source/:id/install', async ({ params: { id, source }, body: { downloadId } }) =>
{ {
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
{ {
return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, body)); return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, { downloadId }));
} else } else
{ {
return status('Not Implemented'); return status('Not Implemented');
} }
}, { }, {
params: z.object({ id: z.string(), source: z.string() }), params: z.object({ id: z.string(), source: z.string() }),
body: z.object({ downloadId: z.string().optional() }).optional(), body: z.object({ downloadId: z.string().optional() }),
response: z.any() response: z.any()
}) })
.delete('/game/:source/:id/install', async ({ params: { id, source } }) => .delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
@ -512,25 +512,7 @@ export default new Elysia()
await plugins.hooks.games.gameLookup.promise(matches, { source, id }); await plugins.hooks.games.gameLookup.promise(matches, { source, id });
return Array.from(matches.values()).flatMap(m => m); return Array.from(matches.values()).flatMap(m => m);
}) })
.get('/game/:source/:id/commands', async ({ params: { id, source }, set }) => .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
{
const validCommands = await getValidLaunchCommandsForGame(source, id);
if (validCommands instanceof Error)
{
return errorToResponse(validCommands, set);
}
return validCommands as {
commands: CommandEntry[];
gameId: FrontEndId;
source?: string;
sourceId?: string;
} | undefined;
}, {
response: z.object({
commands: z.custom<CommandEntry>().array()
})
})
.post('/game/:source/:id/play', async ({ params: { id, source }, body: { command_id }, set }) =>
{ {
const validCommands = await getValidLaunchCommandsForGame(source, id); const validCommands = await getValidLaunchCommandsForGame(source, id);
if (validCommands) if (validCommands)
@ -543,7 +525,7 @@ export default new Elysia()
{ {
try try
{ {
const validCommand = command_id ? validCommands.commands.find(c => c.id === command_id) : validCommands.commands[0]; const validCommand = body.command_id ? validCommands.commands.find(c => c.id === body.command_id) : validCommands.commands[0];
if (validCommand) if (validCommand)
{ {
// launch command waits for the game to exit, we don't want that. // launch command waits for the game to exit, we don't want that.
@ -694,10 +676,7 @@ export default new Elysia()
.post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) => .post('/add/custom', async ({ body: { source, id, platformId, gamePath } }) =>
{ {
if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running"); if (taskQueue.hasActiveOfType(ImportJob)) return status("Conflict", "Import Job Already Running");
const data = await taskQueue.enqueue(ImportJob.query({ source, id }), new ImportJob(source, id, gamePath, platformId), { const data = await taskQueue.enqueue(ImportJob.id, new ImportJob(source, id, gamePath, platformId), true);
throwOnCancel: true
});
return { source: 'local', id: data.localId }; return { source: 'local', id: data.localId };
}, { }, {
body: z.object({ body: z.object({
@ -706,41 +685,4 @@ export default new Elysia()
gamePath: z.string(), gamePath: z.string(),
platformId: z.number() platformId: z.number()
}) })
}).get('/downloads/lookup', async ({ query: { search, page, rows, orderBy, sortDirection, source } }) =>
{
const matches = new Map<string, { count: number, items: DownloadLookupEntry[]; }>();
await plugins.hooks.games.downloadsLookup.promise(matches, { search, page, rows, orderBy, sortDirection, source });
const allValues = Array.from(matches.values());
return { hadMatchers: matches.size > 0, matches: allValues.flatMap(m => m.items), totalCount: allValues.reduce((p, c) => p + c.count, 0) };
}, {
query: z.object({
search: z.string().optional(),
page: z.coerce.number().optional(),
rows: z.coerce.number().optional(),
orderBy: z.string().optional(),
sortDirection: z.literal(["desc", "asc"]).optional(),
source: z.string().optional()
})
}).get('/download/lookup/:source/:id', async ({ params: { source, id } }) =>
{
const match = await plugins.hooks.games.downloadLookup.promise({ source, id });
if (!match) return status("Not Found");
return match;
}).get('/download/file/info', async ({ query: { file_url } }) =>
{
const response = await fetch(file_url, { method: "HEAD" });
if (!response.ok) return status('Internal Server Error', response.statusText);
return { size: Number(response.headers.get('content-length')), content_type: response.headers.get('content-type') };
}, {
query: z.object({ file_url: z.url() })
}).get('/download/lookup/filters', async () =>
{
const filters: DownloadsLookupFilterValues = {
source: [],
orderBy: []
};
await plugins.hooks.games.downloadsLookupFilters.promise({ filters });
return filters;
}); });

View file

@ -8,7 +8,7 @@ import { RPC_URL } from "@shared/constants";
import { hashFile } from "@/bun/utils"; import { hashFile } from "@/bun/utils";
import { host } from "@/bun/utils/host"; import { host } from "@/bun/utils/host";
import * as emulatorSchema from "@schema/emulators"; import * as emulatorSchema from "@schema/emulators";
import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared"; import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared";
export async function calculateSize (installPath: string | null) export async function calculateSize (installPath: string | null)
{ {
@ -468,39 +468,3 @@ export async function createLocalGame (info: {
return id; return id;
} }
export async function downloadGame (ctx: {
downloads: DownloadFileEntry[],
auth?: string,
id: string,
abortSignal?: AbortSignal,
setProgress?: (progress: number, state: "download" | "extract", info: Partial<Omit<ProgressStats, 'progress'>>) => void,
extract_path?: string;
path_fs?: string;
}): Promise<string[] | undefined>
{
const downloadedFiles = await plugins.hooks.downloadFiles.promise({
id: ctx.id,
auth: ctx.auth,
files: ctx.downloads,
downloadPath: config.get('downloadPath'),
abortSignal: ctx.abortSignal,
updateProgress: (stats) => ctx.setProgress?.(stats.progress, 'download', stats)
});
if (!downloadedFiles)
{
return;
}
const finalFiles = await plugins.hooks.postDownloadFiles.promise({
files: downloadedFiles.files,
source: downloadedFiles.source,
extract_path: ctx.extract_path,
downloadPath: config.get('downloadPath'),
path_fs: ctx.path_fs
}) ?? downloadedFiles.files;
return finalFiles;
}

View file

@ -1,44 +1,35 @@
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import z from "zod";
import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import { config, plugins } from "../app"; import { config, plugins } from "../app";
import { simulateProgress } from "@/bun/utils"; import { simulateProgress } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader"; import { Downloader } from "@/bun/utils/downloader";
import path from 'node:path'; import path from 'node:path';
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService"; import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
interface BiosDownloadJobData extends DownloadJobData export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">
{
emulator: string;
}
export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
{ {
static id = "bios-download-job" as const; static id = "bios-download-job" as const;
static dataSchema = z.object({ emulator: z.string() });
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`; static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
group: string = "bios-download"; group: string = "bios-download";
data: BiosDownloadJobData; emulator: string;
dryRun: boolean; dryRun: boolean;
constructor(emulator: string, init?: { dryRun?: boolean; }) constructor(emulator: string, init?: { dryRun?: boolean; })
{ {
this.data = { this.emulator = emulator;
emulator,
name: "Download Emulator Bios"
};
this.dryRun = init?.dryRun ?? false; this.dryRun = init?.dryRun ?? false;
} }
async start (context: JobContext<IJob<BiosDownloadJobData, "download">, BiosDownloadJobData, "download">) async start (context: JobContext<IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">, z.infer<typeof BiosDownloadJob.dataSchema>, "download">)
{ {
const emulator = await getStoreEmulatorPackage(this.data.emulator); const emulator = await getStoreEmulatorPackage(this.emulator);
if (!emulator) throw new Error("Could Not Find 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 systems = await buildStoreFrontendEmulatorSystems(emulator);
const biosFolder = path.join(config.get('downloadPath'), "bios", this.data.emulator); const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
await ensureDir(biosFolder); await ensureDir(biosFolder);
const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.data.emulator, systems, biosFolder }); const files = await plugins.hooks.emulators.fetchBiosDownload.promise({ emulator: this.emulator, systems, biosFolder });
if (!files) throw new Error("Could not find source to download from"); if (!files) throw new Error("Could not find source to download from");
@ -54,12 +45,9 @@ export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
const downloader = new Downloader('bios-download', files.files, biosFolder, { const downloader = new Downloader('bios-download', files.files, biosFolder, {
signal: context.abortSignal, signal: context.abortSignal,
headers, headers,
onProgress: (stats) => onProgress (stats)
{ {
context.setProgress(stats.progress, "download"); context.setProgress(stats.progress, "download");
this.data.downloaded = stats.downloaded;
this.data.speed = stats.speed;
this.data.total = stats.total;
}, },
}); });
@ -69,6 +57,6 @@ export class BiosDownloadJob implements IJob<BiosDownloadJobData, "download">
exposeData () exposeData ()
{ {
return this.data; return { emulator: this.emulator };
} }
} }

View file

@ -1,13 +1,14 @@
import { DownloadJobData, EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared'; import { EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared';
import { getStoreEmulatorPackage } from "../store/services/gamesService"; import { getStoreEmulatorPackage } from "../store/services/gamesService";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import z from "zod";
import { config, plugins } from "../app"; import { config, plugins } from "../app";
import path from 'node:path'; import path from 'node:path';
import Seven from 'node-7z'; import Seven from 'node-7z';
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { Downloader } from "@/bun/utils/downloader"; import { Downloader } from "@/bun/utils/downloader";
import { ensureDir, move } from "fs-extra"; import { ensureDir, move } from "fs-extra";
import { isArchive, simulateProgress } from "@/bun/utils"; import { simulateProgress } from "@/bun/utils";
import { path7za } from "7zip-bin"; import { path7za } from "7zip-bin";
import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService"; import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService";
import { $ } from "bun"; import { $ } from "bun";
@ -15,40 +16,31 @@ import { EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared";
type EmulatorDownloadStates = "download" | "extract"; type EmulatorDownloadStates = "download" | "extract";
interface EmulatorDownloadJobData extends DownloadJobData export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>
{
emulator: string;
}
export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, EmulatorDownloadStates>
{ {
static id = "download-emulator" as const; static id = "download-emulator" as const;
static dataSchema = z.object({ emulator: z.string() });
emulator: string;
downloadSource: string; downloadSource: string;
emulatorPackage?: EmulatorPackageType; emulatorPackage?: EmulatorPackageType;
dryRun: boolean; dryRun: boolean;
isUpdate: boolean; isUpdate: boolean;
data: EmulatorDownloadJobData = {
name: "Download Emulator",
emulator: ""
};
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; }) constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; isUpdate?: boolean; })
{ {
this.data.emulator = emulator; this.emulator = emulator;
this.downloadSource = downloadSource; this.downloadSource = downloadSource;
this.dryRun = init?.dryRun ?? false; this.dryRun = init?.dryRun ?? false;
this.isUpdate = init?.isUpdate ?? false; this.isUpdate = init?.isUpdate ?? false;
} }
async start (context: JobContext<EmulatorDownloadJob, EmulatorDownloadJobData, EmulatorDownloadStates>) async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
{ {
this.emulatorPackage = await getStoreEmulatorPackage(this.data.emulator); this.emulatorPackage = await getStoreEmulatorPackage(this.emulator);
if (!this.emulatorPackage) throw new Error("Emulator not found"); 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 { url, info } = await getEmulatorDownload(this.emulatorPackage, this.downloadSource);
const emulatorsFolder = getEmulatorPath(this.data.emulator); const emulatorsFolder = getEmulatorPath(this.emulator);
if (this.dryRun) if (this.dryRun)
{ {
@ -57,33 +49,29 @@ export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, Emulat
} else } else
{ {
const tmpFolder = path.join(config.get("downloadPath"), ".tmp"); const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
const downloader = new Downloader(this.data.emulator, const downloader = new Downloader(this.emulator,
[{ url, file_name: path.basename(url.pathname), file_path: this.data.emulator }], [{ url, file_name: path.basename(url.pathname), file_path: this.emulator }],
tmpFolder, tmpFolder,
{ {
signal: context.abortSignal, signal: context.abortSignal,
onProgress: (stats) => onProgress (stats)
{ {
context.setProgress(stats.progress, 'download'); 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(); const destinationPaths = await downloader.start();
context.abortSignal.throwIfAborted();
if (destinationPaths) if (destinationPaths)
{ {
const archive = isArchive(destinationPaths[0]); const isArchive = destinationPaths[0].endsWith('.7z') || destinationPaths[0].endsWith('.zip') || destinationPaths[0].endsWith('.tar');
const isAppImage = destinationPaths[0].endsWith(".AppImage"); const isAppImage = destinationPaths[0].endsWith(".AppImage");
if (!archive && !isAppImage) if (!isArchive && !isAppImage)
{ {
throw new Error("Invalid Download Type"); throw new Error("Invalid Download Type");
} }
if (archive) if (isArchive)
{ {
if (destinationPaths[0]) if (destinationPaths[0])
{ {
@ -132,10 +120,10 @@ export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, Emulat
await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3)); await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3));
const execs: EmulatorSourceEntryType[] = []; const execs: EmulatorSourceEntryType[] = [];
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: this.data.emulator, sources: execs }); await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: this.emulator, sources: execs });
await plugins.hooks.emulators.emulatorPostInstall.promise({ await plugins.hooks.emulators.emulatorPostInstall.promise({
emulator: this.data.emulator, emulator: this.emulator,
emulatorPackage: this.emulatorPackage, emulatorPackage: this.emulatorPackage,
path: execs.find(e => e.type === 'store')?.binPath ?? emulatorsFolder, path: execs.find(e => e.type === 'store')?.binPath ?? emulatorsFolder,
info, info,
@ -148,7 +136,7 @@ export class EmulatorDownloadJob implements IJob<EmulatorDownloadJobData, Emulat
exposeData () exposeData ()
{ {
return this.data; return { emulator: this.emulator };
} }
} }

View file

@ -1,32 +1,21 @@
import { eq, inArray, or } from "drizzle-orm"; import { eq, or } from "drizzle-orm";
import { db, plugins } from "../app"; import { db, plugins } from "../app";
import { createLocalGame, downloadGame } from "../games/services/utils"; import { createLocalGame } from "../games/services/utils";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import * as schema from "@schema/app"; import * as schema from "@schema/app";
import { DownloadJobData, GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; import z from "zod";
import { isUrl } from "@/shared/utils"; import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared";
import { basename } from "node:path";
import path from 'node:path';
import { isArchive } from "@/bun/utils";
interface ImportJobData extends DownloadJobData export class ImportJob implements IJob<z.infer<typeof ImportJob.dataSchema>, string>
{
localId: number | null;
}
export class ImportJob implements IJob<ImportJobData, string>
{ {
static id = "import-job" as const; static id = "import-job" as const;
static query = (q: { source: string; id: string; }) => `${ImportJob.id}-${q.source}-${q.id}`; static dataSchema = z.object({ localId: z.number().nullable() });
data: ImportJobData = {
localId: null,
name: "Import Game"
};
group?: 'import-job'; group?: 'import-job';
gamePath: string; gamePath: string;
source: string; source: string;
id: string; id: string;
platformId: number; platformId: number;
localId: number | null = null;
constructor(source: string, id: string, gamePath: string, platformId: number) constructor(source: string, id: string, gamePath: string, platformId: number)
{ {
@ -36,20 +25,18 @@ export class ImportJob implements IJob<ImportJobData, string>
this.platformId = platformId; this.platformId = platformId;
} }
exposeData () exposeData (): z.infer<typeof ImportJob.dataSchema>
{ {
return this.data; return { localId: this.localId };
} }
async start (context: JobContext<IJob<ImportJobData, string>, ImportJobData, string>): Promise<any> async start (context: JobContext<IJob<z.infer<typeof ImportJob.dataSchema>, string>, z.infer<typeof ImportJob.dataSchema>, string>): Promise<any>
{ {
const matchesMap = new Map<string, GameLookup[]>(); const matchesMap = new Map<string, GameLookup[]>();
await plugins.hooks.games.gameLookup.promise(matchesMap, { source: this.source, id: this.id }); await plugins.hooks.games.gameLookup.promise(matchesMap, { source: this.source, id: this.id });
const matches = matchesMap.values().next().value; const matches = matchesMap.values().next().value;
if (!matches || matches.length <= 0) throw Error("Could not Find Game"); if (!matches || matches.length <= 0) throw Error("Could not Find Game");
const match = matches[0]; const match = matches[0];
this.data.name = match.name;
this.data.preview_url = match.coverUrl;
let cover: Buffer<ArrayBufferLike> | undefined = undefined; let cover: Buffer<ArrayBufferLike> | undefined = undefined;
let coverType: string | undefined = undefined; let coverType: string | undefined = undefined;
@ -63,56 +50,24 @@ export class ImportJob implements IJob<ImportJobData, string>
} }
} }
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[] = []; const localSearchFilters: any[] = [];
if (match.igdb_id) localSearchFilters.push(eq(schema.games.igdb_id, match.igdb_id)); 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)); if (match.slug) localSearchFilters.push(eq(schema.games.slug, match.slug));
localSearchFilters.push(eq(schema.games.name, match.name)); localSearchFilters.push(eq(schema.games.name, match.name));
localSearchFilters.push(inArray(schema.games.path_fs, finalFiles)); localSearchFilters.push(eq(schema.games.path_fs, this.gamePath));
const existingLocalGame = await db.query.games.findFirst({ where: or(...localSearchFilters) }); const existingLocalGame = await db.query.games.findFirst({ where: or(...localSearchFilters) });
context.abortSignal.throwIfAborted();
if (existingLocalGame) throw new Error("Game Already Exists"); if (existingLocalGame) throw new Error("Game Already Exists");
this.data.localId = await createLocalGame({ const platformMatch = match.platforms.find(p => p.id === this.platformId);
this.localId = await createLocalGame({
name: match.name, name: match.name,
system_slug: platformMatch?.slug, system_slug: platformMatch?.slug,
source: undefined, source: undefined,
source_id: undefined, source_id: undefined,
slug: match.slug, slug: match.slug,
path_fs: finalFiles[0], path_fs: this.gamePath,
summary: match.summary, summary: match.summary,
igdb_id: match.igdb_id, igdb_id: match.igdb_id,
ra_id: undefined, ra_id: undefined,

View file

@ -1,12 +1,17 @@
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { config, events, plugins } from "../app"; import { config, events, plugins } from "../app";
import { simulateProgress } from "@/bun/utils"; import { simulateProgress } from "@/bun/utils";
import { Downloader } from "@/bun/utils/downloader";
import Seven from 'node-7z';
import z from "zod"; import z from "zod";
import { checkFiles, createLocalGame, downloadGame } from "../games/services/utils"; import { checkFiles, createLocalGame } from "../games/services/utils";
import { ensureDir } from "fs-extra"; import { ensureDir, move } from "fs-extra";
import { DownloadInfo, DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared"; import { path7za } from "7zip-bin";
import StreamZip from 'node-stream-zip';
import { which } from "bun";
import { DownloadInfo } from "@simeonradivoev/gameflow-sdk/shared";
interface JobConfig interface JobConfig
{ {
@ -17,7 +22,7 @@ interface JobConfig
export type InstallJobStates = 'download' | 'extract'; export type InstallJobStates = 'download' | 'extract';
export class InstallJob implements IJob<DownloadJobData, InstallJobStates> export class InstallJob implements IJob<never, InstallJobStates>
{ {
static id = "install-job" as const; static id = "install-job" as const;
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`; static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
@ -29,9 +34,6 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
public localGameId?: number; public localGameId?: number;
public group = InstallJob.id; public group = InstallJob.id;
public localPath?: string; public localPath?: string;
data: DownloadJobData = {
name: "Install Game"
};
constructor(id: string, source: string, config?: JobConfig) constructor(id: string, source: string, config?: JobConfig)
{ {
@ -40,7 +42,7 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
this.source = source; this.source = source;
} }
public async start (cx: JobContext<InstallJob, DownloadJobData, InstallJobStates>) public async start (cx: JobContext<InstallJob, never, InstallJobStates>)
{ {
cx.setProgress(0, 'download'); cx.setProgress(0, 'download');
await fs.mkdir(config.get('downloadPath'), { recursive: true }); await fs.mkdir(config.get('downloadPath'), { recursive: true });
@ -56,31 +58,131 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
if (!info) throw new Error(`Could not find downloader for source ${this.source}`); if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
this.data.name = info.name;
this.data.preview_url = info.coverUrl;
const files = await checkFiles(info.files, !!info.extract_path); const files = await checkFiles(info.files, !!info.extract_path);
if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches)) if (this.config?.dryDownload !== true && files.some(f => !f.exists || !f.matches))
{ {
const downloadedFiles = await downloadGame({ const headers: Record<string, string> = {};
downloads: files.filter(f => !f.exists || !f.matches), if (info.auth)
extract_path: info.extract_path, headers['Authorization'] = info.auth;
path_fs: info.path_fs, const downloader = new Downloader(`game-${this.source}-${this.gameId}`,
abortSignal: cx.abortSignal, files.filter(f => !f.exists || !f.matches),
auth: info.auth, config.get('downloadPath'),
id: `game-${this.source}-${this.gameId}`,
setProgress: (process, state, info) =>
{ {
cx.setProgress(process, state); signal: cx.abortSignal,
this.data.downloaded = info.downloaded; headers,
this.data.speed = info.speed; onProgress (stats)
this.data.total = info.total; {
}, cx.setProgress(stats.progress, 'download');
}); },
});
if (downloadedFiles) 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
{
finalFiles.push(...downloadedFiles); finalFiles.push(...downloadedFiles);
}
} }
if (this.config?.dryDownload === true && info.extract_path) if (this.config?.dryDownload === true && info.extract_path)
@ -91,7 +193,7 @@ export class InstallJob implements IJob<DownloadJobData, InstallJobStates>
const coverResponse = await fetch(info.coverUrl); const coverResponse = await fetch(info.coverUrl);
const cover = Buffer.from(await coverResponse.arrayBuffer()); const cover = Buffer.from(await coverResponse.arrayBuffer());
cx.abortSignal.throwIfAborted(); if (cx.abortSignal.aborted) return;
this.localGameId = await createLocalGame({ this.localGameId = await createLocalGame({
cover, cover,

View file

@ -3,24 +3,22 @@ import z, { _ZodType } from "zod";
import { taskQueue } from "../app"; import { taskQueue } from "../app";
import { LoginJob } from "./login-job"; import { LoginJob } from "./login-job";
import TwitchLoginJob from "./twitch-login-job"; import TwitchLoginJob from "./twitch-login-job";
import EnsureStore from "./ensure-store"; import UpdateStoreJob from "./update-store";
import { EmulatorDownloadJob } from "./emulator-download-job"; import { EmulatorDownloadJob } from "./emulator-download-job";
import { getErrorMessage } from "@/bun/utils"; import { getErrorMessage } from "@/bun/utils";
import { BaseEvent, IJob } from "@simeonradivoev/gameflow-sdk/task-queue"; import { IJob } from "../../../packages/gameflow-sdk/task-queue";
import { LaunchGameJob } from "./launch-game-job"; import { LaunchGameJob } from "./launch-game-job";
import { BiosDownloadJob } from "./bios-download-job"; import { BiosDownloadJob } from "./bios-download-job";
import { InstallJob } from "./install-job"; import { InstallJob } from "./install-job";
import ReloadPluginsJob from "./reload-plugins-job"; import ReloadPluginsJob from "./reload-plugins-job";
import { FrontEndJob } from "@simeonradivoev/gameflow-sdk/shared";
function registerJob< function registerJob<
const Path extends string, const Path extends string,
Schema, const Schema extends z.ZodTypeAny,
const Query extends z.ZodTypeAny,
const States extends string, const States extends string,
> (_job: { T extends IJob<z.infer<Schema>, States>
id: Path; > (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T))
query?: (q: any) => string;
} & (new (...args: any[]) => IJob<Schema, States>))
{ {
return new Elysia().ws(_job.id, { return new Elysia().ws(_job.id, {
body: z.discriminatedUnion('type', [ body: z.discriminatedUnion('type', [
@ -32,9 +30,9 @@ function registerJob<
type: z.literal(['data', 'started', 'progress']), type: z.literal(['data', 'started', 'progress']),
state: z.string().optional(), state: z.string().optional(),
progress: z.number(), progress: z.number(),
data: z.custom<Schema>() data: _job.dataSchema
}), }),
z.object({ type: z.literal(['completed', 'ended']), data: z.custom<Schema>() }), z.object({ type: z.literal(['completed', 'ended']), data: _job.dataSchema }),
z.object({ type: z.literal('waiting') }), z.object({ type: z.literal('waiting') }),
z.object({ type: z.literal('error'), error: z.string() }) z.object({ type: z.literal('error'), error: z.string() })
]), ]),
@ -44,7 +42,7 @@ function registerJob<
const job = taskQueue.findJob(jobId, _job); const job = taskQueue.findJob(jobId, _job);
if (job) if (job)
{ {
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() as Schema }); ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
} else } else
{ {
ws.send({ type: 'waiting' }); ws.send({ type: 'waiting' });
@ -104,87 +102,10 @@ function registerJob<
} }
export const jobs = new Elysia({ prefix: '/api/jobs' }) export const jobs = new Elysia({ prefix: '/api/jobs' })
.ws('/list', {
response: z.discriminatedUnion('type', [
z.object({ type: z.literal("allJobs"), active: z.custom<FrontEndJob>().array(), queued: z.custom<FrontEndJob>().array() }),
z.object({ type: z.literal("started"), job: z.custom<FrontEndJob>() }),
z.object({ type: z.literal("progress"), job: z.custom<FrontEndJob>() }),
z.object({ type: z.literal("queued"), job: z.custom<FrontEndJob>() }),
z.object({ type: z.literal("aborted"), id: z.string() }),
z.object({ type: z.literal("ended"), id: z.string() }),
]),
body: z.discriminatedUnion('type', [
z.object({ type: z.literal("cancel"), id: z.string() })
]),
message (ws, message)
{
switch (message.type)
{
case "cancel":
taskQueue.cancelJob(message.id);
break;
}
},
open (ws)
{
ws.send({
type: 'allJobs',
active: taskQueue.getActiveJobs().map(j =>
{
const job: FrontEndJob = {
id: j.id,
data: j.job.exposeData?.(),
progress: j.progress,
state: j.state,
status: j.status
};
return job;
}),
queued: taskQueue.getQueuedJobs()?.map(j =>
{
const job: FrontEndJob = {
id: j.id,
data: j.job.exposeData?.(),
progress: j.progress,
state: j.state,
status: j.status
};
return job;
}) ?? []
});
(ws.data as any).dispose = [taskQueue.on('started', (e: BaseEvent) =>
{
ws.send({ type: "started", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
}),
taskQueue.on('progress', (e: BaseEvent) =>
{
ws.send({ type: "progress", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
}),
taskQueue.on('queued', (e: BaseEvent) =>
{
ws.send({ type: "queued", job: { id: e.id, data: e.job.job.exposeData?.(), progress: e.job.progress, state: e.job.state, status: e.job.status } });
}),
taskQueue.on('abort', (e: BaseEvent) =>
{
ws.send({ type: "aborted", id: e.id });
}),
taskQueue.on('ended', (e: BaseEvent) =>
{
ws.send({ type: "ended", id: e.id });
})];
},
close (ws, code, reason)
{
(ws.data as any).dispose.forEach((d: any) => d());
},
})
.use(registerJob(LaunchGameJob)) .use(registerJob(LaunchGameJob))
.use(registerJob(LoginJob)) .use(registerJob(LoginJob))
.use(registerJob(TwitchLoginJob)) .use(registerJob(TwitchLoginJob))
.use(registerJob(EnsureStore)) .use(registerJob(UpdateStoreJob))
.use(registerJob(BiosDownloadJob)) .use(registerJob(BiosDownloadJob))
.use(registerJob(InstallJob)) .use(registerJob(InstallJob))
.use(registerJob(ReloadPluginsJob)) .use(registerJob(ReloadPluginsJob))

View file

@ -1,5 +1,5 @@
import z from "zod"; import z from "zod";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk"; import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk";
import { config, db, events, plugins } from "../app"; import { config, db, events, plugins } from "../app";
import * as appSchema from "@schema/app"; import * as appSchema from "@schema/app";

View file

@ -1,5 +1,5 @@
import Elysia, { status } from "elysia"; import Elysia, { status } from "elysia";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue"; import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue";
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants"; import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
import { host, localIp } from "@/bun/utils/host"; import { host, localIp } from "@/bun/utils/host";
import cors from "@elysiajs/cors"; import cors from "@elysiajs/cors";

View file

@ -1,30 +0,0 @@
import { DownloadJobData } from "@simeonradivoev/gameflow-sdk/shared";
import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk/task-queue";
import { sleep } from "bun";
export class TestDownloadJob implements IJob<DownloadJobData, string>
{
data: DownloadJobData = {
speed: 1686,
downloaded: 0,
total: 6615841,
name: "Test Download Job"
};
group = "test-download";
async start (context: JobContext<IJob<DownloadJobData, string>, DownloadJobData, string>): Promise<any>
{
for (let i = 0; i < 10; i++)
{
await sleep(1000);
context.setProgress(i / 10 * 100, 'download');
if (context.abortSignal.aborted) return;
}
}
exposeData (): DownloadJobData
{
return this.data;
}
}

View file

@ -6,9 +6,8 @@ import { runBunPackageCommand } from "../plugins/services";
import { PluginRegistry } from "@/shared/constants"; import { PluginRegistry } from "@/shared/constants";
import path from "node:path"; import path from "node:path";
import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json'; import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json';
import { IsPluginAllowed } from "@/bun/utils";
export default class EnsureStore implements IJob<never, string> export default class UpdateStoreJob implements IJob<never, string>
{ {
static id = "update-store" as const; static id = "update-store" as const;
static dataSchema = z.never(); static dataSchema = z.never();
@ -21,7 +20,7 @@ export default class EnsureStore implements IJob<never, string>
this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0"; this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0";
} }
async start (context: JobContext<EnsureStore, never, string>) async start (context: JobContext<UpdateStoreJob, never, string>)
{ {
const storeFolder = getStoreRootFolder(); const storeFolder = getStoreRootFolder();
await ensureDir(storeFolder); await ensureDir(storeFolder);
@ -33,23 +32,17 @@ export default class EnsureStore implements IJob<never, string>
const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json(); const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json();
if (IsPluginAllowed(sdkPkg.name)) if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
{ {
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);
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 // probably just means we couldn't find a version of the sdk, just install latest
if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version)
{
let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
}
} else
{ {
console.log("Ignoring SDK package"); let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']);
console.log(response);
} }
if (process.env.CUSTOM_STORE_PATH) return; if (process.env.CUSTOM_STORE_PATH) return;

View file

@ -6,7 +6,7 @@ import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiC
import { config, events } from "@/bun/api/app"; import { config, events } from "@/bun/api/app";
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { hashFile, isArchive, isSteamDeckGameMode } from "@/bun/utils"; import { hashFile, isSteamDeckGameMode } from "@/bun/utils";
import { CACHE_KEYS, getOrCached } from "@/bun/api/cache"; import { CACHE_KEYS, getOrCached } from "@/bun/api/cache";
import secrets from "@/bun/api/secrets"; import secrets from "@/bun/api/secrets";
import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { getAuthToken } from "@/clients/romm/core/auth.gen";
@ -44,7 +44,7 @@ export default class RommIntegration implements PluginType<SettingsType>
async getAccessToken (config: Conf<SettingsType>) async getAccessToken (config: Conf<SettingsType>)
{ {
if (process.env.ROMM_CLIENT_TOKEN) return process.env.ROMM_CLIENT_TOKEN; if (process.env.ROMM_CLIENT_TOKEN) return process.env.ROMM_CLIENT_TOKEN;
const client_token = config.get('clientApiToken'); const client_token = await config.get('clientApiToken');
if (client_token) return client_token; if (client_token) return client_token;
return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined; return (await secrets.get({ service: 'gameflow', name: 'romm_access_token' })) ?? undefined;
} }
@ -254,7 +254,8 @@ export default class RommIntegration implements PluginType<SettingsType>
let path_fs = path.join(rom.fs_path, rom.fs_name); let path_fs = path.join(rom.fs_path, rom.fs_name);
if (files.length === 1) if (files.length === 1)
{ {
if (isArchive(files[0].file_name)) const name = files[0].file_name.toLocaleLowerCase();
if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar'))
{ {
extract_path = '.'; extract_path = '.';
path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext); path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext);

View file

@ -12,7 +12,6 @@ import mustache from "mustache";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import fs from "node:fs/promises"; 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 { 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; }) export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; })
{ {
@ -40,7 +39,7 @@ export async function getStoreGame (id: string)
function convertStoreMediaToPath (c: string) function convertStoreMediaToPath (c: string)
{ {
if (isUrl(c)) if (c.startsWith('http'))
{ {
return `/api/romm/image?url=${encodeURIComponent(c)}`; return `/api/romm/image?url=${encodeURIComponent(c)}`;
} else } else

View file

@ -2,25 +2,19 @@ import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-s
import desc from './package.json'; import desc from './package.json';
import path, { } from 'node:path'; import path, { } from 'node:path';
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
import { Glob, pathToFileURL, which } from "bun"; import { Glob, pathToFileURL } from "bun";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import * as emulatorSchema from '@schema/emulators'; import * as emulatorSchema from '@schema/emulators';
import { config, emulatorsDb, taskQueue } from "@/bun/api/app"; import { config, emulatorsDb, taskQueue } from "@/bun/api/app";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
import EnsureStore from "@/bun/api/jobs/ensure-store"; import UpdateStoreJob from "@/bun/api/jobs/update-store";
import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService";
import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared"; import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared";
import { isUrl } from "@/shared/utils";
import { Downloader } from "@/bun/utils/downloader";
import { ensureDir, move } from "fs-extra";
import StreamZip from "node-stream-zip";
import { path7za } from "7zip-bin";
import Seven from 'node-7z';
export default class StoreIntegration implements PluginType export default class RommIntegration implements PluginType
{ {
eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }]; eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }];
@ -29,7 +23,7 @@ export default class StoreIntegration implements PluginType
switch (e) switch (e)
{ {
case 'updateStore': case 'updateStore':
await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
return { reload: true }; return { reload: true };
} }
} }
@ -38,7 +32,7 @@ export default class StoreIntegration implements PluginType
{ {
console.log("Store Directory is ", getStoreFolder()); console.log("Store Directory is ", getStoreFolder());
ctx.setProgress(0, "Updating Store"); ctx.setProgress(0, "Updating Store");
await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
} }
async load (ctx: PluginLoadingContextType) async load (ctx: PluginLoadingContextType)
@ -301,7 +295,7 @@ export default class StoreIntegration implements PluginType
const info: DownloadInfo = { const info: DownloadInfo = {
id: validDownload.id, id: validDownload.id,
coverUrl: game.covers?.[0] ? isUrl(game.covers[0]) ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "",
screenshotUrls: game.screenshots ?? [], screenshotUrls: game.screenshots ?? [],
files: [{ files: [{
url: new URL(validDownload.url), url: new URL(validDownload.url),
@ -331,129 +325,5 @@ export default class StoreIntegration implements PluginType
return info; return info;
}); });
}); });
ctx.hooks.downloadFiles.tapPromise(desc.name, async ({ id, files, downloadPath, abortSignal, auth, updateProgress }) =>
{
const headers: Record<string, string> = {};
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];
}
});
} }
} }

View file

@ -91,7 +91,7 @@ export class PluginManager
return true; return true;
} }
private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }, update: string | undefined | null) private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }, update: string | undefined)
{ {
const plugin = this.plugins[name]; const plugin = this.plugins[name];
if (plugin) if (plugin)
@ -149,7 +149,7 @@ export class PluginManager
for await (const id of Object.keys(this.plugins)) for await (const id of Object.keys(this.plugins))
{ {
ctx.setProgress(0, `Loading ${id}`); ctx.setProgress(0, `Loading ${id}`);
await this.reload(id, ctx, outdated.find(i => i.package === id)?.update); await this.reload(id, ctx, outdated?.[id]);
} }
} }

View file

@ -14,12 +14,10 @@ import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.j
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk"; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk";
import path from 'node:path'; import path from 'node:path';
import { getStoreRootFolder } from "../store/services/gamesService"; import { getStoreRootFolder } from "../store/services/gamesService";
import { getUpdates, runBunPackageCommand } from "./services"; import { getUpdates } from "./services";
import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared"; import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared";
import { taskQueue } from "../app"; import { taskQueue } from "../app";
import EnsureStore from "../jobs/ensure-store"; import UpdateStoreJob from "../jobs/update-store";
import { PluginRegistry } from "@/shared/constants";
import { IsPluginAllowed } from "@/bun/utils";
type PluginEntry = PluginDescriptionType & { load: () => Promise<any>; }; type PluginEntry = PluginDescriptionType & { load: () => Promise<any>; };
@ -60,9 +58,15 @@ export async function unregisterPlugin (id: string, pluginManager: PluginManager
export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager) export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager)
{ {
if (!IsPluginAllowed(plugin.name)) if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(plugin.name))
{ {
console.log("Skipping", plugin.name, "plugin not allowed"); 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");
return; return;
} }
@ -97,57 +101,39 @@ export default async function register (pluginManager: PluginManager)
await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager))); await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager)));
if (IsPluginAllowed('@simeonradivoev/gameflow-store')) const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json');
if (!await Bun.file(storePackageFilePath).exists())
{ {
const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json'); console.log("Store is missing. Updating it.");
if (!await Bun.file(storePackageFilePath).exists()) 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 =>
{ {
console.log("Store is missing. Updating it."); return getPlugin(p, pluginManager);
await taskQueue.enqueue(EnsureStore.id, new EnsureStore()); }));
console.log("Store Updated");
}
const storePackage = await Bun.file(storePackageFilePath).json();
if (storePackage?.dependencies) console.log("Checking for outdated packages");
const outdated = await getUpdates();
const validPlugins = storePlugins.filter(p => !!p);
if (outdated)
{ {
const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).filter(p => !blacklist.has(p)).map(async p => validPlugins.forEach(p =>
{ {
return getPlugin(p, pluginManager); const newVersion = outdated[p.name];
})); if (newVersion)
console.log("Checking for outdated packages");
const outdated = await getUpdates();
const validPlugins = storePlugins.filter(p => !!p);
if (outdated)
{
for (let i = 0; i < validPlugins.length; i++)
{ {
const plugin = validPlugins[i]; console.log("Plugin", p.name, "has update", p.version, "=>", newVersion);
const newVersion = outdated.find(i => i.package === plugin.name);
if (newVersion)
{
console.log("Plugin", plugin.name, "has update", plugin.version, "=>", newVersion.update);
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;
}
}
} }
} });
await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager)));
} }
} else
{ await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager)));
console.log('Skipping Store Packages');
} }
} }

View file

@ -2,8 +2,7 @@ import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { getStoreRootFolder } from '../store/services/gamesService'; import { getStoreRootFolder } from '../store/services/gamesService';
import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk'; import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk';
import { existsSync } from 'node:fs'; import { run } from 'npm-check-updates';
import { checkOutdated } from './update-check';
export function canDisable (description: PluginDescriptionType) export function canDisable (description: PluginDescriptionType)
{ {
@ -16,9 +15,8 @@ export function canDisable (description: PluginDescriptionType)
export async function getUpdates () export async function getUpdates ()
{ {
if (!existsSync(getStoreRootFolder())) return []; const updated = await run({ packageManager: 'bun', peer: true, cwd: getStoreRootFolder(), jsonUpgraded: true, reject: ['@simeonradivoev/gameflow-sdk'] });
const results = await checkOutdated(getStoreRootFolder()); return updated as Record<string, string>;
return results;
} }
export function canUninstall (description: PluginDescriptionType, source: string) export function canUninstall (description: PluginDescriptionType, source: string)

View file

@ -1,169 +0,0 @@
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;
}

View file

@ -10,8 +10,6 @@ import { getRelevantEmulators } from "./services";
import type { JSONSchema7 } from "json-schema"; import type { JSONSchema7 } from "json-schema";
import ReloadPluginsJob from "../jobs/reload-plugins-job"; import ReloadPluginsJob from "../jobs/reload-plugins-job";
import { pluginZodRegistry } from "../plugins/plugin-manager"; 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' }) export const settings = new Elysia({ prefix: '/api/settings' })
.get('/emulators/automatic', async () => .get('/emulators/automatic', async () =>
@ -114,10 +112,6 @@ export const settings = new Elysia({ prefix: '/api/settings' })
{ {
return { value: plugins.plugins[decodeURIComponent(source)].config?.get(decodeURIComponent(id)) }; return { value: plugins.plugins[decodeURIComponent(source)].config?.get(decodeURIComponent(id)) };
}) })
.post('/test/download', async () =>
{
taskQueue.enqueue(randomUUIDv7(), new TestDownloadJob());
})
.put('/:source/:id', async ({ params: { source, id }, body: { value } }) => .put('/:source/:id', async ({ params: { source, id }, body: { value } }) =>
{ {
const plugin = plugins.plugins[decodeURIComponent(source)]; const plugin = plugins.plugins[decodeURIComponent(source)];

View file

@ -188,16 +188,16 @@ export const store = new Elysia({ prefix: '/api/store' })
emulator.integrations = integrations; emulator.integrations = integrations;
return emulator; return emulator;
}, { params: z.object({ id: z.string() }) }) }, { params: z.object({ id: z.string() }) })
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body }) => .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) =>
{ {
if (taskQueue.hasActiveOfType(EmulatorDownloadJob)) if (taskQueue.hasActiveOfType(EmulatorDownloadJob))
{ {
return status("Conflict", "Installation already running"); return status("Conflict", "Installation already running");
} }
const job = new EmulatorDownloadJob(id, source, body); const job = new EmulatorDownloadJob(id, source, { isUpdate });
return taskQueue.enqueue(EmulatorDownloadJob.id, job); return taskQueue.enqueue(EmulatorDownloadJob.id, job);
}, { }, {
body: z.object({ isUpdate: z.boolean().optional() }).optional() body: z.object({ isUpdate: z.boolean().optional() })
}) })
.delete('/emulator/:id', async ({ params: { id } }) => .delete('/emulator/:id', async ({ params: { id } }) =>
{ {

View file

@ -86,7 +86,6 @@ export const system = new Elysia({ prefix: '/api/system' })
z.object({ type: z.literal('info'), data: SystemInfoSchema }), z.object({ type: z.literal('info'), data: SystemInfoSchema }),
z.object({ type: z.literal('focus') }), z.object({ type: z.literal('focus') }),
z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }), z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }),
z.object({ type: z.literal('activeTask'), progress: z.number().nullable() }),
z.object({ type: z.literal('loaded') }), z.object({ type: z.literal('loaded') }),
]), ]),
async open (ws) async open (ws)
@ -95,8 +94,6 @@ export const system = new Elysia({ prefix: '/api/system' })
if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state }); if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state });
else ws.send({ type: 'loaded' }); else ws.send({ type: 'loaded' });
ws.send({ type: 'activeTask', progress: taskQueue.getActiveJobs()[0]?.progress });
const startInfo = async () => const startInfo = async () =>
{ {
const battery = await si.battery(); const battery = await si.battery();
@ -119,8 +116,6 @@ export const system = new Elysia({ prefix: '/api/system' })
dispose.push(taskQueue.on('progress', e => dispose.push(taskQueue.on('progress', e =>
{ {
ws.send({ type: 'activeTask', progress: e.progress });
if (e.id === ReloadPluginsJob.id) if (e.id === ReloadPluginsJob.id)
{ {
ws.send({ type: "loading", progress: e.progress, state: e.state }); ws.send({ type: "loading", progress: e.progress, state: e.state });
@ -132,8 +127,6 @@ export const system = new Elysia({ prefix: '/api/system' })
})); }));
dispose.push(taskQueue.on('started', e => dispose.push(taskQueue.on('started', e =>
{ {
ws.send({ type: 'activeTask', progress: 0 });
if (e.id === ReloadPluginsJob.id) if (e.id === ReloadPluginsJob.id)
ws.send({ type: "loading", progress: e.job.progress, state: e.job.state }); ws.send({ type: "loading", progress: e.job.progress, state: e.job.state });
else if (e.id === SelfUpdateJob.id) else if (e.id === SelfUpdateJob.id)
@ -141,7 +134,6 @@ export const system = new Elysia({ prefix: '/api/system' })
})); }));
dispose.push(taskQueue.on('ended', e => dispose.push(taskQueue.on('ended', e =>
{ {
ws.send({ type: 'activeTask', progress: null });
if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return; if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return;
ws.send({ type: "loaded" }); ws.send({ type: "loaded" });
})); }));

View file

@ -5,8 +5,6 @@ import { config } from './api/app';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import packageDef from '~/package.json'; import packageDef from '~/package.json';
const archiveRegex = /.(zip|rar|7zip|7z|tar|tar.gz)$/i;
export function checkRunning (pid: number) export function checkRunning (pid: number)
{ {
try try
@ -181,23 +179,3 @@ export function getAppVersion ()
{ {
return process.env.VERSION_OVERRIDE ?? packageDef.version; return process.env.VERSION_OVERRIDE ?? packageDef.version;
} }
export function isArchive (path: string)
{
return archiveRegex.test(path);
}
export function IsPluginAllowed (id: string)
{
if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(id))
{
return false;
}
if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(id))
{
return false;
}
return true;
}

View file

@ -5,7 +5,12 @@ import fs from 'node:fs/promises';
import { createWriteStream } from "node:fs"; import { createWriteStream } from "node:fs";
import { config, jar } from "../api/app"; import { config, jar } from "../api/app";
import { moveAllFiles } from "../utils"; import { moveAllFiles } from "../utils";
import { DownloadFileEntry, ProgressStats } from "@simeonradivoev/gameflow-sdk/shared"; import { DownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared";
export interface ProgressStats
{
progress: number;
}
interface TmpDownloadMetadata interface TmpDownloadMetadata
{ {
@ -27,7 +32,6 @@ export class Downloader
id: string; id: string;
tmpPath: string; tmpPath: string;
tmpPathMeta: string; tmpPathMeta: string;
downloadSpeed: number = 0;
/** /**
* *
@ -159,7 +163,10 @@ export class Downloader
}); });
const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0; const totalBytes = totalSize || Number(res.headers.get("content-length")) || 0;
bytesReceived += start; if (totalSize <= 0)
bytesReceived = 0;
else
bytesReceived += start;
const reader = res.body!.getReader(); const reader = res.body!.getReader();
@ -174,11 +181,10 @@ export class Downloader
if (totalBytes > 0 && this.onProgress) if (totalBytes > 0 && this.onProgress)
{ {
const percent = (bytesReceived / totalBytes) * 100; const percent = (bytesReceived / totalBytes) * 100;
const timeDelta = Date.now() - lastUpdate;
if (timeDelta > 100) if (Date.now() - lastUpdate > 100)
{ {
this.downloadSpeed = this.downloadSpeed * 0.8 + Math.round(value.length / (timeDelta / 1000)) * 0.2; this.onProgress({ progress: percent });
this.onProgress({ progress: percent, downloaded: bytesReceived, total: totalBytes, speed: this.downloadSpeed });
lastUpdate = Date.now(); lastUpdate = Date.now();
} }
} }
@ -188,7 +194,7 @@ export class Downloader
if (this.signal.reason === 'cancel') if (this.signal.reason === 'cancel')
{ {
console.log("Canceling Download and cleaning up files"); console.log("Canceling Download and cleaning up files");
await fs.rm(this.tmpPath, { recursive: true, maxRetries: 3, retryDelay: 3 }); await fs.rm(this.tmpPath, { recursive: true });
await fs.rm(this.tmpPathMeta); await fs.rm(this.tmpPathMeta);
return; return;
} }

View file

@ -1,14 +1,13 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AppContext, SystemInfoContext } from "../scripts/contexts"; import { SystemInfoContext } from "../scripts/contexts";
import { systemApi } from "../scripts/clientApi"; import { systemApi } from "../scripts/clientApi";
import { AppInfoContext, SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared'; import { SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared';
import LoadingScreen from "./LoadingScreen"; import LoadingScreen from "./LoadingScreen";
import { GamepadKeyboard } from "./GamepadKeyboard"; import { GamepadKeyboard } from "./GamepadKeyboard";
export default function AppCommunication (data: { children: any; }) export default function AppCommunication (data: { children: any; })
{ {
const [systemInfo, setSystemInfo] = useState<SystemInfoType | undefined>(); const [systemInfo, setSystemInfo] = useState<SystemInfoType | undefined>();
const [appContext, setAppContext] = useState<AppInfoContext>({} as AppInfoContext);
const [loadingInfo, setLoadingInfo] = useState<string | undefined>(undefined); const [loadingInfo, setLoadingInfo] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const loadingProgressBarRef = useRef<HTMLProgressElement>(null); const loadingProgressBarRef = useRef<HTMLProgressElement>(null);
@ -26,9 +25,6 @@ export default function AppCommunication (data: { children: any; })
case "focus": case "focus":
window.focus(); window.focus();
break; break;
case "activeTask":
setAppContext(c => ({ ...c, activeTaskProgress: data.progress }));
break;
case "loading": case "loading":
setLoadingInfo(data.state); setLoadingInfo(data.state);
if (loadingProgressBarRef.current) if (loadingProgressBarRef.current)
@ -49,19 +45,17 @@ export default function AppCommunication (data: { children: any; })
}, []); }, []);
return <SystemInfoContext value={systemInfo}> return <SystemInfoContext value={systemInfo}>
<AppContext value={appContext}> {loading ?
{loading ? <LoadingScreen>
<LoadingScreen> <div className="flex flex-col items-center gap-4">
<div className="flex flex-col items-center gap-4"> <div className="flex gap-2">
<div className="flex gap-2"> <span className="loading loading-spinner loading-xl"></span>
<span className="loading loading-spinner loading-xl"></span> {loadingInfo}
{loadingInfo}
</div>
<progress ref={loadingProgressBarRef} className="progress w-[20vw]" value={0} max="100"></progress>
</div> </div>
</LoadingScreen> <progress ref={loadingProgressBarRef} className="progress w-[20vw]" value={0} max="100"></progress>
: data.children} </div>
<GamepadKeyboard /> </LoadingScreen>
</AppContext> : data.children}
<GamepadKeyboard />
</SystemInfoContext>; </SystemInfoContext>;
} }

View file

@ -6,7 +6,7 @@ import
import CardElement, { GameCardParams } from "./CardElement"; import CardElement, { GameCardParams } from "./CardElement";
import { JSX } from "react"; import { JSX } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { oneShot } from "../scripts/audio/audio"; import { oneShot } from "../scripts/audio/audio";
export interface GameMetaExtra extends GameMeta export interface GameMetaExtra extends GameMeta
@ -16,7 +16,7 @@ export interface GameMetaExtra extends GameMeta
focusKey: string; focusKey: string;
} }
function LocalCardElement (data: { game: GameMetaExtra, i: number; onQuickAction?: (ctx: InteractParamsArgs) => void; } & FocusParams & InteractParams) function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams)
{ {
let preview: GameCardParams['preview'] = data.game.preview; let preview: GameCardParams['preview'] = data.game.preview;
if (!preview && data.game.previewUrls) if (!preview && data.game.previewUrls)
@ -31,28 +31,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; onQuickAction
oneShot('click'); oneShot('click');
}; };
const handleAltAction = (ctx: InteractParamsArgs) => useShortcuts(data.game.focusKey, () => [{ label: "Details", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
{
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 ( return (
<CardElement <CardElement
@ -112,12 +91,7 @@ export function CardList (data: {
> >
<FocusContext.Provider value={focusKey}> <FocusContext.Provider value={focusKey}>
{data.games.map((g, i) => <LocalCardElement {data.games.map((g, i) => <LocalCardElement
key={g.id} key={g.id} onFocus={data.onFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)}
onFocus={data.onFocus}
game={g}
onAction={() => data.onSelectGame?.(g.id)}
i={i}
/>)}
{data.finalElement} {data.finalElement}
</FocusContext.Provider> </FocusContext.Provider>
</ul> </ul>

View file

@ -64,7 +64,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
className={ className={
twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}> twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}>
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<div className={twMerge("flex bg-base-200 in-data-[selected=true]:border-4 in-focused:border-4 border-base-300 w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl gap-2 in-focused:font-semibold focusable light:not-in-data-[selected=true]:control-mouse:hover:bg-base-100 dark:not-in-data-[selected=true]:control-mouse:hover:bg-base-300 in-focused:z-100", <div className={twMerge("flex bg-base-200 in-data-[selected=true]:border-4 in-focused:border-4 border-base-300 w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl gap-2 in-focused:font-semibold focusable not-active:control-mouse:hover:bg-base-300 in-focused:z-100",
data.className, data.className,
colors[data.type], colors[data.type],
"in-focused:bg-base-content in-focused:text-base-100")}> "in-focused:bg-base-content in-focused:text-base-100")}>
@ -166,7 +166,7 @@ export function ContextDialog (data: {
}] : [], [data.open]); }] : [], [data.open]);
return <dialog ref={ref} open={data.open} closedby="any" className={ return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("fixed modal cursor-pointer bg-base-300/60 not-mobile:backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content", twMerge("fixed modal cursor-pointer bg-base-300/80 not-mobile:backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
classNames({ "opacity-0": !data.open }), data.backdropClassName) classNames({ "opacity-0": !data.open }), data.backdropClassName)
} }
onClick={handleClose}> onClick={handleClose}>
@ -174,7 +174,7 @@ export function ContextDialog (data: {
<ContextDialogContext value={{ id: data.id, close: handleClose }} > <ContextDialogContext value={{ id: data.id, close: handleClose }} >
<div <div
className={twMerge( className={twMerge(
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] max-h-[80vh] overflow-y-auto cursor-auto not-mobile:backdrop-blur-2xl not-mobile:drop-shadow-2xl", "bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] max-h-[80vh] overflow-y-auto cursor-auto not-mobile:backdrop-blur-2xl",
data.open ? "animate-scale-delayed" : "opacity-0", data.open ? "animate-scale-delayed" : "opacity-0",
data.className) data.className)
} }

View file

@ -116,7 +116,7 @@ export function FilterUI (data: {
style={{ viewTransitionName: `filter-${data.id}` }} style={{ viewTransitionName: `filter-${data.id}` }}
> >
<FocusContext.Provider value={focusKey}> <FocusContext.Provider value={focusKey}>
<ul className={twMerge("flex flex-row bg-base-100 rounded-full gap-0.5 p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}> <ul className={twMerge("flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
{!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className=" flex px-4 items-center justify-center rounded-full"> {!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className=" flex px-4 items-center justify-center rounded-full">
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" /> <SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
</li>} </li>}

View file

@ -2,15 +2,13 @@ import { useSuspenseQuery } from "@tanstack/react-query";
import { GameMetaExtra, CardList } from "./CardList"; import { GameMetaExtra, CardList } from "./CardList";
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants"; import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { useNavigate, useRouter } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { HardDrive } from "lucide-react"; import { HardDrive } from "lucide-react";
import { JSX, useContext } from "react"; import { JSX, useContext } from "react";
import { useLocalSetting } from "../scripts/utils"; import { useLocalSetting } from "../scripts/utils";
import { AnimatedBackgroundContext } from "../scripts/contexts"; import { AnimatedBackgroundContext } from "../scripts/contexts";
import { allGamesQuery } from "@queries/romm"; import { allGamesQuery } from "@queries/romm";
import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared";
import { isUrl } from "@/shared/utils";
import { FOCUS_KEYS } from "../scripts/types";
export interface GameListParams extends FocusParams export interface GameListParams extends FocusParams
{ {
@ -19,7 +17,6 @@ export interface GameListParams extends FocusParams
grid?: boolean, grid?: boolean,
setBackground?: (url: string) => void; setBackground?: (url: string) => void;
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
onQuickAction?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
focus?: string; focus?: string;
className?: string; className?: string;
finalElement?: JSX.Element | JSX.Element[]; finalElement?: JSX.Element | JSX.Element[];
@ -100,7 +97,7 @@ export function GameList (data: GameListParams)
const previewUrls = g.path_covers.map(c => const previewUrls = g.path_covers.map(c =>
{ {
const url = isUrl(c) ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`); const url = c.startsWith("http") ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`);
url.searchParams.delete('ts'); url.searchParams.delete('ts');
return url; return url;
}); });
@ -108,13 +105,13 @@ export function GameList (data: GameListParams)
let platformUrl: URL | undefined = undefined; let platformUrl: URL | undefined = undefined;
if (g.path_platform_cover) if (g.path_platform_cover)
{ {
platformUrl = isUrl(g.path_platform_cover) ? new URL(g.path_platform_cover) : new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); platformUrl = g.path_platform_cover.startsWith("http") ? new URL(g.path_platform_cover) : new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
platformUrl.searchParams.set('width', "64"); platformUrl.searchParams.set('width', "64");
} }
return { return {
id: `${g.id.source}@${g.id.id}`, id: `${g.id.source}@${g.id.id}`,
focusKey: FOCUS_KEYS.GAME_LIST_CARD(data.id, g.id), focusKey: `${data.id}-${g.id.source}@${g.id.id}`,
title: g.name ?? "", title: g.name ?? "",
subtitle: ( subtitle: (
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
@ -125,7 +122,6 @@ export function GameList (data: GameListParams)
previewUrls: previewUrls, previewUrls: previewUrls,
badges: badges, badges: badges,
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g), 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) onFocus: () => handleFocus(g.id, g.source, g.source_id)
} satisfies GameMetaExtra; } satisfies GameMetaExtra;
}, },

View file

@ -387,6 +387,10 @@ export function GamepadKeyboard ()
const magnitudeSqr = (x * x) + (y * y); const magnitudeSqr = (x * x) + (y * y);
const magnitude = Math.sqrt(magnitudeSqr); 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.left = `calc(50% + ${50 * x}% - 16px)`;
circle.style.top = `calc(50% + ${50 * y}% - 16px)`; circle.style.top = `calc(50% + ${50 * y}% - 16px)`;
circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`; circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`;

View file

@ -1,28 +0,0 @@
import { useState } from "react";
import { GlobalDialogContext } from "../scripts/contexts";
import { useContextDialog } from "./ContextDialog";
export default function GlobalContextDialog (data: { children: any; })
{
const [currentContext, setCurrentContext] = useState<any | undefined>(undefined);
const [preferredChildFocusKey, setPreferredChildFocusKey] = useState<string | undefined>(undefined);
const [onCloseCallback, setOnCloseCallback] = useState<(() => void) | undefined>(undefined);
const { dialog, setOpen } = useContextDialog('global-context-dialog', {
content: currentContext,
onClose: onCloseCallback,
preferredChildFocusKey: preferredChildFocusKey
});
return <GlobalDialogContext value={{
openContext (context, focusKey)
{
setCurrentContext(context.content);
setPreferredChildFocusKey(context.preferredChildFocusKey);
setOnCloseCallback(context.onClose);
setOpen(true, focusKey);
},
}}>
{data.children}
{dialog}
</GlobalDialogContext>;
}

View file

@ -29,11 +29,10 @@ import { twMerge } from "tailwind-merge";
import { TwitchIcon } from "../scripts/brandIcons"; import { TwitchIcon } from "../scripts/brandIcons";
import { rommLoggedInQuery } from "../scripts/queries/romm"; import { rommLoggedInQuery } from "../scripts/queries/romm";
import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
import { AppContext, SystemInfoContext } from "../scripts/contexts"; import { SystemInfoContext } from "../scripts/contexts";
import { useNavigate, useRouter } from "@tanstack/react-router"; import { useNavigate, useRouter } from "@tanstack/react-router";
import { oneShot } from "../scripts/audio/audio"; import { oneShot } from "../scripts/audio/audio";
import { hasUpdateQuery } from "../scripts/queries/system"; import { hasUpdateQuery } from "../scripts/queries/system";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
function HeaderAvatar (data: { function HeaderAvatar (data: {
id: string; id: string;
@ -74,7 +73,6 @@ export interface HeaderButton
external?: boolean; external?: boolean;
action?: () => void; action?: () => void;
className?: string; className?: string;
shortcutLabel?: string;
} }
export interface HeaderAccount export interface HeaderAccount
@ -113,22 +111,14 @@ function NotificationStatus ()
function ClockStatus () function ClockStatus ()
{ {
const navigate = useNavigate(); const ref = useRef<HTMLSpanElement>(null);
const app = useContext(AppContext);
const refClock = useRef<HTMLSpanElement>(null);
const activeTaskProgress = app.activeTaskProgress;
const handleTaskClick = () =>
{
navigate({ to: '/settings/tasks' });
};
const { ref, focusKey } = useFocusable({ focusKey: 'tasks-indicator', focusable: !!activeTaskProgress, onEnterPress: handleTaskClick });
useEffect(() => useEffect(() =>
{ {
function update () function update ()
{ {
if (refClock.current) if (ref.current)
{ {
refClock.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); ref.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} }
} }
@ -152,16 +142,7 @@ function ClockStatus ()
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, []); }, []);
useShortcuts(focusKey, () => [{ return <div className="flex gap-3 sm:text-xs md:text-2xl items-center"><span ref={ref}></span><Clock className="sm:size-4 md:size-8" /></div>;
label: "Downloads", button: GamePadButtonCode.A, action (e)
{
handleTaskClick();
},
}]);
return <div ref={ref} className="flex gap-3 sm:text-xs md:text-2xl items-center">
<span ref={refClock}></span>
{activeTaskProgress ? <div onClick={handleTaskClick} className={twMerge("radial-progress bg-primary text-primary-content border-primary border-4 in-focused:ring-7 in-focused:ring-primary in-focused:bg-base-content in-focused:text-base-200 in-focused:border-base-content", activeTaskProgress ? "cursor-pointer" : "")} style={{ "--value": activeTaskProgress, "--size": "2rem", "--thickness": "0.3rem" }} role="progressbar"></div> : <Clock className="sm:size-4 md:size-8" />}</div>;
} }
function BluetoothStatus () function BluetoothStatus ()
@ -307,7 +288,6 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
{data.buttonElements} {data.buttonElements}
{data.buttons?.map(b => <RoundButton {data.buttons?.map(b => <RoundButton
key={b.id} key={b.id}
shortcutLabel={b.shortcutLabel}
className={twMerge("header-icon sm:size-10 md:size-14", b.className)} className={twMerge("header-icon sm:size-10 md:size-14", b.className)}
id={b.id} id={b.id}
external={b.external} external={b.external}
@ -347,19 +327,7 @@ export function HeaderUI (data: HeaderUIParams)
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<HeaderAccounts key={"header-accounts"} accounts={data.accounts} /> <HeaderAccounts key={"header-accounts"} accounts={data.accounts} />
{data.title} {data.title}
<HeaderStatusBar <HeaderStatusBar key={"header-status-bar"} buttonElements={data.buttonElements} buttons={[...data.buttons ?? [], { icon: <Settings />, id: "header-settings-btn", action: goToSettings, external: true }]} />
key={"header-status-bar"}
buttonElements={data.buttonElements}
buttons={[
...data.buttons ?? [],
{
icon: <Settings />,
id: "header-settings-btn",
action: goToSettings,
external: true,
shortcutLabel: "Settings"
}
]} />
</FocusContext> </FocusContext>
</header > </header >

View file

@ -96,10 +96,10 @@ export default function HeaderSearchField (data: {
isFocusBoundary: data.compact && showInput isFocusBoundary: data.compact && showInput
}); });
return <div ref={ref} className='flex items-center' style={{ viewTransitionName: 'header-search' }}> return <div ref={ref} className='flex items-center'>
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
{(!data.compact || showInput) && <SearchInput className={data.className} autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />} {(!data.compact || showInput) && <SearchInput className={data.className} autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />}
{data.compact && !showInput && <RoundButton cssStyle={{ viewTransitionName: 'search-button' }} onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>} {data.compact && !showInput && <RoundButton onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>}
</FocusContext> </FocusContext>
</div>; </div>;
} }

View file

@ -9,11 +9,10 @@ export function RoundButton (data: {
external?: boolean; external?: boolean;
style?: ButtonStyle; style?: ButtonStyle;
cssStyle?: CSSProperties; cssStyle?: CSSProperties;
shortcutLabel?: string;
} & InteractParams & FocusParams) } & InteractParams & FocusParams)
{ {
return ( return (
<Button shortcutLabel={data.shortcutLabel} cssStyle={data.cssStyle} onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full aspect-square", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}> <Button cssStyle={data.cssStyle} onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full aspect-square", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
{data.children} {data.children}
</Button> </Button>

View file

@ -8,7 +8,6 @@ import Carousel from "./Carousel";
import { ContextDialog } from "./ContextDialog"; import { ContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { isUrl } from "@/shared/utils";
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams) function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
{ {
@ -22,9 +21,8 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' }); scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' });
} }
}); 4096; }); 4096;
const url = isUrl(data.path) ? data.path : `${RPC_URL(__HOST__)}${data.path}`;
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden"> return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={url} loading="lazy" decoding="async" /> <img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" decoding="async" />
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })}> <Fullscreen /> </div> <div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })}> <Fullscreen /> </div>
</div>; </div>;
} }
@ -61,9 +59,8 @@ function Preview (data: { id: string; screenshots?: string[]; preview: number; s
} }
} }
], [data.preview, focusKey, data.screenshots?.length ?? 0]); ], [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 <img ref={ref} draggable={false} className="object-cover w-full h-full rounded-2xl" src={url} loading="lazy" />; return <img ref={ref} draggable={false} className="object-cover w-full h-full rounded-2xl" src={`${RPC_URL(__HOST__)}${data.screenshots?.[data.preview]}`} loading="lazy" />;
} }
export default function Screenshots (data: { screenshots?: string[]; className?: string; } & FocusParams) export default function Screenshots (data: { screenshots?: string[]; className?: string; } & FocusParams)

View file

@ -2,7 +2,7 @@ import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router"; import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { DoorOpen, Gamepad2, Home, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
import { systemApi } from "../scripts/clientApi"; import { systemApi } from "../scripts/clientApi";
import { FOCUS_KEYS } from "../scripts/types"; import { FOCUS_KEYS } from "../scripts/types";
@ -15,7 +15,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
const options: DialogEntry[] = [ const options: DialogEntry[] = [
{ {
content: "Home", content: "Home",
icon: <Home />, icon: <Gamepad2 />,
action (ctx) action (ctx)
{ {
setOpen(false); setOpen(false);

View file

@ -1,25 +1,25 @@
import { DownloadsLookupFilter, DownloadsLookupFilterValues, GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared';
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import classNames from "classnames"; import classNames from "classnames";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-navigation"; import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-navigation";
import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store } from "lucide-react";
import { sourceIconMap } from "./Constants"; import { sourceIconMap } from "./Constants";
import { ContextList, DialogEntry } from "./ContextDialog"; import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog";
import { FrontEndFilterLists } from "@simeonradivoev/gameflow-sdk/shared"; import { FrontEndFilterLists } from "@simeonradivoev/gameflow-sdk/shared";
import { useContext } from 'react';
import { GlobalDialogContext } from '../scripts/contexts';
function FilterButton (data: { function FilterButton (data: {
id: string, id: string,
filters?: GameListFilterType, filters?: GameListFilterType,
tooltip: string, tooltip: string,
icon: any; icon: any;
dialog: (focNewSourceFocusKey: string) => void; dialog: {
setToggle: (focNewSourceFocusKey?: string | undefined) => void;
};
isActive: boolean; isActive: boolean;
}) })
{ {
const handleAction = () => data.dialog(data.id); const handleAction = () => data.dialog.setToggle(data.id);
useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]); useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]);
return <div className="tooltip tooltip-right" data-tip={data.tooltip}> return <div className="tooltip tooltip-right" data-tip={data.tooltip}>
<RoundButton <RoundButton
@ -32,89 +32,6 @@ function FilterButton (data: {
</div>; </div>;
} }
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: <ContextList options={data.filterValues?.orderBy
.map(o => ({
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: <ContextList options={
[{ label: 'asc', icon: <ArrowDown /> }, { label: 'desc', icon: <ArrowUp /> }]
.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: <ContextList options={data.filterValues?.source
.map<DialogEntry>(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 <div className='flex flex-col gap-2' ref={ref}>
<FocusContext value={focusKey} >
<FilterButton tooltip='Sorting' id='filter-order-by' dialog={orderByDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} />
<FilterButton tooltip='Sorting Direction' id='filter-order-direction' dialog={orderDirectionDialog} isActive={!!data.localFilter.sortDirection} icon={<ArrowUpDown />} />
{!data.filters?.source &&
<FilterButton tooltip='Source' id='filter-source' dialog={sourceFilterDialog} isActive={!!data.localFilter.source} icon={<Store />} />
}
{Object.values(data.localFilter).some(v => v !== undefined) &&
<>
<div className="divider m-0"></div>
<RoundButton id={'filter-clear'} onAction={() => data.setLocalFilter({})} className='p-3 drop-shadow-md!' > <FunnelX /> </RoundButton>
</>
}
</FocusContext>
</div>;
}
export default function SideFilters (data: { export default function SideFilters (data: {
id: string, id: string,
filters?: GameListFilterType; filters?: GameListFilterType;
@ -125,107 +42,96 @@ export default function SideFilters (data: {
{ {
const { ref, focusKey } = useFocusable({ focusKey: data.id }); const { ref, focusKey } = useFocusable({ focusKey: data.id });
const globalDialog = useContext(GlobalDialogContext);
const openSourceDialog = (focusKey: string) => const orderByDialog = useContextDialog('order-by-dialog', {
{ content: <ContextList options={([
globalDialog.openContext({ { stat: "name", icon: <ArrowDownAz /> },
content: <ContextList options={["romm"] { stat: "activity", icon: <ClockArrowDown /> },
.map<DialogEntry>(o => ({ { stat: "added", icon: <CalendarArrowDown /> },
content: o, { stat: "release", icon: <Rocket /> },
icon: sourceIconMap[o], ] satisfies { stat: GameListFilterType['orderBy'], icon?: any; }[])
selected: data.localFilter.source === o, .map(o => ({
id: `source-filter-${o}`, content: o.stat,
type: 'primary', icon: o.icon,
action (ctx) selected: data.localFilter.orderBy === o.stat,
{ id: `sort-by-${o.stat}`,
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined });
else data.setLocalFilter({ ...data.localFilter, source: o });
ctx.close();
},
})).concat({
content: "Local Only",
icon: <HardDrive />,
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: <ContextList options={data.filterValues?.genres.map(g => ({
content: g,
selected: data.localFilter.genres?.includes(g),
id: `genre-filter-${g}`,
type: 'primary', type: 'primary',
action (ctx) action (ctx)
{ {
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres?.filter(genre => genre !== g) ?? []] }); data.setLocalFilter({ ...data.localFilter, orderBy: o.stat });
else data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres ?? [], g] });
ctx.close(); ctx.close();
}, },
}))} /> }))} />,
}, focusKey); preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}`
}; });
const openSortingDialog = (focusKey: string) => const sourceFilterDialog = useContextDialog('source-filter-dialog', {
{ content: <ContextList options={["romm"]
globalDialog.openContext({ .map<DialogEntry>(o => ({
content: <ContextList options={([ content: o,
{ stat: "name", icon: <ArrowDownAz /> }, icon: sourceIconMap[o],
{ stat: "activity", icon: <ClockArrowDown /> }, selected: data.localFilter.source === o,
{ stat: "added", icon: <CalendarArrowDown /> }, id: `source-filter-${o}`,
{ stat: "release", icon: <Rocket /> },
] 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: <ContextList options={data.filterValues?.age_ratings.map(a => ({
content: a,
selected: data.localFilter.age_ratings?.includes(a),
id: `age-rating-filter-${a}`,
type: 'primary', type: 'primary',
action (ctx) action (ctx)
{ {
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings?.filter(age => age !== a) ?? []] }); if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined });
else data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings ?? [], a] }); else data.setLocalFilter({ ...data.localFilter, source: o });
ctx.close(); ctx.close();
}, },
}))} /> })).concat({
}, focusKey); content: "Local Only",
}; icon: <HardDrive />,
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: <ContextList options={data.filterValues?.genres.map(g => ({
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: <ContextList options={data.filterValues?.age_ratings.map(a => ({
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();
},
}))} />
});
return <div className='flex flex-col gap-2' ref={ref}> return <div className='flex flex-col gap-2' ref={ref}>
<FocusContext value={focusKey} > <FocusContext value={focusKey} >
<FilterButton tooltip='Sorting' id='filter-order-by' dialog={openSortingDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} /> <FilterButton tooltip='Sorting' id='filter-order-by' dialog={orderByDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} />
<FilterButton tooltip='Age Rating' id='filter-age-ratings' dialog={openAgeRatingDialog} isActive={!!data.localFilter.age_ratings && data.localFilter.age_ratings.length > 0} icon={<User />} /> <FilterButton tooltip='Age Rating' id='filter-age-ratings' dialog={ageRatingFilterDialog} isActive={!!data.localFilter.age_ratings && data.localFilter.age_ratings.length > 0} icon={<User />} />
<FilterButton tooltip='Genre' id='filter-genre' dialog={openGenreDialog} isActive={!!data.localFilter.genres && data.localFilter.genres.length > 0} icon={<Drama />} /> <FilterButton tooltip='Genre' id='filter-genre' dialog={genreFilterDialog} isActive={!!data.localFilter.genres && data.localFilter.genres.length > 0} icon={<Drama />} />
{!data.filters?.source && {!data.filters?.source &&
<FilterButton tooltip='Source' id='filter-source' dialog={openSourceDialog} isActive={!!data.localFilter.source || data.localFilter.localOnly !== undefined} icon={<Store />} /> <FilterButton tooltip='Source' id='filter-source' dialog={sourceFilterDialog} isActive={!!data.localFilter.source || data.localFilter.localOnly !== undefined} icon={<Store />} />
} }
{Object.values(data.localFilter).some(v => v !== undefined) && {Object.values(data.localFilter).some(v => v !== undefined) &&
<> <>
@ -233,6 +139,10 @@ export default function SideFilters (data: {
<RoundButton id={'filter-clear'} onAction={() => data.setLocalFilter({})} className='p-3 drop-shadow-md!' > <FunnelX /> </RoundButton> <RoundButton id={'filter-clear'} onAction={() => data.setLocalFilter({})} className='p-3 drop-shadow-md!' > <FunnelX /> </RoundButton>
</> </>
} }
{orderByDialog.dialog}
{sourceFilterDialog.dialog}
{genreFilterDialog.dialog}
{ageRatingFilterDialog.dialog}
</FocusContext> </FocusContext>
</div>; </div>;
} }

View file

@ -30,11 +30,7 @@ function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractP
</ActionButton>; </ActionButton>;
} }
export default function ActionButtons (data: { export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
game?: FrontEndGameTypeDetailed,
source: string,
id: string;
})
{ {
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
const navigate = useNavigate(); const navigate = useNavigate();

View file

@ -1,20 +1,21 @@
import { rommApi } from "@/mainview/scripts/clientApi"; import { rommApi } from "@/mainview/scripts/clientApi";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { JSX, useContext, useEffect, useRef, useState } from "react"; import { JSX, useEffect, useRef, useState } from "react";
import { getErrorMessage } from "react-error-boundary"; import { getErrorMessage } from "react-error-boundary";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { ContextList, DialogEntry } from "../ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react"; import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react";
import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm"; import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm";
import ActionButton from "./ActionButton"; import ActionButton from "./ActionButton";
import { useNavigate, UseNavigateResult, useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
import { CommandEntry, FrontEndGameTypeDetailed, DownloadSourceType } from "@simeonradivoev/gameflow-sdk/shared"; import { CommandEntry, FrontEndGameTypeDetailed, DownloadSourceType } from "@simeonradivoev/gameflow-sdk/shared";
import { GlobalDialogContext } from "@/mainview/scripts/contexts";
export function usePlayMutation (navigate: UseNavigateResult<string>) export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
{ {
const installMut = useMutation(installMutation(data.source, data.id));
const router = useRouter();
const playMut = useMutation({ const playMut = useMutation({
...playMutation, onError (error) ...playMutation, onError (error)
{ {
@ -22,36 +23,9 @@ export function usePlayMutation (navigate: UseNavigateResult<string>)
}, },
onSuccess (data, { source, id }, onMutateResult, context) onSuccess (data, { source, id }, onMutateResult, context)
{ {
navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } }); router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id } });
}, },
}); });
return playMut;
}
export function playGame (source: string, id: string, cmd: CommandEntry, navigate: UseNavigateResult<string>, 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 ws = useRef<{ send: (data: string) => void; }>(undefined);
const [progress, setProgress] = useState<number | undefined>(undefined); const [progress, setProgress] = useState<number | undefined>(undefined);
const [status, setStatus] = useState<string | undefined>(undefined); const [status, setStatus] = useState<string | undefined>(undefined);
@ -68,7 +42,7 @@ export default function MainActions (data: {
if (preferredCommand && c.id !== preferredCommand) return false; if (preferredCommand && c.id !== preferredCommand) return false;
return true; return true;
}); });
const playMut = usePlayMutation(navigate);
useEffect(() => useEffect(() =>
{ {
const sub = rommApi.api.romm.status({ source: data.source })({ id: data.id }).subscribe(); const sub = rommApi.api.romm.status({ source: data.source })({ id: data.id }).subscribe();
@ -125,33 +99,32 @@ export default function MainActions (data: {
} }
const showProgress = progress !== null && !!progressIcon; 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 mainButton: any | undefined = undefined;
let showAllCommandsAction: ((focusKey: string) => void) | undefined; let showAllCommandsAction: ((focusKey: string) => void) | undefined;
let mainAction: () => void; let mainAction: () => void;
if (status === 'installed') if (status === 'installed')
{ {
if (validCommands.length > 1) showAllCommandsAction = (focusKey) => globalDialog.openContext({ if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey);
content: <ContextList options={validCommands.map((c, i) => mainAction = () => handlePlay(validDefaultCommand);
{
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 = <div className="flex gap-2"> mainButton = <div className="flex gap-2">
<ActionButton onAction={mainAction} tooltip={validDefaultCommand?.label ?? details} <ActionButton onAction={mainAction} tooltip={validDefaultCommand?.label ?? details}
key="primary" key="primary"
@ -209,18 +182,7 @@ export default function MainActions (data: {
case 'install': case 'install':
if (installSources && installSources.length > 1) if (installSources && installSources.length > 1)
{ {
globalDialog.openContext({ showInstallSource(true, 'mainAction');
content: <ContextList options={installSources?.map(s => ({
content: s.name,
action (ctx)
{
installMut.mutate({ downloadId: s.id });
ctx.close();
},
type: 'primary',
id: s.id
} satisfies DialogEntry)) ?? []} />
}, 'mainAction');
} else } else
{ {
installMut.mutate({}); installMut.mutate({});
@ -260,21 +222,55 @@ export default function MainActions (data: {
return shortcuts; return shortcuts;
}, [showAllCommandsAction, mainAction]); }, [showAllCommandsAction, mainAction]);
const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', {
content: <ContextList options={validCommands.map((c, i) =>
{
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: <ContextList options={[{
id: 'cancel',
content: "Cancel",
action (ctx)
{
ws.current?.send('cancel');
ctx.close();
},
type: 'primary'
}]} />
});
const { dialog: installSourcesDialog, setOpen: showInstallSource } = useContextDialog('install-source-dialog', {
content: <ContextList options={installSources?.map(s => ({
content: s.name,
action (ctx)
{
installMut.mutate({ downloadId: s.id });
ctx.close();
},
type: 'primary',
id: s.id
} satisfies DialogEntry)) ?? []} />
});
return <div className="flex gap-2"> return <div className="flex gap-2">
{mainButton} {mainButton}
<div className="divider divider-horizontal m-0"></div> <div className="divider divider-horizontal m-0"></div>
{showProgress && <ActionButton onAction={() => globalDialog.openContext({ {showProgress && <ActionButton onAction={() => showInstallOptions(true, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
content: <ContextList options={[{
id: 'cancel',
content: "Cancel",
action (ctx)
{
ws.current?.send('cancel');
ctx.close();
},
type: 'primary'
}]} />
}, "progress")} key="progress" square tooltip={details} type="base" id="progress" >
<div key={`install-${status}`} data-tooltip={details ?? status} className="flex flex-col gap-2 w-16 items-center text-2xl"> <div key={`install-${status}`} data-tooltip={details ?? status} className="flex flex-col gap-2 w-16 items-center text-2xl">
<div className="flex flex-row"> <div className="flex flex-row">
{progressIcon} {progressIcon}
@ -282,5 +278,8 @@ export default function MainActions (data: {
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress> <progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
</div> </div>
</ActionButton>} </ActionButton>}
{installSourcesDialog}
{installOptionsDialog}
{allCommandDialog}
</div>; </div>;
} }

View file

@ -12,7 +12,7 @@ import { oneShot } from "@/mainview/scripts/audio/audio";
export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
const styles = { const styles = {
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', 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',
accent: "bg-accent text-accent-content active:not-disabled:bg-base-100! active:not-disabled:text-base-content! active:ring-offset-accent", 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", 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", 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,17 +22,6 @@ 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", 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: { export function Button (data: {
id: string, id: string,
children?: any, children?: any,
@ -75,9 +64,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", 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'], styles[data.style ?? 'base'],
focused ? data.focusClassName : undefined, focused ? data.focusClassName : undefined,
data.external ? `focusable focusable-hover ${externalStyles[data.style as keyof typeof externalStyles]}` : '',
classNames({ classNames({
"btn-accent": focused "btn-accent": focused,
"focusable focusable-primary focusable-hover": data.external
}, data.className))} }, data.className))}
type={data.type ?? 'button'} type={data.type ?? 'button'}
> >

View file

@ -6,15 +6,11 @@ import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { CircleFadingArrowUp, FileQuestion, IceCream2, Package, Store, WandSparkles } from "lucide-react"; import { CircleFadingArrowUp, FileQuestion, IceCream2, Package, Store, WandSparkles } from "lucide-react";
import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { FlatpackIcon } from "@/mainview/scripts/brandIcons"; import { FlatpackIcon } from "@/mainview/scripts/brandIcons";
import { JSX, useContext } from "react"; import { JSX } from "react";
import { oneShot } from "@/mainview/scripts/audio/audio"; import { oneShot } from "@/mainview/scripts/audio/audio";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store"; import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store";
import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared"; 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<string, JSX.Element> = { export const emulatorStatusIcons: Record<string, JSX.Element> = {
store: <Store />, store: <Store />,
@ -32,7 +28,6 @@ export function StoreEmulatorCard (data: {
className?: string; className?: string;
}) })
{ {
const navigate = useNavigate();
const handleSelect = () => const handleSelect = () =>
{ {
data.onSelect?.(data.emulator.name, focusKey); data.onSelect?.(data.emulator.name, focusKey);
@ -50,32 +45,7 @@ export function StoreEmulatorCard (data: {
const { data: updateInfo } = useQuery(getUpdateInfoForEmulator(data.emulator.name)); const { data: updateInfo } = useQuery(getUpdateInfoForEmulator(data.emulator.name));
const globalDialogContext = useContext(GlobalDialogContext); useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
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: <ContextList options={entries} /> }, focusKey);
}
}], [handleSelect]);
return ( return (
<div <div

View file

@ -13,7 +13,6 @@ import { Route as GamesRouteImport } from './../routes/games'
import { Route as SettingsRouteRouteImport } from './../routes/settings/route' import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
import { Route as IndexRouteImport } from './../routes/index' import { Route as IndexRouteImport } from './../routes/index'
import { Route as SettingsUpdateRouteImport } from './../routes/settings/update' import { Route as SettingsUpdateRouteImport } from './../routes/settings/update'
import { Route as SettingsTasksRouteImport } from './../routes/settings/tasks'
import { Route as SettingsPluginsRouteImport } from './../routes/settings/plugins' import { Route as SettingsPluginsRouteImport } from './../routes/settings/plugins'
import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface' import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface'
import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators' import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators'
@ -26,7 +25,6 @@ import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index'
import { Route as StoreTabPluginsRouteImport } from './../routes/store/tab/plugins' import { Route as StoreTabPluginsRouteImport } from './../routes/store/tab/plugins'
import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games' import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games'
import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators' import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators'
import { Route as StoreTabDownloadRouteImport } from './../routes/store/tab/download'
import { Route as SettingsPluginSourceRouteImport } from './../routes/settings/plugin.$source' import { Route as SettingsPluginSourceRouteImport } from './../routes/settings/plugin.$source'
import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id' import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id'
import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id' import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id'
@ -36,7 +34,6 @@ import { Route as CollectionSourceIdRouteImport } from './../routes/collection.$
import { Route as StoreDetailsPluginIdRouteImport } from './../routes/store/details.plugin.$id' import { Route as StoreDetailsPluginIdRouteImport } from './../routes/store/details.plugin.$id'
import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id' import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id'
import { Route as GameUpdateSourceIdRouteImport } from './../routes/game/update.$source.$id' import { Route as GameUpdateSourceIdRouteImport } from './../routes/game/update.$source.$id'
import { Route as StoreDetailsDownloadSourceIdRouteImport } from './../routes/store/details.download.$source.$id'
const GamesRoute = GamesRouteImport.update({ const GamesRoute = GamesRouteImport.update({
id: '/games', id: '/games',
@ -58,11 +55,6 @@ const SettingsUpdateRoute = SettingsUpdateRouteImport.update({
path: '/update', path: '/update',
getParentRoute: () => SettingsRouteRoute, getParentRoute: () => SettingsRouteRoute,
} as any) } as any)
const SettingsTasksRoute = SettingsTasksRouteImport.update({
id: '/tasks',
path: '/tasks',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ const SettingsPluginsRoute = SettingsPluginsRouteImport.update({
id: '/plugins', id: '/plugins',
path: '/plugins', path: '/plugins',
@ -123,11 +115,6 @@ const StoreTabEmulatorsRoute = StoreTabEmulatorsRouteImport.update({
path: '/emulators', path: '/emulators',
getParentRoute: () => StoreTabRouteRoute, getParentRoute: () => StoreTabRouteRoute,
} as any) } as any)
const StoreTabDownloadRoute = StoreTabDownloadRouteImport.update({
id: '/download',
path: '/download',
getParentRoute: () => StoreTabRouteRoute,
} as any)
const SettingsPluginSourceRoute = SettingsPluginSourceRouteImport.update({ const SettingsPluginSourceRoute = SettingsPluginSourceRouteImport.update({
id: '/plugin/$source', id: '/plugin/$source',
path: '/plugin/$source', path: '/plugin/$source',
@ -173,12 +160,6 @@ const GameUpdateSourceIdRoute = GameUpdateSourceIdRouteImport.update({
path: '/game/update/$source/$id', path: '/game/update/$source/$id',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } 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 { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@ -192,7 +173,6 @@ export interface FileRoutesByFullPath {
'/settings/emulators': typeof SettingsEmulatorsRoute '/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute '/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute '/settings/plugins': typeof SettingsPluginsRoute
'/settings/tasks': typeof SettingsTasksRoute
'/settings/update': typeof SettingsUpdateRoute '/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute '/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute
@ -200,7 +180,6 @@ export interface FileRoutesByFullPath {
'/launcher/$source/$id': typeof LauncherSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute
'/settings/plugin/$source': typeof SettingsPluginSourceRoute '/settings/plugin/$source': typeof SettingsPluginSourceRoute
'/store/tab/download': typeof StoreTabDownloadRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute '/store/tab/games': typeof StoreTabGamesRoute
'/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab/plugins': typeof StoreTabPluginsRoute
@ -208,7 +187,6 @@ export interface FileRoutesByFullPath {
'/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
'/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute
'/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@ -221,7 +199,6 @@ export interface FileRoutesByTo {
'/settings/emulators': typeof SettingsEmulatorsRoute '/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute '/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute '/settings/plugins': typeof SettingsPluginsRoute
'/settings/tasks': typeof SettingsTasksRoute
'/settings/update': typeof SettingsUpdateRoute '/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute '/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute
@ -229,7 +206,6 @@ export interface FileRoutesByTo {
'/launcher/$source/$id': typeof LauncherSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute
'/settings/plugin/$source': typeof SettingsPluginSourceRoute '/settings/plugin/$source': typeof SettingsPluginSourceRoute
'/store/tab/download': typeof StoreTabDownloadRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute '/store/tab/games': typeof StoreTabGamesRoute
'/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab/plugins': typeof StoreTabPluginsRoute
@ -237,7 +213,6 @@ export interface FileRoutesByTo {
'/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
'/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute
'/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@ -252,7 +227,6 @@ export interface FileRoutesById {
'/settings/emulators': typeof SettingsEmulatorsRoute '/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute '/settings/interface': typeof SettingsInterfaceRoute
'/settings/plugins': typeof SettingsPluginsRoute '/settings/plugins': typeof SettingsPluginsRoute
'/settings/tasks': typeof SettingsTasksRoute
'/settings/update': typeof SettingsUpdateRoute '/settings/update': typeof SettingsUpdateRoute
'/collection/$source/$id': typeof CollectionSourceIdRoute '/collection/$source/$id': typeof CollectionSourceIdRoute
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute '/embedded/$source/$id': typeof EmbeddedSourceIdRoute
@ -260,7 +234,6 @@ export interface FileRoutesById {
'/launcher/$source/$id': typeof LauncherSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute
'/settings/plugin/$source': typeof SettingsPluginSourceRoute '/settings/plugin/$source': typeof SettingsPluginSourceRoute
'/store/tab/download': typeof StoreTabDownloadRoute
'/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute
'/store/tab/games': typeof StoreTabGamesRoute '/store/tab/games': typeof StoreTabGamesRoute
'/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab/plugins': typeof StoreTabPluginsRoute
@ -268,7 +241,6 @@ export interface FileRoutesById {
'/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/game/update/$source/$id': typeof GameUpdateSourceIdRoute
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
'/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute
'/store/details/download/$source/$id': typeof StoreDetailsDownloadSourceIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@ -284,7 +256,6 @@ export interface FileRouteTypes {
| '/settings/emulators' | '/settings/emulators'
| '/settings/interface' | '/settings/interface'
| '/settings/plugins' | '/settings/plugins'
| '/settings/tasks'
| '/settings/update' | '/settings/update'
| '/collection/$source/$id' | '/collection/$source/$id'
| '/embedded/$source/$id' | '/embedded/$source/$id'
@ -292,7 +263,6 @@ export interface FileRouteTypes {
| '/launcher/$source/$id' | '/launcher/$source/$id'
| '/platform/$source/$id' | '/platform/$source/$id'
| '/settings/plugin/$source' | '/settings/plugin/$source'
| '/store/tab/download'
| '/store/tab/emulators' | '/store/tab/emulators'
| '/store/tab/games' | '/store/tab/games'
| '/store/tab/plugins' | '/store/tab/plugins'
@ -300,7 +270,6 @@ export interface FileRouteTypes {
| '/game/update/$source/$id' | '/game/update/$source/$id'
| '/store/details/emulator/$id' | '/store/details/emulator/$id'
| '/store/details/plugin/$id' | '/store/details/plugin/$id'
| '/store/details/download/$source/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@ -313,7 +282,6 @@ export interface FileRouteTypes {
| '/settings/emulators' | '/settings/emulators'
| '/settings/interface' | '/settings/interface'
| '/settings/plugins' | '/settings/plugins'
| '/settings/tasks'
| '/settings/update' | '/settings/update'
| '/collection/$source/$id' | '/collection/$source/$id'
| '/embedded/$source/$id' | '/embedded/$source/$id'
@ -321,7 +289,6 @@ export interface FileRouteTypes {
| '/launcher/$source/$id' | '/launcher/$source/$id'
| '/platform/$source/$id' | '/platform/$source/$id'
| '/settings/plugin/$source' | '/settings/plugin/$source'
| '/store/tab/download'
| '/store/tab/emulators' | '/store/tab/emulators'
| '/store/tab/games' | '/store/tab/games'
| '/store/tab/plugins' | '/store/tab/plugins'
@ -329,7 +296,6 @@ export interface FileRouteTypes {
| '/game/update/$source/$id' | '/game/update/$source/$id'
| '/store/details/emulator/$id' | '/store/details/emulator/$id'
| '/store/details/plugin/$id' | '/store/details/plugin/$id'
| '/store/details/download/$source/$id'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@ -343,7 +309,6 @@ export interface FileRouteTypes {
| '/settings/emulators' | '/settings/emulators'
| '/settings/interface' | '/settings/interface'
| '/settings/plugins' | '/settings/plugins'
| '/settings/tasks'
| '/settings/update' | '/settings/update'
| '/collection/$source/$id' | '/collection/$source/$id'
| '/embedded/$source/$id' | '/embedded/$source/$id'
@ -351,7 +316,6 @@ export interface FileRouteTypes {
| '/launcher/$source/$id' | '/launcher/$source/$id'
| '/platform/$source/$id' | '/platform/$source/$id'
| '/settings/plugin/$source' | '/settings/plugin/$source'
| '/store/tab/download'
| '/store/tab/emulators' | '/store/tab/emulators'
| '/store/tab/games' | '/store/tab/games'
| '/store/tab/plugins' | '/store/tab/plugins'
@ -359,7 +323,6 @@ export interface FileRouteTypes {
| '/game/update/$source/$id' | '/game/update/$source/$id'
| '/store/details/emulator/$id' | '/store/details/emulator/$id'
| '/store/details/plugin/$id' | '/store/details/plugin/$id'
| '/store/details/download/$source/$id'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@ -376,7 +339,6 @@ export interface RootRouteChildren {
GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute
StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute
StoreDetailsPluginIdRoute: typeof StoreDetailsPluginIdRoute StoreDetailsPluginIdRoute: typeof StoreDetailsPluginIdRoute
StoreDetailsDownloadSourceIdRoute: typeof StoreDetailsDownloadSourceIdRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -409,13 +371,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsUpdateRouteImport preLoaderRoute: typeof SettingsUpdateRouteImport
parentRoute: typeof SettingsRouteRoute parentRoute: typeof SettingsRouteRoute
} }
'/settings/tasks': {
id: '/settings/tasks'
path: '/tasks'
fullPath: '/settings/tasks'
preLoaderRoute: typeof SettingsTasksRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/plugins': { '/settings/plugins': {
id: '/settings/plugins' id: '/settings/plugins'
path: '/plugins' path: '/plugins'
@ -500,13 +455,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StoreTabEmulatorsRouteImport preLoaderRoute: typeof StoreTabEmulatorsRouteImport
parentRoute: typeof StoreTabRouteRoute 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': { '/settings/plugin/$source': {
id: '/settings/plugin/$source' id: '/settings/plugin/$source'
path: '/plugin/$source' path: '/plugin/$source'
@ -570,13 +518,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof GameUpdateSourceIdRouteImport preLoaderRoute: typeof GameUpdateSourceIdRouteImport
parentRoute: typeof rootRouteImport 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
}
} }
} }
@ -587,7 +528,6 @@ interface SettingsRouteRouteChildren {
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
SettingsInterfaceRoute: typeof SettingsInterfaceRoute SettingsInterfaceRoute: typeof SettingsInterfaceRoute
SettingsPluginsRoute: typeof SettingsPluginsRoute SettingsPluginsRoute: typeof SettingsPluginsRoute
SettingsTasksRoute: typeof SettingsTasksRoute
SettingsUpdateRoute: typeof SettingsUpdateRoute SettingsUpdateRoute: typeof SettingsUpdateRoute
SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute
} }
@ -599,7 +539,6 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsEmulatorsRoute: SettingsEmulatorsRoute, SettingsEmulatorsRoute: SettingsEmulatorsRoute,
SettingsInterfaceRoute: SettingsInterfaceRoute, SettingsInterfaceRoute: SettingsInterfaceRoute,
SettingsPluginsRoute: SettingsPluginsRoute, SettingsPluginsRoute: SettingsPluginsRoute,
SettingsTasksRoute: SettingsTasksRoute,
SettingsUpdateRoute: SettingsUpdateRoute, SettingsUpdateRoute: SettingsUpdateRoute,
SettingsPluginSourceRoute: SettingsPluginSourceRoute, SettingsPluginSourceRoute: SettingsPluginSourceRoute,
} }
@ -609,7 +548,6 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
) )
interface StoreTabRouteRouteChildren { interface StoreTabRouteRouteChildren {
StoreTabDownloadRoute: typeof StoreTabDownloadRoute
StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute
StoreTabGamesRoute: typeof StoreTabGamesRoute StoreTabGamesRoute: typeof StoreTabGamesRoute
StoreTabPluginsRoute: typeof StoreTabPluginsRoute StoreTabPluginsRoute: typeof StoreTabPluginsRoute
@ -617,7 +555,6 @@ interface StoreTabRouteRouteChildren {
} }
const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = { const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = {
StoreTabDownloadRoute: StoreTabDownloadRoute,
StoreTabEmulatorsRoute: StoreTabEmulatorsRoute, StoreTabEmulatorsRoute: StoreTabEmulatorsRoute,
StoreTabGamesRoute: StoreTabGamesRoute, StoreTabGamesRoute: StoreTabGamesRoute,
StoreTabPluginsRoute: StoreTabPluginsRoute, StoreTabPluginsRoute: StoreTabPluginsRoute,
@ -642,7 +579,6 @@ const rootRouteChildren: RootRouteChildren = {
GameUpdateSourceIdRoute: GameUpdateSourceIdRoute, GameUpdateSourceIdRoute: GameUpdateSourceIdRoute,
StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute, StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute,
StoreDetailsPluginIdRoute: StoreDetailsPluginIdRoute, StoreDetailsPluginIdRoute: StoreDetailsPluginIdRoute,
StoreDetailsDownloadSourceIdRoute: StoreDetailsDownloadSourceIdRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View file

@ -9,7 +9,6 @@
@theme { @theme {
--breakpoint-sm: 0px; --breakpoint-sm: 0px;
--breakpoint-md: 1024px; --breakpoint-md: 1024px;
--breakpoint-lg: 1280px;
--page-scroll-bg: transparent; --page-scroll-bg: transparent;
--animation-size: 1; --animation-size: 1;

View file

@ -8,7 +8,7 @@ import
RouterProvider, RouterProvider,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { routeTree } from "./gen/routeTree.gen"; import { routeTree } from "./gen/routeTree.gen";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "./scripts/gamepads"; import "./scripts/gamepads";
import "./scripts/windowEvents"; import "./scripts/windowEvents";
import "./scripts/spatialNavigation"; import "./scripts/spatialNavigation";
@ -16,16 +16,6 @@ import NotFound from "./components/NotFound";
import Error from "./components/Error"; import Error from "./components/Error";
import serviceWorker from './scripts/serviceWorker?worker&url'; import serviceWorker from './scripts/serviceWorker?worker&url';
import App from "./App"; 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) if ('serviceWorker' in navigator)
{ {
@ -34,31 +24,7 @@ if ('serviceWorker' in navigator)
const hashHistory = createHashHistory({}); 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<PersistedClient>(idbValidKey, idbStore);
},
removeClient: async () =>
{
await del(idbValidKey, idbStore);
},
} satisfies Persister;
}
export interface RouterContext export interface RouterContext
{ {
@ -108,9 +74,9 @@ if (!rootElement.innerHTML)
root.render( root.render(
<StrictMode> <StrictMode>
<App> <App>
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: createIDBPersister(), buster: pkg.version }}> <QueryClientProvider client={queryClient}>
<RouterProvider router={Router} /> <RouterProvider router={Router} />
</PersistQueryClientProvider> </QueryClientProvider>
</App> </App>
</StrictMode>, </StrictMode>,
); );

View file

@ -8,7 +8,6 @@ import { useEffect } from "react";
import AppCommunication from "../components/AppCommunication"; import AppCommunication from "../components/AppCommunication";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import GlobalContextDialog from "../components/GlobalContextDialog";
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent, component: RootComponent,
@ -40,11 +39,9 @@ function RootComponent ()
return ( return (
<div data-device={isMobile ? 'mobile' : ''} data-active-control={control} className="w-screen h-screen overflow-hidden"> <div data-device={isMobile ? 'mobile' : ''} data-active-control={control} className="w-screen h-screen overflow-hidden">
<GlobalContextDialog> <AppCommunication>
<AppCommunication> <Outlet />
<Outlet /> </AppCommunication>
</AppCommunication>
</GlobalContextDialog>
<Notifications /> <Notifications />
<Toaster containerStyle={{ viewTimelineName: 'toasters', viewTransitionName: 'notifications' }} /> <Toaster containerStyle={{ viewTimelineName: 'toasters', viewTransitionName: 'notifications' }} />
{queryDevOptions && <ReactQueryDevtools buttonPosition="top-right" />} {queryDevOptions && <ReactQueryDevtools buttonPosition="top-right" />}

View file

@ -33,9 +33,7 @@ export const Route = createFileRoute("/game/$source/$id")({
}, },
component: RouteComponent, component: RouteComponent,
errorComponent: Error, errorComponent: Error,
validateSearch: zodValidator(z.object({ validateSearch: zodValidator(z.object({ focus: z.string().optional() })),
focus: z.string().optional(),
})),
staticData: { staticData: {
enterSound: 'openDetails', enterSound: 'openDetails',
goBackSound: "returnDetails" goBackSound: "returnDetails"

View file

@ -8,18 +8,15 @@ import { PathSettingsOptionBase } from '@/mainview/components/options/PathSettin
import SelectMenu from '@/mainview/components/SelectMenu'; import SelectMenu from '@/mainview/components/SelectMenu';
import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { FloatingShortcuts } from '@/mainview/components/Shortcuts';
import { oneShot } from '@/mainview/scripts/audio/audio'; import { oneShot } from '@/mainview/scripts/audio/audio';
import { rommApi } from '@/mainview/scripts/clientApi';
import { addManualGameMutation, allGamesInvalidateQuery, gameLookupDetails, platformLookupMatchQuery } from '@/mainview/scripts/queries/romm'; import { addManualGameMutation, allGamesInvalidateQuery, gameLookupDetails, platformLookupMatchQuery } from '@/mainview/scripts/queries/romm';
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
import { HandleGoBack } from '@/mainview/scripts/utils'; import { HandleGoBack } from '@/mainview/scripts/utils';
import { isUrl } from '@/shared/utils';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter'; import { zodValidator } from '@tanstack/zod-adapter';
import { ArrowBigRightDash, Check, CirclePlus, CircleQuestionMark, CircleX, File, FileSearch, FolderOpen, Globe, HardDrive, Link, Save } from 'lucide-react'; import { ArrowBigRightDash, Check, CirclePlus, CircleQuestionMark, CircleX, FileSearch, FolderOpen, HardDrive } from 'lucide-react';
import { basename } from 'pathe'; import { basename } from 'pathe';
import prettyBytes from 'pretty-bytes';
import { JSX, useState } from 'react'; import { JSX, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@ -42,7 +39,6 @@ export const Route = createFileRoute('/game/add')({
function FileSelectionField (data: { location: string | undefined, setLocation: (location: string | undefined) => void; }) function FileSelectionField (data: { location: string | undefined, setLocation: (location: string | undefined) => void; })
{ {
const [localLocation, setLocalLocation] = useState<string | undefined>(data.location); const [localLocation, setLocalLocation] = useState<string | undefined>(data.location);
const navigate = useNavigate();
return <PathSettingsOptionBase return <PathSettingsOptionBase
isDirty={false} isDirty={false}
label={"Game Location"} label={"Game Location"}
@ -55,9 +51,7 @@ function FileSelectionField (data: { location: string | undefined, setLocation:
localValue={localLocation} localValue={localLocation}
setLocalValue={setLocalLocation} setLocalValue={setLocalLocation}
defaultValue={data.location} defaultValue={data.location}
> />;
<Button className='focusable focusable-accent' external onAction={e => navigate({ to: '/store/tab/download' })} shortcutLabel='Search In Shop' id='download-lookup-btn'><Globe /></Button>
</PathSettingsOptionBase>;
} }
const TAG_REGEX = /\(([^)]+)\)|\[([^\]]+)\]/g; const TAG_REGEX = /\(([^)]+)\)|\[([^\]]+)\]/g;
@ -101,17 +95,6 @@ function Overview (data: {})
const navigate = useNavigate(); const navigate = useNavigate();
const router = useRouter(); const router = useRouter();
const state = Route.useSearch(); 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: game } = useQuery(gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id));
const { data: platform } = useQuery(platformLookupMatchQuery(state.selectedGame?.source, state.platformId)); const { data: platform } = useQuery(platformLookupMatchQuery(state.selectedGame?.source, state.platformId));
const addGame = useMutation({ const addGame = useMutation({
@ -122,7 +105,7 @@ function Overview (data: {})
}, },
async onSuccess (data, variables, onMutateResult, context) async onSuccess (data, variables, onMutateResult, context)
{ {
if (data.id === null || isUrl(state.gameLocation)) return; if (data.id === null) return;
await context.client.invalidateQueries(allGamesInvalidateQuery); await context.client.invalidateQueries(allGamesInvalidateQuery);
navigate({ navigate({
to: '/game/$source/$id', params: { to: '/game/$source/$id', params: {
@ -153,13 +136,7 @@ function Overview (data: {})
<div> {platform?.match.type}</div> <div> {platform?.match.type}</div>
</div> </div>
</div> </div>
<div className='flex gap-2'>{isUrl(state.gameLocation) ? <Link /> : <FolderOpen />}{state.gameLocation}</div> <div className='flex gap-2'><FolderOpen />{state.gameLocation}</div>
<div className='flex gap-2'>
<Save />
{linkInfo.isFetching ? <span className="loading loading-spinner loading-md"></span> : (linkInfo.data?.data?.size && prettyBytes(linkInfo.data.data.size))}
<File />
{linkInfo.isFetching ? <span className="loading loading-spinner loading-md"></span> : (linkInfo.data?.data?.content_type && linkInfo.data.data.content_type)}
</div>
</div> </div>
</div> </div>
<div className="divider">Actions</div> <div className="divider">Actions</div>
@ -173,11 +150,6 @@ function Overview (data: {})
gamePath: state.gameLocation, gamePath: state.gameLocation,
platformId: state.platformId platformId: state.platformId
}); });
if (isUrl(state.gameLocation))
{
navigate({ to: '/settings/tasks' });
}
}} ><CirclePlus /> Add Game</Button> }} ><CirclePlus /> Add Game</Button>
<Button id="cancel-btn" style='warning' type='button' className='gap-2 focusable focusable-primary' onAction={e => <Button id="cancel-btn" style='warning' type='button' className='gap-2 focusable focusable-primary' onAction={e =>
{ {
@ -252,6 +224,7 @@ const StepDetails = [{ label: "Select Location" }, { label: "Find Match" }, { la
function Location () function Location ()
{ {
const state = Route.useSearch(); const state = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
const handleSetLocation = (location: string | undefined) => const handleSetLocation = (location: string | undefined) =>
@ -273,7 +246,7 @@ function Location ()
<div className="divider"><FolderOpen className='size-12' /> Select Game Rom</div> <div className="divider"><FolderOpen className='size-12' /> Select Game Rom</div>
<FileSelectionField location={state.gameLocation ?? ''} setLocation={handleSetLocation} /> <FileSelectionField location={state.gameLocation ?? ''} setLocation={handleSetLocation} />
<div className='flex justify-center text-base-content/60'> <div className='flex justify-center text-base-content/60'>
Select The Rom File from your local storage or use a link Select The Rom File from your local storage
</div> </div>
</div>; </div>;
} }

View file

@ -31,7 +31,7 @@ function RouteComponent ()
return <CollectionsDetail return <CollectionsDetail
headerButtonElements={ headerButtonElements={
[<RoundButton shortcutLabel='Add Game' external id={'add-game-btn'} onAction={(e) => [<RoundButton external id={'add-game-btn'} onAction={(e) =>
{ {
navigate({ to: '/game/add' }); navigate({ to: '/game/add' });
}} ><Plus /></RoundButton>, }} ><Plus /></RoundButton>,

View file

@ -14,7 +14,6 @@ import
import import
{ {
createFileRoute, createFileRoute,
useNavigate,
useRouter, useRouter,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
@ -41,7 +40,7 @@ import z from "zod";
import CollectionList from "../components/CollectionList"; import CollectionList from "../components/CollectionList";
import { zodValidator } from '@tanstack/zod-adapter'; import { zodValidator } from '@tanstack/zod-adapter';
import { mobileCheck, scrollIntoViewHandler, useDragScroll } from "../scripts/utils"; import { mobileCheck, scrollIntoViewHandler, useDragScroll } from "../scripts/utils";
import { AnimatedBackgroundContext, GlobalDialogContext } from "../scripts/contexts"; import { AnimatedBackgroundContext } from "../scripts/contexts";
import Carousel from "../components/Carousel"; import Carousel from "../components/Carousel";
import { closeMutation } from "@queries/system"; import { closeMutation } from "@queries/system";
import { gameQuery } from "../scripts/queries/romm"; import { gameQuery } from "../scripts/queries/romm";
@ -52,10 +51,6 @@ import HeaderSearchField from "../components/HeaderSearchField";
import CardElement from "../components/CardElement"; import CardElement from "../components/CardElement";
import { Router } from ".."; import { Router } from "..";
import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; 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("/")({ export const Route = createFileRoute("/")({
component: ConsoleHomeUI, component: ConsoleHomeUI,
@ -157,9 +152,6 @@ function HomeList (data: {
focusKey: "home-list", focusKey: "home-list",
preferredChildFocusKey: `${data.selectedFilter}-list` preferredChildFocusKey: `${data.selectedFilter}-list`
}); });
const navigate = useNavigate();
const playGameMut = usePlayMutation(navigate);
const globalDialog = useContext(GlobalDialogContext);
const handleNodeFocus = (id: string, node: HTMLElement, details: FocusDetails) => const handleNodeFocus = (id: string, node: HTMLElement, details: FocusDetails) =>
{ {
@ -177,52 +169,6 @@ function HomeList (data: {
router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
}; };
async function handleGamePlay (id: FrontEndId, source: string | null, sourceId: string | null)
{
const finalSource = source ?? id.source;
const finalId = String(sourceId ?? id.id);
const validCommands = await rommApi.api.romm.game({ source: finalSource })({ id: finalId }).commands.get();
if (validCommands.data)
{
const preferredCommand = localStorage.getItem(`${finalSource}-${finalId}-preferred-command`);
if (preferredCommand)
{
playGame(finalSource, finalId, validCommands.data.commands[JSON.parse(preferredCommand)], navigate, playGameMut.mutate);
} else
{
if (validCommands.data.commands.length > 1)
{
globalDialog.openContext({
content: <ContextList options={
validCommands.data.commands.map((c, i) =>
{
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; let activeList: JSX.Element;
switch (data.selectedFilter) switch (data.selectedFilter)
{ {
@ -244,7 +190,6 @@ function HomeList (data: {
activeList = <> activeList = <>
<GameList <GameList
onGameSelect={handleGameSelect} onGameSelect={handleGameSelect}
onQuickAction={handleGamePlay}
saveChildFocus="session" saveChildFocus="session"
onFocus={(l, n, d) => onFocus={(l, n, d) =>
{ {
@ -258,7 +203,7 @@ function HomeList (data: {
setBackground={bg.setBackground} setBackground={bg.setBackground}
filters={{ limit: 12, orderBy: 'activity' }} filters={{ limit: 12, orderBy: 'activity' }}
finalElement={[ finalElement={[
<AdditionalCard key='store-games-btn' icon={Store} badgeIcon={Search} route='/store/tab/games' id='store-games-btn' title="Gameflow Store" subTitle="Get Free Games and Plugins" index={43} actionLabel="Go To Store" />, <AdditionalCard key='store-games-btn' icon={Store} badgeIcon={Search} route='/store/tab/games' id='store-games-btn' title="Gameflow Store" subTitle="Get Free Games" index={43} actionLabel="Go To Store" />,
<AdditionalCard key='all-games-btn' icon={LayoutGrid} route='/games' id='all-games-btn' title="All Games" subTitle="All Owned Games" index={17} actionLabel="All Games" /> <AdditionalCard key='all-games-btn' icon={LayoutGrid} route='/games' id='all-games-btn' title="All Games" subTitle="All Owned Games" index={17} actionLabel="All Games" />
]} ]}
emptyElement={[ emptyElement={[

View file

@ -8,10 +8,8 @@ import { zodValidator } from "@tanstack/zod-adapter";
import z from "zod"; import z from "zod";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { RefreshCcw, Settings2 } from "lucide-react"; import { RefreshCcw, Settings2 } from "lucide-react";
import { ContextList, DialogEntry } from "../components/ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "../components/ContextDialog";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useContext } from "react";
import { GlobalDialogContext } from "../scripts/contexts";
export const Route = createFileRoute("/platform/$source/$id")({ export const Route = createFileRoute("/platform/$source/$id")({
component: RouteComponent, component: RouteComponent,
@ -47,7 +45,6 @@ function RouteComponent ()
context.client.invalidateQueries(localPlatformFilter(id)); context.client.invalidateQueries(localPlatformFilter(id));
}, },
}); });
const globalDialog = useContext(GlobalDialogContext);
const deletePlatform = useMutation({ const deletePlatform = useMutation({
...deletePlatformMutation(id), ...deletePlatformMutation(id),
onError (error, variables, onMutateResult, context) onError (error, variables, onMutateResult, context)
@ -80,7 +77,7 @@ function RouteComponent ()
if (source === 'local') if (source === 'local')
{ {
settingsOptions.push({ settingsOptions.push({
id: 'delete-platform', id: 'update-platform',
type: "error", type: "error",
content: "Delete", content: "Delete",
icon: deletePlatform.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />, icon: deletePlatform.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />,
@ -91,6 +88,10 @@ function RouteComponent ()
}); });
} }
const { dialog: platformSettingsDialog, setOpen: setPlatformSettingsOpen } = useContextDialog('platform-settings-dialog', {
content: <ContextList options={settingsOptions} />
});
return ( return (
<div className="w-full h-full"> <div className="w-full h-full">
<CollectionsDetail <CollectionsDetail
@ -101,13 +102,14 @@ function RouteComponent ()
icon: <Settings2 />, icon: <Settings2 />,
action () action ()
{ {
globalDialog.openContext({ content: <ContextList options={settingsOptions} /> }, 'open-platform-settings-btn'); setPlatformSettingsOpen(true, 'open-platform-settings-btn');
}, },
}]} }]}
countHint={countHint} countHint={countHint}
title={<PlatformTitle />} title={<PlatformTitle />}
filters={{ platform_id: Number(id), platform_source: source }} filters={{ platform_id: Number(id), platform_source: source }}
/> />
{platformSettingsDialog}
</div> </div>
); );
} }

View file

@ -24,7 +24,6 @@ import { SettingsDropdown } from '@/mainview/components/options/SettingsDropdown
import { FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared'; import { FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared';
import { zodValidator } from '@tanstack/zod-adapter'; import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod'; import z from 'zod';
import { isUrl } from '@/shared/utils';
export const Route = createFileRoute('/settings/emulators')({ export const Route = createFileRoute('/settings/emulators')({
component: RouteComponent, component: RouteComponent,
@ -239,7 +238,7 @@ function EmulatorBadge (data: {
let logoUrl: string | undefined = undefined; let logoUrl: string | undefined = undefined;
if (data.emulator.logo) if (data.emulator.logo)
{ {
if (isUrl(data.emulator.logo)) if (data.emulator.logo.startsWith('http'))
{ {
logoUrl = data.emulator.logo; logoUrl = data.emulator.logo;
} else } else

View file

@ -16,7 +16,6 @@ import classNames from "classnames";
import import
{ {
ArrowBigLeft, ArrowBigLeft,
Cog,
FingerprintPattern, FingerprintPattern,
HardDrive, HardDrive,
Info, Info,
@ -156,12 +155,6 @@ function SettingsMenu (data: {})
label="Plugins" label="Plugins"
icon={<Puzzle />} icon={<Puzzle />}
/> />
<MenuItem
focusSelect
route="/settings/tasks"
label="Tasks"
icon={<Cog />}
/>
<MenuItem <MenuItem
focusSelect focusSelect
route="/settings/directories" route="/settings/directories"

View file

@ -1,123 +0,0 @@
import { Button } from '@/mainview/components/options/Button';
import { jobsApi } from '@/mainview/scripts/clientApi';
import { FrontEndJob } from '@simeonradivoev/gameflow-sdk/shared';
import { createFileRoute } from '@tanstack/react-router';
import { Ban, Clock, Cog, Download, DownloadCloud, Gauge } from 'lucide-react';
import prettyBytes from 'pretty-bytes';
import { useEffect, useRef, useState } from 'react';
export const Route = createFileRoute('/settings/tasks')({
component: RouteComponent,
});
function RouteComponent ()
{
const [activeJobs, setActiveJobs] = useState<FrontEndJob[]>([]);
const [queuedJobs, setQueuedJobs] = useState<FrontEndJob[]>([]);
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 <div>
<div className="divider"><Cog size={48} />Active</div>
<ul className='flex flex-col bg-base-300 p-4 rounded-2xl gap-2'>
{activeJobs.map((job, i) => <li key={i} className='flex items-center gap-4 justify-between'>
<div className='flex items-center gap-4'>
<div className='bg-primary text-primary-content w-32 h-21 rounded-2xl overflow-hidden'>
{job.data.preview_url ? <img className='object-cover' src={job.data.preview_url} /> : <Cog size={128} />}
</div>
<div className='font-semibold text-2xl'>{job.data.name ?? job.id}</div>
</div>
<div className='flex gap-2 items-center'>
<div className='flex flex-col'>
<div className='flex justify-between'>
<div className='text-primary font-semibold'>{job.state}</div>
<div>{job.progress.toFixed(1)}%</div>
</div>
<progress className="progress progress-primary w-sm mb-2" value={job.progress} max="100"></progress>
<div className='flex gap-4'>
{job.data.downloaded != null && job.data.total != null && <div className='flex gap-1 items-center'><Download />{prettyBytes(job.data.downloaded)}/{prettyBytes(job.data.total)}</div>}
{job.data.speed != null && <div className='flex gap-1 items-center'><Gauge />{prettyBytes(job.data.speed)}/s</div>}
</div>
</div>
<Button style='warning' onAction={e => handleCancel(job.id)} id={`'cancel-dl-${job.id}-btn'`}>{job.status === 'aborted' ? <span className="loading loading-spinner loading-lg"></span> : <Ban />}</Button>
</div>
</li>)}
</ul>
<div className="divider"><Clock size={48} /> Queued</div>
<ul className='flex flex-col gap-2 bg-base-300 p-4 rounded-2xl'>
{queuedJobs.map((job, i) => <li key={i} className='flex items-center gap-4 justify-between'>
<div className='flex items-center gap-4'>
<div className='bg-primary w-32 h-21 rounded-2xl'></div>
<div className='font-semibold text-2xl'>{job.data.name ?? job.id}</div>
</div>
<div className='flex gap-2 items-center'>
<div className='flex flex-col'>
<div className='flex gap-4'>
{job.data.total !== undefined && <div className='flex gap-1 items-center'><DownloadCloud />{prettyBytes(job.data.total)}</div>}
</div>
</div>
<Button style='warning' onAction={e => handleCancel(job.id)} id={`'cancel-dl-${job.id}-btn'`}>{job.status === 'aborted' ? <span className="loading loading-spinner loading-lg"></span> : <Ban />}</Button>
</div>
</li>)}
</ul>
</div>;
}

View file

@ -1,129 +0,0 @@
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 <>
<DotsLoading ref={ref} />
<AutoFocus focus={focusSelf} />
</>;
}
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 <div className='flex flex-col'>
<Screenshots className='bg-base-300 h-64' screenshots={screenshots} />
<div className='flex flex-col gap-4'>
<div className='flex bg-base-200 p-16 justify-between'>
<div className=' flex gap-2'>
{!!download.cover_url && <img className='w-32 object-cover rounded-2xl' src={download.cover_url} />}
<div className='flex flex-col grow'>
<div className='font-bold text-2xl'>{download.name}</div>
<div className='flex gap-1'>
<div>{download.date?.toDateString()}</div>
<div className="divider divider-horizontal m-0"></div>
<div>{download.source}</div>
</div>
</div>
</div>
<div className='flex items-center'>
<Button external id='download-btn' className='gap-2 font-semibold text-2xl' style='accent' onAction={(ctx) => data.onDownload(ctx.focusKey!)} ><Download />Download</Button>
</div>
</div>
<div className='flex gap-4 px-16 py-4 justify-center'>
{!!download.summary && <div className='flex prose grow'>
<div dangerouslySetInnerHTML={{ __html: download.summary }}></div>
</div>}
<div>
<div className="divider"><Download size={64} /> Downloads</div>
<ul className='flex flex-col gap-2'>
{download.files.filter(f => f.format && !downloadsBlacklist.has(f.format)).map(f => <li className='flex bg-base-300 gap-2 px-4 py-2 rounded-2xl justify-between'>
{f.id}
<span className='font-semibold'>{!!f.size && prettyBytes(f.size)}</span>
</li>)}
</ul>
</div>
</div>
</div>
</div>;
}
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 <div ref={ref} className='absolute w-full h-full overflow-y-scroll overflow-x-hidden'>
<FocusContext value={focusKey}>
<StickyHeaderUI ref={ref} />
<Details onDownload={(focusKey) => globalDialog.openContext({
content: <ContextList options={data.files.filter(f => 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)} />
<FloatingShortcuts />
</FocusContext>
<SelectMenu rootFocusKey={focusKey} />
<AutoFocus focus={focusSelf} />
</div>;
}

View file

@ -1,4 +1,4 @@
import { useContext, useRef, useState } from "react"; import { useRef, useState } from "react";
import import
{ {
useFocusable, useFocusable,
@ -11,7 +11,7 @@ import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { rommApi, systemApi } from "@/mainview/scripts/clientApi"; import { rommApi, systemApi } from "@/mainview/scripts/clientApi";
import { Button } from "@/mainview/components/options/Button"; 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 { ChevronDown, CircleFadingArrowUp, CloudUpload, Cpu, Download, Fullscreen, Gamepad2, Info, Monitor, Puzzle, Settings, Settings2, Terminal, Trash2, TriangleAlert, WandSparkles } from "lucide-react";
import { ContextList, DialogEntry } from "@/mainview/components/ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
import { RPC_URL } from "@/shared/constants"; import { RPC_URL } from "@/shared/constants";
import Screenshots from "@/mainview/components/Screenshots"; import Screenshots from "@/mainview/components/Screenshots";
import { StickyHeaderUI } from "@/mainview/components/Header"; import { StickyHeaderUI } from "@/mainview/components/Header";
@ -30,7 +30,6 @@ import { AutoFocus } from "@/mainview/components/AutoFocus";
import { FilterUI } from "@/mainview/components/Filters"; import { FilterUI } from "@/mainview/components/Filters";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { FrontEndEmulatorDetailed } from "@simeonradivoev/gameflow-sdk/shared"; import { FrontEndEmulatorDetailed } from "@simeonradivoev/gameflow-sdk/shared";
import { GlobalDialogContext } from "@/mainview/scripts/contexts";
export const Route = createFileRoute('/store/details/emulator/$id')({ export const Route = createFileRoute('/store/details/emulator/$id')({
component: RouteComponent, component: RouteComponent,
@ -66,7 +65,6 @@ function TitleArea (data: {
onUpdate: (source: string) => void; onUpdate: (source: string) => void;
}) })
{ {
const globalDialog = useContext(GlobalDialogContext);
const navigation = useNavigate(); const navigation = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@ -255,12 +253,14 @@ function TitleArea (data: {
installButtonContent = <><TriangleAlert />Unsupported</>; installButtonContent = <><TriangleAlert />Unsupported</>;
} }
const openOptionsDialog = (focusKey: string) => globalDialog.openContext({ content: <ContextList options={options} /> }, focusKey); const { dialog: installOptionsDialog, setOpen } = useContextDialog("install-context-menu", {
content: <ContextList options={options} />
});
const handleOptionsOpen = () => const handleOptionsOpen = () =>
{ {
if (isInstalling || !data.emulator) return false; if (isInstalling || !data.emulator) return false;
openOptionsDialog('install-btn'); setOpen(true, 'install-btn');
}; };
return <div ref={ref} className="flex flex-wrap gap-4 sm:portrait:justify-center md:justify-normal items-center"> return <div ref={ref} className="flex flex-wrap gap-4 sm:portrait:justify-center md:justify-normal items-center">
@ -294,10 +294,10 @@ function TitleArea (data: {
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4 items-center"> <div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4 items-center">
<FocusTooltip visible={hasFocusedChild} parentRef={ref} /> <FocusTooltip visible={hasFocusedChild} parentRef={ref} />
{(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion && <div className="tooltip tooltip-warning" data-tip="Update Available"> {(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion && <div className="tooltip tooltip-warning" data-tip="Update Available">
<Button id="update-warning-bt" tooltipType="warning" tooltip="Update Available" style="warning" className="rounded-full size-14 focusable focusable-warning shadow-lg" onAction={() => openOptionsDialog('update-warning-bt')}><CircleFadingArrowUp /></Button> <Button id="update-warning-bt" tooltipType="warning" tooltip="Update Available" style="warning" className="rounded-full size-14 focusable focusable-warning shadow-lg" onAction={() => setOpen(true, 'update-warning-bt')}><CircleFadingArrowUp /></Button>
</div>} </div>}
{(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore && <div className="tooltip tooltip-error" data-tip="Missing BIOS"> {(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore && <div className="tooltip tooltip-error" data-tip="Missing BIOS">
<Button id="bios-warning-bt" tooltipType="error" tooltip="Missing BIOS" style="error" className="rounded-full size-14 focusable focusable-error shadow-lg" onAction={() => openOptionsDialog('bios-warning-bt')}><TriangleAlert /></Button> <Button id="bios-warning-bt" tooltipType="error" tooltip="Missing BIOS" style="error" className="rounded-full size-14 focusable focusable-error shadow-lg" onAction={() => setOpen(true, 'bios-warning-bt')}><TriangleAlert /></Button>
</div>} </div>}
<Button style="accent" id="install-btn" className="px-8 py-3 rounded-4xl focusable focusable-accent sm:portrait:grow flex-col gap-2 light:ring-offset-7 light:ring-offset-base-100 light:focused:ring-offset-0 shadow-lg" onAction={handleOptionsOpen} > <Button style="accent" id="install-btn" className="px-8 py-3 rounded-4xl focusable focusable-accent sm:portrait:grow flex-col gap-2 light:ring-offset-7 light:ring-offset-base-100 light:focused:ring-offset-0 shadow-lg" onAction={handleOptionsOpen} >
<div className="flex gap-4"> <div className="flex gap-4">
@ -309,6 +309,7 @@ function TitleArea (data: {
{isInstalling && <progress ref={installProgressRef} className="progress" value={0} max="100"></progress>} {isInstalling && <progress ref={installProgressRef} className="progress" value={0} max="100"></progress>}
</Button> </Button>
</div> </div>
{installOptionsDialog}
</FocusContext > </FocusContext >
</div >; </div >;
} }

View file

@ -112,10 +112,10 @@ function Details ()
{!!data.update && <Button onAction={e => update.mutate()} className='gap-2' style='warning' id='install-btn' > {!!data.update && <Button onAction={e => update.mutate()} className='gap-2' style='warning' id='install-btn' >
{update.isPending ? <span className="loading loading-spinner loading-lg"></span> : <CircleFadingArrowUp />} Update {update.isPending ? <span className="loading loading-spinner loading-lg"></span> : <CircleFadingArrowUp />} Update
</Button>} </Button>}
<Button onAction={e => uninstall.mutate()} className='gap-2' style='accent' id='uninstall-btn' > <Button onAction={e => uninstall.mutate()} className='gap-2' style='accent' id='install-btn' >
{uninstall.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Trash />} Uninstall {uninstall.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Trash />} Uninstall
</Button> </Button>
<Button external onAction={e => { navigate({ to: '/settings/plugin/$source', params: { source: encodeURIComponent(plugin) } }); }} className='gap-2' style='info' id='plugin-settings-btn' > <Button external onAction={e => { navigate({ to: '/settings/plugin/$source', params: { source: encodeURIComponent(plugin) } }); }} className='gap-2' style='info' id='install-btn' >
<Settings /> Settings <Settings /> Settings
</Button> </Button>

View file

@ -1,114 +0,0 @@
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';
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 { ArrowRight, DownloadIcon, Eye, MessageCircle, Puzzle, 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 <li onClick={handleAction} ref={ref} className='flex gap-4 bg-base-100 not-mobile:shadow-xl rounded-3xl p-2 focusable focusable-accent focusable-hover cursor-pointer overflow-hidden'>
{!!data.match.cover_url && <img className='min-w-32 w-32 rounded-2xl object-cover' src={data.match.cover_url} />}
<div className='flex flex-col gap-2 justify-center grow w-[calc(100%-16rem)]'>
<div className='font-semibold overflow-hidden text-xl text-shadow-md truncate'>{data.match.name}</div>
<div className='text-base-content/60 overflow-hidden truncate'>{data.match.date?.toDateString()}</div>
<ul className='flex flex-wrap gap-2'>
{!!data.match.size && <li className='flex gap-1 text-base-content bg-base-300 rounded-full px-2 py-1'><Save />{prettyBytes(data.match.size)}</li>}
{!!data.match.download_count && <li className='flex gap-1 text-base-content bg-base-300 rounded-full px-2 py-1'><DownloadIcon />{data.match.download_count}</li>}
{!!data.match.view_count && <li className='flex gap-1 text-base-content bg-base-300 rounded-full px-2 py-1'><Eye />{data.match.view_count}</li>}
{!!data.match.comment_count && <li className='flex gap-1 text-base-content bg-base-300 rounded-full px-2 py-1'><MessageCircle />{data.match.comment_count}</li>}
{!!data.match.rating && <li className='flex gap-1 text-base-content bg-base-300 rounded-full px-2 py-1'><Star />{data.match.rating}</li>}
</ul>
</div>
</li>;
}
function Downloads (data: {
pages: {
data: DownloadLookupEntry[];
totalCount: number;
hadMatchers: boolean;
nextPage: number;
}[];
hasNextPage: boolean,
isFetchingNextPage: boolean,
isFetching: boolean,
fetchNextPage: () => void,
error: string | undefined;
})
{
const navigate = useNavigate();
const { ref, focusKey } = useFocusable({ focusKey: 'downloads-list' });
return <ul ref={ref} className='grid ml-12 h-fit sm:gap-2 md:gap-5 auto-rows-[10rem] grid-cols-1 md:grid-cols-2 lg:grid-cols-3'>
<FocusContext value={focusKey}>
{data.pages.flatMap((page, p) => page.data.map((match, i) => <Download focusKey={`dl-${p}-${i}`} key={match.id} match={match} />))}
{!data.pages[0].hadMatchers && <div className='flex justify-center items-center gap-2 font-semibold text-2xl col-span-3'><Button id='install-plugins-btn' className='gap-2 text-2xl!' onAction={e => navigate({ to: '/store/tab/plugins' })}><Puzzle />Get Donwloads Plugin <ArrowRight /></Button></div>}
{data.hasNextPage && data.pages[0].hadMatchers && <LoadMoreButton
isFetching={data.isFetchingNextPage || data.isFetching}
hidden
onAction={() =>
{
if (data.isFetchingNextPage || data.isFetching)
return;
data.fetchNextPage();
}} />}
{!!data.error}
</FocusContext>
</ul>;
}
function RouteComponent ()
{
const [search] = useSessionStorage<string | undefined>(`${Route.to}-search`, undefined);
const [filter, setFilter] = useSessionStorage<DownloadsLookupFilter>('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 <div ref={ref} className='px-6 py-4 animate-slide-up'>
<FocusContext value={focusKey}>
<div className="divider font-bold uppercase tracking-widest">
{isFetching && <span className="loading loading-xl loading-spinner"></span>}
Results
{isPending ? <span className="loading loading-spinner"></span> : <span className='bg-base-100 px-2 rounded-full'>{data?.pages[0].totalCount}</span>}
</div>
{isPending && <DotsLoading />}
{data && <Downloads hasNextPage={hasNextPage} isFetching={isFetching} isFetchingNextPage={isFetchingNextPage} error={error?.message} pages={data.pages} fetchNextPage={fetchNextPage} />}
<div className='fixed left-2 top-52 bottom-0 sm:w-10 md:w-14 z-10'>
<SideDownloadFilters id={'downloads-lookup-filter'} setLocalFilter={setFilter} localFilter={filter} filterValues={lookupFilters} />
</div>
</FocusContext>
</div>;
}

View file

@ -11,15 +11,10 @@ import { useQuery } from '@tanstack/react-query';
import { storeEmulatorsQuery } from '@queries/store'; import { storeEmulatorsQuery } from '@queries/store';
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
import { useSessionStorage } from 'usehooks-ts'; import { useSessionStorage } from 'usehooks-ts';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
export const Route = createFileRoute('/store/tab/emulators')({ export const Route = createFileRoute('/store/tab/emulators')({
component: RouteComponent, component: RouteComponent,
errorComponent: InvalidStoreError, errorComponent: InvalidStoreError
validateSearch: zodValidator(z.object({
search: z.string().optional()
}))
}); });
function RouteComponent () function RouteComponent ()
@ -31,11 +26,7 @@ function RouteComponent ()
preferredChildFocusKey: focus preferredChildFocusKey: focus
}); });
const storeContext = useContext(StoreContext); const storeContext = useContext(StoreContext);
const { data: emulators } = useQuery({ const { data: emulators } = useQuery({ ...storeEmulatorsQuery({ search }), retry: false, throwOnError: true });
...storeEmulatorsQuery({ search }),
retry: false,
throwOnError: true
});
useEffect(() => useEffect(() =>
{ {
@ -71,7 +62,6 @@ function RouteComponent ()
/> />
)) ?? Array.from({ length: 10 }).map((_, i) => <div key={i} className="skeleton rounded-3xl" />)} )) ?? Array.from({ length: 10 }).map((_, i) => <div key={i} className="skeleton rounded-3xl" />)}
</div> </div>
</FocusContext> </FocusContext>
</section> </section>
</>; </>;

View file

@ -15,7 +15,6 @@ import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod'; import z from 'zod';
import SideFilters from '@/mainview/components/SideFilters'; import SideFilters from '@/mainview/components/SideFilters';
import { gameFiltersQuery } from '@/mainview/scripts/queries/romm'; import { gameFiltersQuery } from '@/mainview/scripts/queries/romm';
import { isUrl } from '@/shared/utils';
export const Route = createFileRoute('/store/tab/games')({ export const Route = createFileRoute('/store/tab/games')({
component: RouteComponent, component: RouteComponent,
@ -69,7 +68,7 @@ function RouteComponent ()
Games Games
</h2> </h2>
</div> </div>
<div className="ml-12"> <div className="pl-12">
<CardList grid finalElement={<LoadMoreButton <CardList grid finalElement={<LoadMoreButton
hidden hidden
lastId={data?.pages.at(-1)?.data.at(-1)?.id} lastId={data?.pages.at(-1)?.data.at(-1)?.id}
@ -91,7 +90,7 @@ function RouteComponent ()
const previewUrls = g.path_covers.map(c => const previewUrls = g.path_covers.map(c =>
{ {
const url = isUrl(c) ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`); const url = c.startsWith('http') ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`);
url.searchParams.delete('ts'); url.searchParams.delete('ts');
return url; return url;
}); });

View file

@ -6,12 +6,17 @@ import { PluginEntryType } from '@simeonradivoev/gameflow-sdk/shared';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { QueryClient, useMutation, useQuery } from '@tanstack/react-query'; import { QueryClient, useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import { CircleFadingArrowUp, Dot, Download, HardDrive, Puzzle } from 'lucide-react'; import { CircleFadingArrowUp, Dot, Download, HardDrive, Puzzle } from 'lucide-react';
import prettyMilliseconds from 'pretty-ms'; import prettyMilliseconds from 'pretty-ms';
import { useSessionStorage } from 'usehooks-ts'; import { useSessionStorage } from 'usehooks-ts';
import z from 'zod';
export const Route = createFileRoute('/store/tab/plugins')({ export const Route = createFileRoute('/store/tab/plugins')({
component: RouteComponent component: RouteComponent,
validateSearch: zodValidator(z.object({
search: z.string().optional()
}))
}); });
function PluginCard (data: { plugin: PluginEntryType; }) function PluginCard (data: { plugin: PluginEntryType; })
@ -102,7 +107,7 @@ function PluginCard (data: { plugin: PluginEntryType; })
{(install.isPending || uninstall.isPending) && <span className="loading loading-spinner loading-lg"></span>} {(install.isPending || uninstall.isPending) && <span className="loading loading-spinner loading-lg"></span>}
</div> </div>
<div className='text-base-content/40'>{data.plugin.package.description}</div> <div className='text-base-content/40'>{data.plugin.package.description}</div>
<ul className='flex flex-wrap gap-2'>{data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map((k, i) => <li key={i} className='bg-base-300 px-2 rounded-full'>{k}</li>)}</ul> <ul className='flex flex-wrap gap-2'>{data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map(k => <li className='bg-base-300 px-2 rounded-full'>{k}</li>)}</ul>
<ul className='flex flex-wrap gap-2'> <ul className='flex flex-wrap gap-2'>
<li>{data.plugin.package.publisher.username}</li> <li>{data.plugin.package.publisher.username}</li>
<Dot /> <Dot />

View file

@ -14,7 +14,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { useMatchRoute, useRouter } from '@tanstack/react-router';
import { createFileRoute, Outlet } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter'; import { zodValidator } from '@tanstack/zod-adapter';
import { DownloadCloud, Gamepad2, Home, Joystick, Puzzle } from 'lucide-react'; import { Gamepad2, Home, Joystick, Puzzle } from 'lucide-react';
import { useRef } from 'react'; import { useRef } from 'react';
import { useSessionStorage } from 'usehooks-ts'; import { useSessionStorage } from 'usehooks-ts';
import z from 'zod'; import z from 'zod';
@ -97,7 +97,6 @@ function RouteComponent ()
home: { label: "Home", icon: <Home />, selected: useIsSettings(''), }, home: { label: "Home", icon: <Home />, selected: useIsSettings(''), },
emulators: { label: "Emulators", icon: <Joystick />, selected: useIsSettings('emulators') }, emulators: { label: "Emulators", icon: <Joystick />, selected: useIsSettings('emulators') },
games: { label: "Games", icon: <Gamepad2 />, selected: useIsSettings('games') }, games: { label: "Games", icon: <Gamepad2 />, selected: useIsSettings('games') },
download: { label: "Download", icon: <DownloadCloud />, selected: useIsSettings('download') },
plugins: { label: "Plugins", icon: <Puzzle />, selected: useIsSettings('plugins') } plugins: { label: "Plugins", icon: <Puzzle />, selected: useIsSettings('plugins') }
}; };
const [search, setSearch] = useSessionStorage<string | undefined>(`${router.history.location.pathname}-search`, undefined); const [search, setSearch] = useSessionStorage<string | undefined>(`${router.history.location.pathname}-search`, undefined);

View file

@ -1,4 +1,4 @@
import { SystemInfoType, Drive, AppInfoContext } from '@simeonradivoev/gameflow-sdk/shared'; import { SystemInfoType, Drive } from '@simeonradivoev/gameflow-sdk/shared';
import { Direction, FocusDetails } from "@noriginmedia/norigin-spatial-navigation"; import { Direction, FocusDetails } from "@noriginmedia/norigin-spatial-navigation";
import { createContext } from "react"; import { createContext } from "react";
import { Shortcut } from "./shortcuts"; import { Shortcut } from "./shortcuts";
@ -45,16 +45,6 @@ export const ShortcutsContext = createContext({} as {
export const SystemInfoContext = createContext({} as SystemInfoType | undefined); export const SystemInfoContext = createContext({} as SystemInfoType | undefined);
export const AppContext = createContext({} as AppInfoContext);
export const GlobalDialogContext = createContext({} as {
openContext: (options: {
content: any;
preferredChildFocusKey?: string;
onClose?: () => void;
}, focusKey: string) => void;
});
export const GameDetailsContext = createContext<{ export const GameDetailsContext = createContext<{
update: () => void; update: () => void;
}>({} as any); }>({} as any);

View file

@ -1,7 +1,7 @@
import { DefaultRommStaleTime } from "@/shared/constants"; import { DefaultRommStaleTime } from "@/shared/constants";
import { GameListFilterType, RommLoginDataSchema, FrontEndId, DownloadLookupEntry, DownloadsLookupFilter } from '@simeonradivoev/gameflow-sdk/shared'; import { GameListFilterType, RommLoginDataSchema, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared';
import { rommApi, settingsApi } from "../clientApi"; import { rommApi, settingsApi } from "../clientApi";
import { infiniteQueryOptions, InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions } from "@tanstack/react-query"; import { InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions } from "@tanstack/react-query";
import z from "zod"; import z from "zod";
import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
@ -294,40 +294,3 @@ export const addManualGameMutation = mutationOptions({
return data; return data;
} }
}); });
export const downloadsLookupQuery = (filter: DownloadsLookupFilter) => infiniteQueryOptions<{
data: DownloadLookupEntry[],
totalCount: number,
nextPage: number;
hadMatchers: boolean;
}>({
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, hadMatchers: data.hadMatchers, 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;
}
});

View file

@ -12,9 +12,7 @@ export const FOCUS_KEYS = {
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`, EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
GAME_SECTION: "GAME_SECTION", GAME_SECTION: "GAME_SECTION",
GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, 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}`, GAME_MATCH: (id: FrontEndId) => `GAME_${id.source}_${id.id}`,
STATS_SECTION: "STATS_SECTION", 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; } as const;

View file

@ -50,7 +50,6 @@ declare interface GameMeta extends FocusParams
{ {
id: string, id: string,
onSelect?: () => void, onSelect?: () => void,
onQuickAction?: () => void,
title: string, title: string,
subtitle?: any, subtitle?: any,
previewUrls?: string | URL[]; previewUrls?: string | URL[];

0
src/packages/gameflow-sdk/build.ts Executable file → Normal file
View file

View file

@ -1,9 +1,7 @@
import { AsyncSeriesBailHook } from "tapable";
import AuthHooks from "./auth"; import AuthHooks from "./auth";
import EmulatorHooks from "./emulators"; import EmulatorHooks from "./emulators";
import GameHooks from "./games"; import GameHooks from "./games";
import StoreHooks from "./store"; import StoreHooks from "./store";
import { DownloadFileEntry, ProgressStats } from "../shared";
export class GameflowHooks export class GameflowHooks
{ {
@ -11,39 +9,4 @@ export class GameflowHooks
emulators = new EmulatorHooks(); emulators = new EmulatorHooks();
auth = new AuthHooks(); auth = new AuthHooks();
store = new StoreHooks(); 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']);
} }

View file

@ -5,7 +5,6 @@ import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable";
export default class EmulatorHooks export default class EmulatorHooks
{ {
/** Download emulator bios files */
fetchBiosDownload = new AsyncSeriesBailHook<[ctx: { fetchBiosDownload = new AsyncSeriesBailHook<[ctx: {
emulator: string; emulator: string;
systems: EmulatorSystem[]; systems: EmulatorSystem[];
@ -16,9 +15,7 @@ export default class EmulatorHooks
* Triggered when emulator is downloaded or updated * Triggered when emulator is downloaded or updated
*/ */
emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContextType], { emulator: string; }>(['ctx']); 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']); findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']);
/** Match emulators for a given system */
findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']); findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']);
constructor() constructor()

View file

@ -1,32 +1,30 @@
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 { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '../shared';
import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable';
export default class GameHooks export default class GameHooks
{ {
/** Build commands the game can be launched with. */
buildLaunchCommands = new AsyncSeriesBailHook<[ctx: { buildLaunchCommands = new AsyncSeriesBailHook<[ctx: {
source: string | null; source: string | null;
sourceId: string | null; sourceId: string | null;
id: FrontEndId; id: FrontEndId;
systemSlug: string; systemSlug: string;
gamePath: string | null, gamePath: string | null,
/** The glob pattern for the main executable of the game */
mainGlob?: string | null, mainGlob?: string | null,
}], CommandEntry[] | Error | undefined>(['ctx']); }], CommandEntry[] | Error | undefined>(['ctx']);
/** override the launch command for an emulator /** 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. * @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. * If no emulator bin in the command entry is found the actual command will be used as the bin.
*/ */
emulatorLaunch = new AsyncSeriesBailHook<[ctx: { emulatorLaunch = new AsyncSeriesBailHook<[ctx: {
/** The auto generated command for example based on the ES-DE listing */
autoValidCommand: CommandEntry; autoValidCommand: CommandEntry;
/** Don't actually launch just see if it can be launched */
dryRun: boolean, dryRun: boolean,
game: { game: {
/** The source of the game */
source?: string; source?: string;
/** The ID of the source. This could be for example the ROMM ID the game was */
sourceId?: string; sourceId?: string;
id: FrontEndId; id: FrontEndId;
platformSlug?: string; platformSlug?: string;
@ -43,36 +41,34 @@ export default class GameHooks
}], EmulatorSupport | undefined, { emulator: string; }>(['ctx']); }], EmulatorSupport | undefined, { emulator: string; }>(['ctx']);
/** /**
* Fetches and returns a list of games converted to frontend. * Fetches and returns a list of games converted to frontend.
* @param ctx.localGameIds This is local game ids in the format '<source>@<sourceId>'
*/ */
fetchGames = new AsyncSeriesHook<[ctx: { fetchGames = new AsyncSeriesHook<[ctx: {
query: GameListFilterType; query: GameListFilterType;
games: FrontEndGameTypeWithIds[]; games: FrontEndGameTypeWithIds[];
}]>(['ctx']); }]>(['ctx']);
/** Return all filters the users can apply for a give source. */
fetchFilters = new AsyncSeriesHook<[ctx: { fetchFilters = new AsyncSeriesHook<[ctx: {
source?: string; source?: string;
filters: FrontEndFilterSets; filters: FrontEndFilterSets;
}]>(['ctx']); }]>(['ctx']);
/** Get game metadata */
fetchGame = new AsyncSeriesBailHook<[ctx: { fetchGame = new AsyncSeriesBailHook<[ctx: {
source: string; source: string;
localGame?: FrontEndGameTypeDetailed; localGame?: FrontEndGameTypeDetailed;
id: string; id: string;
}], FrontEndGameTypeDetailed | undefined>(['ctx']); }], FrontEndGameTypeDetailed | undefined>(['ctx']);
/** Search for a given game based on the igdb id or ra id. */
searchGame = new AsyncSeriesBailHook<[ctx: { searchGame = new AsyncSeriesBailHook<[ctx: {
source: string; source: string;
igdb_id?: number; igdb_id?: number;
ra_id?: number; ra_id?: number;
}], FrontEndGameTypeDetailed | undefined>(['ctx']); }], FrontEndGameTypeDetailed | undefined>(['ctx']);
/** Get download file URLs */ /** Get download file URLs
* @param ctx.checksum Check if file already exists using checksums
*/
fetchDownloads = new AsyncSeriesBailHook<[ctx: { fetchDownloads = new AsyncSeriesBailHook<[ctx: {
source: string; source: string;
id: string; id: string;
/** If there are multiple downloads, use the one with same ID */
downloadId?: string; downloadId?: string;
}], DownloadInfo[] | undefined>(['ctx']); }], DownloadInfo[] | undefined>(['ctx']);
/** Get the paths to rom files. This is mainly used for emulator js. */
fetchRomFiles = new AsyncSeriesBailHook<[ctx: { fetchRomFiles = new AsyncSeriesBailHook<[ctx: {
source: string; source: string;
id: string; id: string;
@ -90,7 +86,6 @@ export default class GameHooks
source: string; source: string;
id: string; id: string;
}], FrontEndPlatformType | undefined>(['ctx']); }], FrontEndPlatformType | undefined>(['ctx']);
/** Lookup a given platform with a given slug or id. This may or may not exist. */
platformLookup = new AsyncSeriesBailHook<[ctx: { platformLookup = new AsyncSeriesBailHook<[ctx: {
source?: string; source?: string;
id?: string; id?: string;
@ -101,23 +96,6 @@ export default class GameHooks
name?: string; name?: string;
family_name?: string; family_name?: string;
} | undefined>(['ctx']); } | 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<string, {
count: number;
items: DownloadLookupEntry[];
}>, 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<string, GameLookup[]>, ctx: { gameLookup = new AsyncSeriesWaterfallHook<[matches: Map<string, GameLookup[]>, ctx: {
source?: string, source?: string,
id?: string; id?: string;
@ -126,7 +104,6 @@ export default class GameHooks
fetchPlatforms = new AsyncSeriesHook<[ctx: { fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[]; platforms: FrontEndPlatformType[];
}]>(['ctx']); }]>(['ctx']);
/** Called before the game is played. */
prePlay = new AsyncSeriesHook<[ctx: { prePlay = new AsyncSeriesHook<[ctx: {
source: string, source: string,
id: string; id: string;
@ -138,25 +115,20 @@ export default class GameHooks
}; };
}]>(["ctx"]); }]>(["ctx"]);
/** /**
* Called after the game process has finished. * @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
*/ */
postPlay = new AsyncSeriesHook<[ctx: { postPlay = new AsyncSeriesHook<[ctx: {
source: string, source: string,
id: string; id: string;
saveFolderSlots?: SaveSlots; saveFolderSlots?: SaveSlots;
/** Auto detected changed files. This is mainly used to see what changed during gameplay */
changedSaveFiles: { subPath: string, cwd: string; }[], changedSaveFiles: { subPath: string, cwd: string; }[],
/** This will be final valid changes to be saved using save integrations like rclone */
validChangedSaveFiles: Record<string, SaveFileChange>, validChangedSaveFiles: Record<string, SaveFileChange>,
/** The command that was used to launch the game */
command: CommandEntry; command: CommandEntry;
gameInfo: { gameInfo: {
platformSlug?: string; platformSlug?: string;
}; };
}]>(["ctx"]); }]>(["ctx"]);
/** Called after game install
* This includes game being downloaded and registered in the database.
*/
postInstall = new AsyncSeriesHook<[ctx: { postInstall = new AsyncSeriesHook<[ctx: {
source: string, source: string,
id: string; id: string;

View file

@ -41,8 +41,7 @@ export const PluginDescriptionSchema = z.object({
peerDependencies: z.record(z.string(), z.string()).optional(), peerDependencies: z.record(z.string(), z.string()).optional(),
category: z.string().default("other"), category: z.string().default("other"),
main: z.string().describe("The main entry. It must export a default class implementing PluginType"), 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({ export const PluginSchema = z.object({

View file

@ -13,6 +13,10 @@
"peerDependencies": { "peerDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"@auth/core": "^0.34.3", "@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", "cheerio": "^1.2.0",
"conf": "^15.1.0", "conf": "^15.1.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
@ -32,8 +36,12 @@
"pathe": "^2.0.3", "pathe": "^2.0.3",
"slugify": "^1.6.9", "slugify": "^1.6.9",
"smol-toml": "^1.6.1", "smol-toml": "^1.6.1",
"systeminformation": "^5.31.5",
"tapable": "^2.3.3", "tapable": "^2.3.3",
"tough-cookie": "^6.0.1",
"tough-cookie-file-store": "^3.3.0",
"unzip-stream": "^0.3.4", "unzip-stream": "^0.3.4",
"webview-bun": "^2.4.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"keywords": [ "keywords": [

View file

@ -250,7 +250,6 @@ export interface EmulatorSourceEntryType
binPath: string; binPath: string;
rootPath?: string; rootPath?: string;
type: EmulatorSourceType; type: EmulatorSourceType;
/** Does the emulator exist in the file system */
exists: boolean; exists: boolean;
} }
@ -490,15 +489,6 @@ export interface GameInstallProgress
export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted'; export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted';
export type GameInstallProgressEvent = 'refresh'; export type GameInstallProgressEvent = 'refresh';
export interface FrontEndJob
{
id: string;
data: any;
progress: number;
state?: string;
status: string;
}
export interface FrontendPlugin export interface FrontendPlugin
{ {
name: string; name: string;
@ -632,79 +622,10 @@ 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 export interface AutoSaveChange
{ {
subPath: string; subPath: string;
cwd: string; cwd: string;
} }
export interface AppInfoContext
{
activeTaskProgress: number | null;
}
export type SaveSlots = Record<string, { cwd: string; }>; export type SaveSlots = Record<string, { cwd: string; }>;
/** Jobs that are downloading stuff can implement this data interface to show up in the downloads screen */
export interface DownloadJobData extends Partial<Omit<ProgressStats, 'progress'>>
{
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[];
}

View file

@ -18,24 +18,14 @@ export class TaskQueue
}); });
} }
public enqueue<T> (id: string, job: T, options?: { throwOnCancel?: boolean; }): T extends IJob<infer TData, infer TState extends string> public enqueue<T> (id: string, job: T, throwOnError?: boolean): T extends IJob<infer TData, infer TState extends string>
? Promise<TData> ? Promise<TData>
: never : never
{ {
this.disposeSafeguard(); this.disposeSafeguard();
if (!this.queue || !this.events) throw new Error("Queue disposed"); if (!this.queue || !this.events) throw new Error("Queue disposed");
if (this.activeQueue.some(j => j.id === id)) throw new Error(`Job with ID ${id} already active`); const context = new JobContext<any, any, any>(id, this.events, job);
if (this.queue.some(j => j.id === id)) throw new Error(`Job with ${id} already queued`);
const context = new JobContext<any, any, any>(id, this.events, job, options);
this.queue.push(context as any); 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.events?.emit('queued', { id: context.id, job: context });
this.processQueue(); this.processQueue();
return context.promise.promise as any; return context.promise.promise as any;
@ -45,24 +35,7 @@ export class TaskQueue
{ {
if (!this.queue) return Promise.resolve(); if (!this.queue) return Promise.resolve();
let activeGroupsSet = new Set(this.activeQueue.filter(j => j.job.group).map(j => j.job.group)); const next = this.queue.filter(j => !j.job.group || !this.activeQueue.some(a => a.job.group === j.job.group)).map((job, i) => ({ i, job }));
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)); next.reverse().forEach(({ i }) => this.queue!.splice(i, 1));
@ -91,11 +64,6 @@ export class TaskQueue
return this.activeQueue.length > 0; return this.activeQueue.length > 0;
} }
public hasQueued ()
{
return this.queue && this.queue.length > 0;
}
public hasActiveOfType (type: any) public hasActiveOfType (type: any)
{ {
for (const entry of this.activeQueue) for (const entry of this.activeQueue)
@ -114,38 +82,6 @@ export class TaskQueue
return job?.promise.promise ?? Promise.resolve(); return job?.promise.promise ?? Promise.resolve();
} }
public waitForAll ()
{
return new Promise((resolve) =>
{
if (!this.hasActive())
{
resolve(true);
return;
}
const handleEnded = () =>
{
if (!this.hasActive() && !this.hasQueued())
{
resolve(true);
this.events?.removeListener('ended', handleEnded);
this.events?.removeListener('abort', handleEnded);
}
};
this.events?.on('ended', handleEnded);
this.events?.on('abort', handleEnded);
});
}
public cancelJob (id: string)
{
const job = this.queue?.find(j => j.id === id)
?? this.activeQueue?.find(j => j.id === id);
job?.abort('cancel');
}
public findJob<T> ( public findJob<T> (
id: string, id: string,
type: new (...args: any[]) => T type: new (...args: any[]) => T
@ -163,16 +99,6 @@ export class TaskQueue
return undefined as any; return undefined as any;
} }
public getActiveJobs ()
{
return this.activeQueue;
}
public getQueuedJobs ()
{
return this.queue;
}
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
{ {
this.events?.on(event, listener); this.events?.on(event, listener);
@ -244,7 +170,6 @@ export interface CompletedEvent extends BaseEvent
export interface IJob<TData, TState extends string> export interface IJob<TData, TState extends string>
{ {
/** What group does the job belong to. Grouped jobs can only have 1 active job per group */
group?: string; group?: string;
start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>; start (context: JobContext<IJob<TData, TState>, TData, TState>): Promise<any>;
exposeData?(): TData; exposeData?(): TData;
@ -285,14 +210,12 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
private events: EventEmitter<EventsList>; private events: EventEmitter<EventsList>;
private abortController: AbortController; private abortController: AbortController;
private m_promise: PromiseWithResolvers<TData | undefined>; private m_promise: PromiseWithResolvers<TData | undefined>;
private throwOnCancel: boolean;
private readonly m_job: T; private readonly m_job: T;
constructor(id: string, events: EventEmitter<EventsList>, job: T, options?: { throwOnCancel?: boolean; }) constructor(id: string, events: EventEmitter<EventsList>, job: T)
{ {
this.m_id = id; this.m_id = id;
this.m_job = job; this.m_job = job;
this.throwOnCancel = options?.throwOnCancel ?? false;
this.abortController = new AbortController(); this.abortController = new AbortController();
this.abortController.signal.addEventListener('abort', () => this.abortController.signal.addEventListener('abort', () =>
{ {
@ -324,13 +247,7 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
{ {
if (error.target instanceof AbortSignal) if (error.target instanceof AbortSignal)
{ {
if (this.throwOnCancel) this.m_promise.resolve(undefined);
{
this.m_promise.reject(this.abortSignal.reason);
} else
{
this.m_promise.resolve(undefined);
}
} else } else
{ {
console.error(error); console.error(error);

View file

0
src/shared/types.ts Normal file
View file

View file

@ -23,11 +23,3 @@ export async function delay (delay: number | Date, signal?: AbortSignal)
}); });
}; };
const urlRegex = /^https?:\/\//;
export function isUrl (value: string | undefined)
{
if (!value) return false;
return urlRegex.test(value);
}

View file

@ -1,20 +1,18 @@
import { beforeAll, beforeEach, afterEach } from 'bun:test'; import { beforeAll, beforeEach, afterEach } from 'bun:test';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import * as app from '@/bun/api/app'; import * as app from '@/bun/api/app';
import { ensureDir, remove } from 'fs-extra'; import { remove } from 'fs-extra';
export async function LoadApp () export async function LoadApp ()
{ {
console.log("Loading App"); console.log("Loading App");
await app.load(); await app.load();
await app.taskQueue.waitForAll();
} }
export async function CleanupApp () export async function CleanupApp ()
{ {
console.log("Cleaning Up App"); console.log("Cleaning Up App");
await app.cleanup(); await app.cleanup();
await app.resetCleanup();
} }
beforeAll(async () => beforeAll(async () =>
@ -22,7 +20,7 @@ beforeAll(async () =>
process.env.CUSTOM_STORE_PATH = resolve('./src/tests/mock-store'); process.env.CUSTOM_STORE_PATH = resolve('./src/tests/mock-store');
process.env.CONFIG_CWD = resolve('./src/tests/mock-config'); process.env.CONFIG_CWD = resolve('./src/tests/mock-config');
process.env.DEFAULT_DOWNLOAD_PATH = resolve('./src/tests/mock-roms'); process.env.DEFAULT_DOWNLOAD_PATH = resolve('./src/tests/mock-roms');
process.env.PLUGIN_BLACKLIST = 'com.simeonradivoev.gameflow.rclone,@simeonradivoev/gameflow-store,com.simeonradivoev.gameflow.romm,com.simeonradivoev.gameflow.igdb,@simeonradivoev/gameflow-sdk'; process.env.PLUGIN_BLACKLIST = 'com.simeonradivoev.gameflow.rclone';
}); });
async function FileCleanup () async function FileCleanup ()