feat: Implemented launching and downloading of roms

This is just an initial implementation lots of kings to iron out
This commit is contained in:
Simeon Radivoev 2026-02-19 16:10:29 +02:00
parent ef08fa6114
commit f15bf9a1e0
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
117 changed files with 37776 additions and 1073 deletions

4
.gitignore vendored
View file

@ -21,4 +21,6 @@ dist-*
*.tar.gz *.tar.gz
settings.local.json settings.local.json
.tanstack .tanstack
artifacts artifacts
trace
downloads

7
.vscode/launch.json vendored
View file

@ -16,7 +16,7 @@
"internalConsoleOptions": "neverOpen", "internalConsoleOptions": "neverOpen",
"request": "attach", "request": "attach",
"name": "Attach Bun", "name": "Attach Bun",
"url": "ws://127.0.0.1:9229/7lt63qegtr8", "url": "ws://127.0.0.1:9229/54esztvxlfe",
"localRoot": "${workspaceFolder}", "localRoot": "${workspaceFolder}",
"stopOnEntry": false, "stopOnEntry": false,
} }
@ -24,7 +24,10 @@
"compounds": [ "compounds": [
{ {
"name": "Attach Debug App", "name": "Attach Debug App",
"configurations": ["Attach Bun", "Attach to Edge"], "configurations": [
"Attach Bun",
"Attach to Edge"
],
"stopAll": true, "stopAll": true,
"preLaunchTask": "bun: dev" "preLaunchTask": "bun: dev"
} }

10
.vscode/settings.json vendored
View file

@ -10,7 +10,6 @@
"search.exclude": { "search.exclude": {
"**/*.gen.ts": true, "**/*.gen.ts": true,
"src/mainview/gen/*": true, "src/mainview/gen/*": true,
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"[typescriptreact]": { "[typescriptreact]": {
@ -22,6 +21,13 @@
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"cSpell.words": [ "cSpell.words": [
"elysia" "elysia",
"elysiajs",
"gameflow",
"hackolade",
"keytar",
"norigin",
"noriginmedia",
"romm"
] ]
} }

View file

@ -17,6 +17,8 @@ Focused on building a simple user experience and intuitive UI.
- Not tested on Mac yet - Not tested on Mac yet
- **Steam Deck Support**: Extensively tested with the steam deck. It can use flatpak installed browsers. - **Steam Deck Support**: Extensively tested with the steam deck. It can use flatpak installed browsers.
- **Great for Controllers**: The UI is inspired by the switch and works great with joysticks and dpads. - **Great for Controllers**: The UI is inspired by the switch and works great with joysticks and dpads.
- **Automatic Download** Downloads roms from ROMM automatically
- **Automatic Emulator Discovery** Using the configs of the excellent ES-DE to discover installed emulators and launch games.
## Screenshots ## Screenshots
@ -47,6 +49,14 @@ Focused on building a simple user experience and intuitive UI.
``` ```
Builds will go in `/builds/<platform>`. Builds will go in `/builds/<platform>`.
4. Additional Commands:
- ```bun run mappings:generate``` converts the es-de configs into local sqlite configs with mappings to rom systems
- ```bun run drizzle:generate``` generates sqlite migrations based on the app schema
- ```bun run openapi-ts``` generated the openapi client calls from romm's API
- ```bun run package``` builds an executable
- ```bun run package:auto-prod``` builds and executable for production
### Tech Stack ### Tech Stack
- [Bun](https://bun.com/) for the backend - [Bun](https://bun.com/) for the backend

370
bun.lock
View file

@ -9,10 +9,15 @@
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.6", "@elysiajs/eden": "^1.4.6",
"@elysiajs/static": "^1.4.7", "@elysiajs/static": "^1.4.7",
"@hackolade/keytar": "^7.9.0-7",
"@rcompat/webview": "^0.18.0", "@rcompat/webview": "^0.18.0",
"cheerio": "^1.2.0",
"conf": "^15.0.2", "conf": "^15.0.2",
"drizzle-orm": "^0.45.1",
"elysia": "^1.4.22", "elysia": "^1.4.22",
"get-folder-size": "^5.0.0",
"node-downloader-helper": "^2.1.10",
"node-stream-zip": "^1.15.0",
"open": "^11.0.0",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"tough-cookie": "^6.0.0", "tough-cookie": "^6.0.0",
"tough-cookie-file-store": "^3.3.0", "tough-cookie-file-store": "^3.3.0",
@ -39,12 +44,16 @@
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"daisyui": "^5.5.14", "daisyui": "^5.5.14",
"drizzle-kit": "^0.31.9",
"dts-bundle-generator": "^9.5.1",
"eden-tanstack-query": "^0.0.9",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-error-boundary": "^6.1.0", "react-error-boundary": "^6.1.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"sass-embedded": "^1.97.3",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@ -53,6 +62,7 @@
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-svg-icons-ng": "^1.5.2", "vite-plugin-svg-icons-ng": "^1.5.2",
"vite-static-assets-plugin": "^1.2.2", "vite-static-assets-plugin": "^1.2.2",
"vite-tsconfig-paths": "^6.1.1",
}, },
}, },
}, },
@ -103,6 +113,10 @@
"@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.11.0", "", {}, "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="],
"@elysiajs/eden": ["@elysiajs/eden@1.4.6", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-Tsa4NwXEWg/u73vWiYZQ3L5/ecgZSxqiEjYwpS+4qBKXeTZqZKl2hcgHJSVBL+InEDMi35Xugct7qyAXE5oM4Q=="], "@elysiajs/eden": ["@elysiajs/eden@1.4.6", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-Tsa4NwXEWg/u73vWiYZQ3L5/ecgZSxqiEjYwpS+4qBKXeTZqZKl2hcgHJSVBL+InEDMi35Xugct7qyAXE5oM4Q=="],
@ -111,69 +125,61 @@
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@hackolade/keytar": ["@hackolade/keytar@7.9.0-7", "", { "dependencies": { "@hackolade/keytar-darwin-arm64": "7.9.0-7", "@hackolade/keytar-darwin-x64": "7.9.0-7", "@hackolade/keytar-linux-arm64": "7.9.0-7", "@hackolade/keytar-linux-x64": "7.9.0-7", "@hackolade/keytar-win32-x64": "7.9.0-7" } }, "sha512-1U4Wfo3dbP63Dcl+SZyHgy3Q+sOdKzvZjuEu01BxDq4A/gtB/1e3Q9HouWUY37xdcTRSG1rD4iYirbQ79RN2iQ=="], "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@hackolade/keytar-darwin-arm64": ["@hackolade/keytar-darwin-arm64@7.9.0-7", "", {}, "sha512-A4rE3nAnjtJ0JaKfXoYLGDHky0JbIK2AqyZUcpSTIfVo28rBhqbfY8JPqEfgwibInKO1vjDUeizlhSOP0Q5RQA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@hackolade/keytar-darwin-x64": ["@hackolade/keytar-darwin-x64@7.9.0-7", "", {}, "sha512-xc/B1MrTD9cfxArBVto2dL9d9noRHgT/ZDZfBlfCEfb2pmbfg83WipDKxuu0nvL2Pzs2Ob26sBbFAxQE5djWIQ=="],
"@hackolade/keytar-linux-arm64": ["@hackolade/keytar-linux-arm64@7.9.0-7", "", {}, "sha512-G99cXS3li/mnW3qtncLAsDNPpx6Jqut1HnRgJVnO1RNotGdGU6EDcTog4pPHy7TVSwsc3QZ3Jay5wfJ0meXnSQ=="],
"@hackolade/keytar-linux-x64": ["@hackolade/keytar-linux-x64@7.9.0-7", "", {}, "sha512-Zx2e4aSbt3Ti0727GQMohlMqBOc7KElXOpZeW+F822U67CMckulJO0D4jcRKov5Kg3eO0nqCVT0GAnvLX6xtXw=="],
"@hackolade/keytar-win32-x64": ["@hackolade/keytar-win32-x64@7.9.0-7", "", {}, "sha512-tsSNLp5N8w7c3cauMxOpO5/ZVnEQ5TRDiAwZAQxI1TRJ3zerS7GmDbUhriZPU22d1qp4q9Fd+nQFhOe4NvJ8Lg=="],
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.6.0", "", { "dependencies": { "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-O6TRP3g1188gdpZC9yio64KkvWD97QpLjaCFnwTgykTZtzQXZbkyw+lYPVfZ7Ee+m7eLau1/jEJmQcUY9G0sLw=="], "@hey-api/codegen-core": ["@hey-api/codegen-core@0.6.0", "", { "dependencies": { "@hey-api/types": "0.1.3", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-O6TRP3g1188gdpZC9yio64KkvWD97QpLjaCFnwTgykTZtzQXZbkyw+lYPVfZ7Ee+m7eLau1/jEJmQcUY9G0sLw=="],
@ -213,6 +219,34 @@
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@rcompat/assert": ["@rcompat/assert@0.6.0", "", { "dependencies": { "@rcompat/is": "^0.4.0", "@rcompat/type": "^0.9.0" } }, "sha512-V8YrttJqBNsLo9DQkXGpPt09LqfUGvwA30q8Tf+uukEhE6Nraw4jM4Nq0c5yKQF0IWv1eSoPUJyCO4W4neS5IA=="], "@rcompat/assert": ["@rcompat/assert@0.6.0", "", { "dependencies": { "@rcompat/is": "^0.4.0", "@rcompat/type": "^0.9.0" } }, "sha512-V8YrttJqBNsLo9DQkXGpPt09LqfUGvwA30q8Tf+uukEhE6Nraw4jM4Nq0c5yKQF0IWv1eSoPUJyCO4W4neS5IA=="],
"@rcompat/dict": ["@rcompat/dict@0.3.1", "", { "dependencies": { "@rcompat/assert": "^0.6.0", "@rcompat/is": "^0.4.2" } }, "sha512-eWZ4ACk0DpT8PS+umVlp/TmFfWAD0yqkGxfvvtfL/9fqPEh1bcCFtGMySCwmTGx/FU8sPnxwnSiZGZmN36gTBQ=="], "@rcompat/dict": ["@rcompat/dict@0.3.1", "", { "dependencies": { "@rcompat/assert": "^0.6.0", "@rcompat/is": "^0.4.2" } }, "sha512-eWZ4ACk0DpT8PS+umVlp/TmFfWAD0yqkGxfvvtfL/9fqPEh1bcCFtGMySCwmTGx/FU8sPnxwnSiZGZmN36gTBQ=="],
@ -429,6 +463,10 @@
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
@ -445,6 +483,8 @@
"color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="],
"colorjs.io": ["colorjs.io@0.5.2", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="],
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
@ -507,19 +547,31 @@
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"dts-bundle-generator": ["dts-bundle-generator@9.5.1", "", { "dependencies": { "typescript": ">=5.0.2", "yargs": "^17.6.0" }, "bin": { "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" } }, "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA=="],
"eden-tanstack-query": ["eden-tanstack-query@0.0.9", "", { "peerDependencies": { "@elysiajs/eden": ">=1.0.0", "@tanstack/query-core": "^5.90.16" } }, "sha512-EYnFasVEFHFZ9aoI2TDFHU+q0gRdvnFYvX8QzzjXww4dLy0qXDNTGvywmLuiqfiICDC2y4oh2/ZpIPFkGTLNPQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.277", "", {}, "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw=="], "electron-to-chromium": ["electron-to-chromium@1.5.277", "", {}, "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw=="],
"elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="], "elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@ -553,12 +605,16 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-folder-size": ["get-folder-size@5.0.0", "", { "bin": { "get-folder-size": "bin/get-folder-size.js" } }, "sha512-+fgtvbL83tSDypEK+T411GDBQVQtxv+qtQgbV+HVa/TYubqDhNd5ghH/D6cOHY9iC5/88GtOZB7WI8PXy2A3bg=="],
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@ -567,8 +623,14 @@
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"immutable": ["immutable@5.1.4", "", {}, "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
@ -659,12 +721,18 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"node-downloader-helper": ["node-downloader-helper@2.1.10", "", { "bin": { "ndh": "bin/ndh" } }, "sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-html-parser": ["node-html-parser@7.0.2", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ=="], "node-html-parser": ["node-html-parser@7.0.2", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"node-stream-zip": ["node-stream-zip@1.15.0", "", {}, "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"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=="],
@ -679,6 +747,12 @@
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@ -739,6 +813,48 @@
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sass": ["sass@1.97.3", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg=="],
"sass-embedded": ["sass-embedded@1.97.3", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", "immutable": "^5.0.2", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-all-unknown": "1.97.3", "sass-embedded-android-arm": "1.97.3", "sass-embedded-android-arm64": "1.97.3", "sass-embedded-android-riscv64": "1.97.3", "sass-embedded-android-x64": "1.97.3", "sass-embedded-darwin-arm64": "1.97.3", "sass-embedded-darwin-x64": "1.97.3", "sass-embedded-linux-arm": "1.97.3", "sass-embedded-linux-arm64": "1.97.3", "sass-embedded-linux-musl-arm": "1.97.3", "sass-embedded-linux-musl-arm64": "1.97.3", "sass-embedded-linux-musl-riscv64": "1.97.3", "sass-embedded-linux-musl-x64": "1.97.3", "sass-embedded-linux-riscv64": "1.97.3", "sass-embedded-linux-x64": "1.97.3", "sass-embedded-unknown-all": "1.97.3", "sass-embedded-win32-arm64": "1.97.3", "sass-embedded-win32-x64": "1.97.3" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-eKzFy13Nk+IRHhlAwP3sfuv+PzOrvzUkwJK2hdoCKYcWGSdmwFpeGpWmyewdw8EgBnsKaSBtgf/0b2K635ecSA=="],
"sass-embedded-all-unknown": ["sass-embedded-all-unknown@1.97.3", "", { "dependencies": { "sass": "1.97.3" }, "cpu": [ "!arm", "!x64", "!arm64", ] }, "sha512-t6N46NlPuXiY3rlmG6/+1nwebOBOaLFOOVqNQOC2cJhghOD4hh2kHNQQTorCsbY9S1Kir2la1/XLBwOJfui0xg=="],
"sass-embedded-android-arm": ["sass-embedded-android-arm@1.97.3", "", { "os": "android", "cpu": "arm" }, "sha512-cRTtf/KV/q0nzGZoUzVkeIVVFv3L/tS1w4WnlHapphsjTXF/duTxI8JOU1c/9GhRPiMdfeXH7vYNcMmtjwX7jg=="],
"sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.97.3", "", { "os": "android", "cpu": "arm64" }, "sha512-aiZ6iqiHsUsaDx0EFbbmmA0QgxicSxVVN3lnJJ0f1RStY0DthUkquGT5RJ4TPdaZ6ebeJWkboV4bra+CP766eA=="],
"sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.97.3", "", { "os": "android", "cpu": "none" }, "sha512-zVEDgl9JJodofGHobaM/q6pNETG69uuBIGQHRo789jloESxxZe82lI3AWJQuPmYCOG5ElfRthqgv89h3gTeLYA=="],
"sass-embedded-android-x64": ["sass-embedded-android-x64@1.97.3", "", { "os": "android", "cpu": "x64" }, "sha512-3ke0le7ZKepyXn/dKKspYkpBC0zUk/BMciyP5ajQUDy4qJwobd8zXdAq6kOkdiMB+d9UFJOmEkvgFJHl3lqwcw=="],
"sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.97.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fuqMTqO4gbOmA/kC5b9y9xxNYw6zDEyfOtMgabS7Mz93wimSk2M1quQaTJnL98Mkcsl2j+7shNHxIS/qpcIDDA=="],
"sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.97.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/2RBs/2bZpP8lMkyZ0Px0vkVkT8uBd0YXpOwK7iOwYkAT8SsO4+WdVwErsqC65vI5e1e5p1bb20tuwsoQBMVA=="],
"sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.97.3", "", { "os": "linux", "cpu": "arm" }, "sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA=="],
"sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.97.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-IP1+2otCT3DuV46ooxPaOKV1oL5rLjteRzf8ldZtfIEcwhSgSsHgA71CbjYgLEwMY9h4jeal8Jfv3QnedPvSjg=="],
"sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.97.3", "", { "os": "linux", "cpu": "arm" }, "sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg=="],
"sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.97.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw=="],
"sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.97.3", "", { "os": "linux", "cpu": "none" }, "sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA=="],
"sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.97.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw=="],
"sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.97.3", "", { "os": "linux", "cpu": "none" }, "sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA=="],
"sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.97.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg=="],
"sass-embedded-unknown-all": ["sass-embedded-unknown-all@1.97.3", "", { "dependencies": { "sass": "1.97.3" }, "os": [ "!linux", "!win32", "!darwin", "!android", ] }, "sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q=="],
"sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.97.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-RDGtRS1GVvQfMGAmVXNxYiUOvPzn9oO1zYB/XUM9fudDRnieYTcUytpNTQZLs6Y1KfJxgt5Y+giRceC92fT8Uw=="],
"sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.97.3", "", { "os": "win32", "cpu": "x64" }, "sha512-SFRa2lED9UEwV6vIGeBXeBOLKF+rowF3WmNfb/BzhxmdAsKofCXrJ8ePW7OcDVrvNEbTOGwhsReIsF5sH8fVaw=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@ -773,6 +889,10 @@
"svgo": ["svgo@3.3.2", "", { "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0" }, "bin": "./bin/svgo" }, "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw=="], "svgo": ["svgo@3.3.2", "", { "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0" }, "bin": "./bin/svgo" }, "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw=="],
"sync-child-process": ["sync-child-process@1.0.2", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="],
"sync-message-port": ["sync-message-port@1.2.0", "", {}, "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg=="],
"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.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
@ -807,6 +927,8 @@
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
@ -817,6 +939,8 @@
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="],
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
@ -829,14 +953,22 @@
"usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="], "usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
"varint": ["varint@6.0.0", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.5.2", "", { "dependencies": { "fast-glob": "^3.3.3", "fs-extra": "^11.3.2", "node-html-parser": "^7.0.1", "svgo": "^3.3.2" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-A68obs8XDT+q8q8dKyjrT/v0qw8h5pEBKXJ27aUXjARYeJ6MNvhIhRLLiUwnSrbn/B4TBF4UVaWRXKftAqP7+A=="], "vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.5.2", "", { "dependencies": { "fast-glob": "^3.3.3", "fs-extra": "^11.3.2", "node-html-parser": "^7.0.1", "svgo": "^3.3.2" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-A68obs8XDT+q8q8dKyjrT/v0qw8h5pEBKXJ27aUXjARYeJ6MNvhIhRLLiUwnSrbn/B4TBF4UVaWRXKftAqP7+A=="],
"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=="],
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@ -859,6 +991,8 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@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.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/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.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@ -889,24 +1023,184 @@
"elysia/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "elysia/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], "nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"vite-static-assets-plugin/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "vite-static-assets-plugin/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
"sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
"vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
"vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
"vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
} }
} }

3
bunfig.toml Normal file
View file

@ -0,0 +1,3 @@
[test]
# Load these modules before running tests.
preload = ["./src/tests/preload.ts"]

11
drizzle.config.ts Normal file
View file

@ -0,0 +1,11 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/bun/api/schema/app.ts',
dialect: 'sqlite',
dbCredentials: {
url: "./games.db"
}
});

View file

@ -0,0 +1,65 @@
CREATE TABLE `collections` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text
);
--> statement-breakpoint
CREATE TABLE `collections_games` (
`collection_id` integer NOT NULL,
`game_id` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE cascade ON DELETE cascade,
FOREIGN KEY (`game_id`) REFERENCES `games`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `games` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`source_id` integer,
`source` text,
`igdb_id` integer,
`name` text,
`ra_id` integer,
`path_fs` text,
`last_played` integer,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`metadata` text DEFAULT '{}',
`slug` text,
`platform_id` integer NOT NULL,
`cover` blob,
`type` text,
`summary` text,
FOREIGN KEY (`platform_id`) REFERENCES `platforms`(`id`) ON UPDATE cascade ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `games_source_id_unique` ON `games` (`source_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `games_igdb_id_unique` ON `games` (`igdb_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `games_ra_id_unique` ON `games` (`ra_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `games_slug_unique` ON `games` (`slug`);--> statement-breakpoint
CREATE TABLE `platforms` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`igdb_id` integer,
`igdb_slug` text,
`moby_id` integer,
`name` text NOT NULL,
`es_slug` text,
`ra_id` integer,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`slug` text NOT NULL,
`metadata` text,
`cover` blob,
`type` text,
`family_name` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `platforms_igdb_id_unique` ON `platforms` (`igdb_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `platforms_igdb_slug_unique` ON `platforms` (`igdb_slug`);--> statement-breakpoint
CREATE UNIQUE INDEX `platforms_moby_id_unique` ON `platforms` (`moby_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `platforms_es_slug_unique` ON `platforms` (`es_slug`);--> statement-breakpoint
CREATE UNIQUE INDEX `platforms_ra_id_unique` ON `platforms` (`ra_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `platforms_slug_unique` ON `platforms` (`slug`);--> statement-breakpoint
CREATE TABLE `screenshots` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`game_id` integer,
`content` blob NOT NULL,
`type` text,
FOREIGN KEY (`game_id`) REFERENCES `games`(`id`) ON UPDATE cascade ON DELETE cascade
);

View file

@ -0,0 +1,458 @@
{
"version": "6",
"dialect": "sqlite",
"id": "673fe5dc-58a5-495b-8fb1-104e7945e90b",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"collections": {
"name": "collections",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collections_games": {
"name": "collections_games",
"columns": {
"collection_id": {
"name": "collection_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_id": {
"name": "game_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"collections_games_collection_id_collections_id_fk": {
"name": "collections_games_collection_id_collections_id_fk",
"tableFrom": "collections_games",
"tableTo": "collections",
"columnsFrom": [
"collection_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
},
"collections_games_game_id_games_id_fk": {
"name": "collections_games_game_id_games_id_fk",
"tableFrom": "collections_games",
"tableTo": "games",
"columnsFrom": [
"game_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"games": {
"name": "games",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"source_id": {
"name": "source_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"igdb_id": {
"name": "igdb_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ra_id": {
"name": "ra_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"path_fs": {
"name": "path_fs",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_played": {
"name": "last_played",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'{}'"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"platform_id": {
"name": "platform_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cover": {
"name": "cover",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"games_source_id_unique": {
"name": "games_source_id_unique",
"columns": [
"source_id"
],
"isUnique": true
},
"games_igdb_id_unique": {
"name": "games_igdb_id_unique",
"columns": [
"igdb_id"
],
"isUnique": true
},
"games_ra_id_unique": {
"name": "games_ra_id_unique",
"columns": [
"ra_id"
],
"isUnique": true
},
"games_slug_unique": {
"name": "games_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {
"games_platform_id_platforms_id_fk": {
"name": "games_platform_id_platforms_id_fk",
"tableFrom": "games",
"tableTo": "platforms",
"columnsFrom": [
"platform_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"platforms": {
"name": "platforms",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"igdb_id": {
"name": "igdb_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"igdb_slug": {
"name": "igdb_slug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"moby_id": {
"name": "moby_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"es_slug": {
"name": "es_slug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ra_id": {
"name": "ra_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cover": {
"name": "cover",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"family_name": {
"name": "family_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"platforms_igdb_id_unique": {
"name": "platforms_igdb_id_unique",
"columns": [
"igdb_id"
],
"isUnique": true
},
"platforms_igdb_slug_unique": {
"name": "platforms_igdb_slug_unique",
"columns": [
"igdb_slug"
],
"isUnique": true
},
"platforms_moby_id_unique": {
"name": "platforms_moby_id_unique",
"columns": [
"moby_id"
],
"isUnique": true
},
"platforms_es_slug_unique": {
"name": "platforms_es_slug_unique",
"columns": [
"es_slug"
],
"isUnique": true
},
"platforms_ra_id_unique": {
"name": "platforms_ra_id_unique",
"columns": [
"ra_id"
],
"isUnique": true
},
"platforms_slug_unique": {
"name": "platforms_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"screenshots": {
"name": "screenshots",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"game_id": {
"name": "game_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"screenshots_game_id_games_id_fk": {
"name": "screenshots_game_id_games_id_fk",
"tableFrom": "screenshots",
"tableTo": "games",
"columnsFrom": [
"game_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1771508990238,
"tag": "0000_pretty_harry_osborn",
"breakpoints": true
}
]
}

View file

@ -12,20 +12,28 @@
"build:dev": "NODE_ENV=development bun run build", "build:dev": "NODE_ENV=development bun run build",
"package": "bun run build && bun run ./scripts/package-bun.ts", "package": "bun run build && bun run ./scripts/package-bun.ts",
"package:auto-prod": "bun run build:pro && NODE_ENV=production bun run ./scripts/package-bun.ts", "package:auto-prod": "bun run build:pro && NODE_ENV=production bun run ./scripts/package-bun.ts",
"package:linux": "bun run build && TARGET=bun-linux-x64 bun run ./scripts/package-bun.ts", "package:linux": "bun run build && NODE_ENV=development TARGET=bun-linux-x64 bun run ./scripts/package-bun.ts",
"openapi-ts": "bun run ./scripts/romm/openapi-ts.ts", "openapi-ts": "bun run ./scripts/romm/openapi-ts.ts",
"run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .github/workflows/build.yml", "run:build-action": "act workflow_dispatch --artifact-server-path artifacts --env ACTIONS_RUNTIME_TOKEN=foo -W .forgejo/workflows/build.yml",
"hmr": "vite --port 5173" "hmr": "vite --port 5173",
"drizzle:generate": "bunx drizzle-kit generate",
"test": "bun test",
"mappings:generate": "bun run drizzle-kit generate --dialect=sqlite --schema=./src/bun/api/schema/emulators.ts --out=./scripts/drizzle/es-de && bun run ./scripts/generate-es-de-mapping.ts"
}, },
"dependencies": { "dependencies": {
"@auth/core": "^0.34.3", "@auth/core": "^0.34.3",
"@elysiajs/cors": "^1.4.1", "@elysiajs/cors": "^1.4.1",
"@elysiajs/eden": "^1.4.6", "@elysiajs/eden": "^1.4.6",
"@elysiajs/static": "^1.4.7", "@elysiajs/static": "^1.4.7",
"@hackolade/keytar": "^7.9.0-7",
"@rcompat/webview": "^0.18.0", "@rcompat/webview": "^0.18.0",
"cheerio": "^1.2.0",
"conf": "^15.0.2", "conf": "^15.0.2",
"drizzle-orm": "^0.45.1",
"elysia": "^1.4.22", "elysia": "^1.4.22",
"get-folder-size": "^5.0.0",
"node-downloader-helper": "^2.1.10",
"node-stream-zip": "^1.15.0",
"open": "^11.0.0",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"tough-cookie": "^6.0.0", "tough-cookie": "^6.0.0",
"tough-cookie-file-store": "^3.3.0", "tough-cookie-file-store": "^3.3.0",
@ -52,12 +60,16 @@
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"daisyui": "^5.5.14", "daisyui": "^5.5.14",
"drizzle-kit": "^0.31.9",
"dts-bundle-generator": "^9.5.1",
"eden-tanstack-query": "^0.0.9",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-error-boundary": "^6.1.0", "react-error-boundary": "^6.1.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"sass-embedded": "^1.97.3",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@ -65,6 +77,7 @@
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-svg-icons-ng": "^1.5.2", "vite-plugin-svg-icons-ng": "^1.5.2",
"vite-static-assets-plugin": "^1.2.2" "vite-static-assets-plugin": "^1.2.2",
"vite-tsconfig-paths": "^6.1.1"
} }
} }

View file

@ -0,0 +1,160 @@
import fs from 'node:fs/promises';
import * as cheerio from 'cheerio';
import { getSupportedPlatformsEndpointApiPlatformsSupportedGet } from '../src/clients/romm';
import customMappings from '../vendors/romm/custom-overrides.json';
import { Database } from "bun:sqlite";
import * as schema from '../src/bun/api/schema/emulators';
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { drizzle } from "drizzle-orm/bun-sqlite";
/** get all latest supported romm platforms */
const rommPlatforms = await getSupportedPlatformsEndpointApiPlatformsSupportedGet({ baseUrl: "https://demo.romm.app" });
/** a matrix for supported platforms and architectures */
const platforms: [NodeJS.Platform, NodeJS.Architecture][] = [['linux', 'x64'], ['win32', 'x64'], ['darwin', 'x64'], ['haiku', 'x64'], ['linux', 'arm']];
/** Save client minimal info for emulator names and descriptions */
await Promise.all(platforms.map(async ([platform, arch]) =>
{
const rules = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_find_rules.xml`).arrayBuffer();
const $r = cheerio.load(Buffer.from(rules));
const es_emulators = $r('ruleList emulator');
const emulators = Object.fromEntries(es_emulators.toArray().map(system =>
{
const $system = $r(system);
const key = $system.attr('name');
const comment = $system.contents().toArray().find((node) => node.type === 'comment');
return [key, comment?.data.trim() ?? key];
}));
await Bun.write(`./vendors/es-de/emulators.${platform}.${arch}.json`, JSON.stringify(emulators, null, 3));
}));
/** Delete old databases, we recreate them each time */
await Promise.all(platforms.map(async ([platform, arch]) =>
{
const sqlitePath = `./vendors/es-de/emulators.${platform}.${arch}.sqlite`;
if (await fs.exists(sqlitePath))
await fs.rm(sqlitePath);
}));
await Promise.all(platforms.map(async ([platform, arch]) =>
{
const systems = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_systems.xml`).arrayBuffer();
const $s = cheerio.load(Buffer.from(systems));
const rules = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_find_rules.xml`).arrayBuffer();
const $r = cheerio.load(Buffer.from(rules));
const sqlitePath = `./vendors/es-de/emulators.${platform}.${arch}.sqlite`;
const sqlite = new Database(sqlitePath, { create: true, readwrite: true });
const db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder: "./scripts/drizzle/es-de" });
/** Save the ruleset for emulators */
await db.insert(schema.emulators).values($r('ruleList emulator').toArray().map(s =>
{
const $emulator = $r(s);
const $systempath = $emulator.find('rule[type=systempath] entry');
const $staticpath = $emulator.find('rule[type=staticpath] entry');
const $corepath = $emulator.find('rule[type=corepath] entry');
const $androidpackage = $emulator.find('rule[type=androidpackage] entry');
const $winregistrypath = $emulator.find('rule[type=winregistrypath] entry');
const emulatorName = $emulator.attr('name');
const emulator: typeof schema.emulators.$inferInsert = {
name: emulatorName!,
systempath: $systempath.toArray().map(p => $r(p).text()),
staticpath: $staticpath.toArray().map(p => $r(p).text()),
corepath: $corepath.toArray().map(p => $r(p).text()),
androidpackage: $androidpackage.toArray().map(p => $r(p).text()),
winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()),
};
return emulator;
}));
/** Save the systems like ps2 or psp */
await Promise.all($s(`systemList system`).toArray().map(async s =>
{
const name = $s(s).find("name").text();
const fullname = $s(s).find("fullname").text();
const rommMapping = rommPlatforms.data?.find(p =>
p.slug === (customMappings as any)[name] ||
p.slug === name ||
p.igdb_slug === name ||
p.hltb_slug === name ||
p.moby_slug === name ||
p.display_name === fullname
);
const system: typeof schema.systems.$inferInsert = {
name,
fullname,
path: $s(s).find("path").text(),
extension: $s(s).find("extension").text().replaceAll('.', '').split(' ')
};
/** Store mappings to all other sources for easy reference */
db.transaction(async (tx) =>
{
await tx.insert(schema.systems).values(system);
if (rommMapping)
{
const sources: [string, keyof typeof rommMapping | null, keyof typeof rommMapping | null][] = [
['ra', 'ra_id', null],
['ss', 'ss_id', null],
['hltb', null, 'hltb_slug'],
['moby', 'moby_id', 'moby_slug'],
['launchbox', 'launchbox_id', null],
['sgdb', 'sgdb_id', null],
['tgdb', 'tgdb_id', null],
['hasheous', 'hasheous_id', null],
['flashpoint', 'flashpoint_id', null],
['romm', null, 'slug'],
['igdb', 'igdb_id', 'igdb_slug']
];
await tx.insert(schema.systemMappings)
.values(sources.map(([source, sourceId, sourceSlug]) => ({
source,
sourceId: sourceId ? rommMapping[sourceId] as number : null,
sourceSlug: sourceSlug ? rommMapping[sourceSlug] as string : null,
system: system.name
} satisfies typeof schema.systemMappings.$inferInsert))
.filter(m => m.sourceId !== null || m.sourceSlug !== null));
}
});
await db.insert(schema.commands).values($s(s).find("command").toArray().map(c =>
{
const command: typeof schema.commands.$inferInsert = {
label: $s(c).attr('label'),
command: $s(c).text(),
system: system.name
};
return command;
}));
}));
}));
/** map from bun platform to es-de folder naming */
function mapSystem (platform: NodeJS.Platform, arch: NodeJS.Architecture)
{
let system: string | undefined = undefined;
if (platform === 'darwin')
{
system = 'macos';
} else if (platform === 'win32')
{
system = 'windows';
} else if (platform === 'linux' && arch === 'arm')
{
system = 'linuxarm';
}
else
{
system = platform;
}
return system;
}

View file

@ -1,8 +1,10 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path, { } from "node:path"; import path, { } from "node:path";
import os from "node:os"; import os from "node:os";
import { Glob } from "bun";
const buildSubDir = process.env.BUILD_DIR ?? `./build/${os.platform()}`; const system = getPlatform();
const buildSubDir = process.env.BUILD_DIR ?? `./build/${system.platform}`;
const compileOption: Bun.CompileBuildOptions = { const compileOption: Bun.CompileBuildOptions = {
outfile: "gameflow", outfile: "gameflow",
@ -19,7 +21,7 @@ if (process.env.TARGET)
} }
await Bun.build({ await Bun.build({
entrypoints: ["./src/bun/index.ts", "./src/bun/webview-worker.ts"], entrypoints: ["./src/bun/index.ts", `./src/bun/webview/${system.platform}.ts`],
metafile: true, metafile: true,
compile: compileOption, compile: compileOption,
outdir: buildSubDir, outdir: buildSubDir,
@ -27,8 +29,8 @@ await Bun.build({
define: { define: {
"process.env.IS_BINARY": "true" "process.env.IS_BINARY": "true"
}, },
minify: true, minify: process.env.NODE_ENV !== 'development',
sourcemap: "linked", sourcemap: process.env.NODE_ENV === 'development' ? 'inline' : "linked",
target: 'bun', target: 'bun',
format: 'esm', format: 'esm',
loader: { loader: {
@ -52,7 +54,32 @@ await Bun.build({
build.onEnd(async () => build.onEnd(async () =>
{ {
await fs.cp('./dist', `${buildSubDir}/dist`, { recursive: true }); await fs.cp('./dist', `${buildSubDir}/dist`, { recursive: true });
await fs.cp('./drizzle', `${buildSubDir}/drizzle`, { recursive: true });
await fs.cp(`./vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, `${buildSubDir}/vendors/es-de/emulators.${system.platform}.${system.arch}.sqlite`, { recursive: true });
}); });
}, },
}] }]
}); });
function getPlatform ()
{
if (process.env.TARGET)
{
const arch = process.env.TARGET.includes('arm') ? 'arm' : 'x64';
let platform = os.platform();
if (platform.includes('windows'))
{
platform = 'win32';
} else if (platform.includes('darwin'))
{
platform = 'darwin';
} else
{
platform = 'linux';
}
return { platform, arch };
} else
{
return { platform: os.platform(), arch: os.arch() };
}
}

76
src/bun/api/app.ts Normal file
View file

@ -0,0 +1,76 @@
import { TaskQueue } from "./task-queue";
import { Database } from "bun:sqlite";
import { CookieJar } from 'tough-cookie';
import FileCookieStore from 'tough-cookie-file-store';
import path from 'node:path';
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { drizzle } from "drizzle-orm/bun-sqlite";
import Conf from "conf";
import projectPackage from '~/package.json';
import { SERVER_URL, SettingsSchema, SettingsType } from "../../shared/constants";
import { client } from "@clients/romm/client.gen";
import * as schema from "./schema/app";
import * as emulatorSchema from "./schema/emulators";
import { login, logout } from "./auth";
import fs from 'node:fs/promises';
import os from 'node:os';
import { ActiveGame } from "../types/types";
import EventEmitter from "node:events";
import { ErrorLike } from "bun";
export const config = new Conf<SettingsType>({
projectName: projectPackage.name,
projectSuffix: 'bun',
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any,
defaults: SettingsSchema.parse({}),
});
export const customEmulators = new Conf<Record<string, string>>({
projectName: projectPackage.name,
projectSuffix: 'bun',
configName: 'custom-emulators',
rootSchema: {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
});
console.log("Config Path Located At: ", config.path);
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
export const jar = new CookieJar(fileCookieStore);
await fs.mkdir(config.get('downloadPath'), { recursive: true });
const sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
export const db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder: "./drizzle" });
const emulatorsSqlite = new Database(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`, { readonly: true });
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
export const taskQueue = new TaskQueue();
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
await login();
export let activeGame: ActiveGame | undefined;
export function setActiveGame (game: ActiveGame)
{
if (activeGame) throw new Error("Only one active game at a time");
return activeGame = game;
}
export const events = new EventEmitter<AppEventMap>();
events.addListener('activegameexit', () => activeGame = undefined);
console.log("Logging In to Romm");
export async function cleanup ()
{
await taskQueue.close();
sqlite.close();
await logout();
emulatorsSqlite.close();
}
interface AppEventMap
{
activegameexit: [{ subprocess: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
exitapp: [];
}

97
src/bun/api/auth.ts Normal file
View file

@ -0,0 +1,97 @@
import Elysia, { status } from "elysia";
import { config, db, jar } from "./app";
import z from "zod";
import { client } from "@clients/romm/client.gen";
import { loginApiLoginPost } from "@clients/romm";
import secrets from '../api/secrets';
export default new Elysia()
.post('/login', async ({ body: { host, username, password } }) =>
{
if (config.has('rommAddress') && config.has('rommUser'))
{
await logout();
const oldRommAddress = config.get('rommAddress');
if (oldRommAddress)
{
const cookies = await jar.getCookies(oldRommAddress);
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
}
}
config.set('rommAddress', host);
config.set('rommUser', username);
await secrets.set({ service: 'gameflow', name: 'romm', value: password });
await login();
return status(200);
}, { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
.get('/login', async () =>
{
const credentials = await secrets.get({ service: 'gameflow', name: 'romm' });
return { hasPassword: !!credentials };
}, { response: z.object({ hasPassword: z.boolean() }) })
.post('/logout', async () =>
{
await secrets.delete({ service: 'gameflow', name: 'romm' });
await logout();
const rommAddress = config.get('rommAddress');
if (rommAddress)
{
const cookies = await jar.getCookies(rommAddress);
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
}
return status(200);
}, { response: z.any() });
async function updateClient ()
{
client.setConfig({
baseUrl: config.get('rommAddress'), headers: {
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
}
});
}
export async function logout ()
{
if (!config.has('rommAddress'))
{
return;
}
const rommAddress = config.get('rommAddress');
if (rommAddress)
{
console.log("Logging Out of ROMM");
try
{
await loginApiLoginPost({
baseUrl: rommAddress, headers: {
'cookie': await jar.getCookieString(rommAddress)
}
});
} catch (error)
{
console.error("Failed to logout of ROMM ", error);
}
}
}
export async function login ()
{
if (!config.has('rommAddress') || !config.has('rommUser'))
{
return;
}
const rommAddress = config.get('rommAddress');
const rommUser = config.get('rommUser');
if (rommAddress && rommUser)
{
console.log("Logging In to ROMM");
const password = await secrets.get({ service: 'gameflow', name: "romm" });
const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` });
loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress));
await updateClient();
}
}

View file

@ -1,97 +1,12 @@
import z from "zod"; import z from "zod";
import { config } from "./settings"; import Elysia from "elysia";
import Elysia, { status } from "elysia"; import { config, jar } from "./app";
import keytar from '@hackolade/keytar'; import games from "./games/games";
import { loginApiLoginPost } from "../../clients/romm"; import platforms from "./games/platforms";
import { CookieJar } from 'tough-cookie'; import auth from "./auth";
import FileCookieStore from 'tough-cookie-file-store';
import path from 'node:path';
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); export default new Elysia({ prefix: "/api/romm" })
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath); .use([games, platforms, auth])
const jar = new CookieJar(fileCookieStore);
await login();
export async function logout ()
{
if (!config.has('rommAddress'))
{
return;
}
const rommAddress = config.get('rommAddress');
if (rommAddress)
{
console.log("Logging Out of ROMM");
try
{
await loginApiLoginPost({
baseUrl: rommAddress, headers: {
'cookie': await jar.getCookieString(rommAddress)
}
});
} catch (error)
{
console.error("Failed to logout of ROMM ", error);
}
}
}
async function login ()
{
if (!config.has('rommAddress') || !config.has('rommUser'))
{
return;
}
const rommAddress = config.get('rommAddress');
const rommUser = config.get('rommUser');
if (rommAddress && rommUser)
{
console.log("Logging In to ROMM");
const password = await keytar.getPassword('romm', 'gameflow');
const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` });
loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress));
}
}
export const romm = new Elysia({ prefix: "/romm" })
.post('/login', async ({ body: { host, username, password } }) =>
{
if (config.has('rommAddress') && config.has('rommUser'))
{
await logout();
const oldRommAddress = config.get('rommAddress');
if (oldRommAddress)
{
const cookies = await jar.getCookies(oldRommAddress);
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
}
}
config.set('rommAddress', host);
config.set('rommUser', username);
await keytar.setPassword('romm', 'gameflow', password);
await login();
return status(200);
}, { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
.get('/login', async () =>
{
const credentials = await keytar.getPassword('romm', 'gameflow');
return { hasPassword: !!credentials };
}, { response: z.object({ hasPassword: z.boolean() }) })
.post('/logout', async () =>
{
await keytar.deletePassword('romm', 'gameflow');
await logout();
const rommAddress = config.get('rommAddress');
if (rommAddress)
{
const cookies = await jar.getCookies(rommAddress);
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
}
return status(200);
})
.all("/*", async ({ request, params, set }) => .all("/*", async ({ request, params, set }) =>
{ {
if (!config.has('rommAddress') && !config.get('rommAddress')) if (!config.has('rommAddress') && !config.get('rommAddress'))
@ -119,19 +34,6 @@ export const romm = new Elysia({ prefix: "/romm" })
redirect: 'manual', // avoid ROMM redirects redirect: 'manual', // avoid ROMM redirects
}); });
/*
if (rommResponse.status === 403 && config.has('rommUser'))
{
await login();
headers.set('cookie', await jar.getCookieString(rommUrl.href));
rommResponse = await fetch(url, {
method: request.method,
headers,
body: await request.arrayBuffer(),
redirect: 'manual', // avoid ROMM redirects
});
}*/
set.status = rommResponse.status; set.status = rommResponse.status;
rommResponse.headers.forEach((value, key) => rommResponse.headers.forEach((value, key) =>
{ {
@ -139,4 +41,5 @@ export const romm = new Elysia({ prefix: "/romm" })
}); });
return new Response(rommResponse.body, { status: rommResponse.status }); return new Response(rommResponse.body, { status: rommResponse.status });
}).on('stop', logout); }, { response: z.instanceof(Response) });

245
src/bun/api/games/games.ts Normal file
View file

@ -0,0 +1,245 @@
import Elysia, { status } from "elysia";
import { activeGame, config, db, events, setActiveGame, taskQueue } from "../app";
import { and, eq, getTableColumns } from "drizzle-orm";
import z from "zod";
import * as schema from "../schema/app";
import fs from "node:fs/promises";
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
export default new Elysia()
.get('/game/local/:id/cover', async ({ params: { id }, set }) =>
{
const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) });
if (!coverBlob || !coverBlob.cover)
{
return status(404);
}
if (coverBlob.cover_type)
{
set.headers["content-type"] = coverBlob.cover_type;
}
return status(200, coverBlob.cover);
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) })
.get('/screenshot/:id', async ({ params: { id }, set }) =>
{
const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } });
if (screenshot)
{
if (screenshot.type)
{
set.headers["content-type"] = screenshot.type;
}
return screenshot.content;
}
return status(404);
}, { params: z.object({ id: z.coerce.number() }) })
.get("/game/local/:id/installed", async ({ params: { id } }) =>
{
const data = await db.query.games.findFirst({ where: eq(schema.games.id, id) });
if (data && data.path_fs)
{
return { installed: await fs.exists(data.path_fs) };
}
return { installed: false };
}, {
params: z.object({ id: z.number() }),
response: z.object({ installed: z.boolean() })
}).get('/games', async ({ query: { platform_id, collection_id } }) =>
{
const where: any[] = [];
if (platform_id)
{
where.push(eq(schema.games.id, platform_id));
}
const games: FrontEndGameType[] = [];
const localGames = await db.select({
platform_display_name: schema.platforms.name,
id: schema.games.id,
last_played: schema.games.last_played,
created_at: schema.games.created_at,
platform_id: schema.games.platform_id,
slug: schema.games.slug,
name: schema.games.name,
path_fs: schema.games.path_fs,
source_id: schema.games.source_id,
source: schema.games.source
}).from(schema.games).leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id)).where(and(...where));
const localGamesSet = new Set(localGames.map(g => g.source_id));
games.push(...localGames.map(g =>
{
const game: FrontEndGameType = {
...g,
platform_display_name: g.platform_display_name ?? "Local",
id: { id: g.id, source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
source_id: g.source_id,
source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`
};
return game;
}));
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true });
games.push(...rommGames.data.items.filter(g => !localGamesSet.has(g.id)).map(g =>
{
return convertRomToFrontend(g);
}));
return { games };
}, {
query: z.object({ platform_id: z.coerce.number().optional(), collection_id: z.coerce.number().optional() }),
})
.get('/game/:source/:id', async ({ params: { source, id } }) =>
{
async function getLocalGameDetailed (match: any)
{
const localGames = await db.select({
platform_display_name: schema.platforms.name,
...getTableColumns(schema.games)
}).from(schema.games).where(match).leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id));
if (localGames.length > 0)
{
const screenshots = await db.query.screenshots.findMany({ where: eq(schema.screenshots.game_id, localGames[0].id), columns: { id: true } });
const exists = await checkInstalled(localGames[0].path_fs);
const fileSize = await calculateSize(localGames[0].path_fs);
const game: FrontEndGameTypeDetailed = {
...localGames[0],
path_cover: `/api/romm/game/local/${localGames[0].id}/cover`,
updated_at: localGames[0].created_at,
id: { id: localGames[0].id, source: 'local' },
path_platform_cover: `/api/romm/platform/local/${localGames[0].platform_id}/cover`,
fs_size_bytes: fileSize ?? null,
paths_screenshots: screenshots.map(s => `/api/romm/screenshot/${s.id}`),
local: true,
missing: !exists
};
return game;
}
return undefined;
}
if (source === 'local')
{
const localGame = await getLocalGameDetailed(eq(schema.games.id, id));
if (localGame) return localGame;
return status('Not Found');
}
else
{
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
if (localGame) return localGame;
const rom = await getRomApiRomsIdGet({ path: { id } });
if (rom.data)
{
const romGame = convertRomToFrontendDetailed(rom.data);
return romGame;
}
return status("Not Found", rom.response);
}
}, {
params: z.object({ source: z.string(), id: z.coerce.number() })
})
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
{
set.headers["content-type"] = 'text/event-stream';
set.headers["cache-control"] = 'no-cache';
set.headers['connection'] = 'keep-alive';
return buildStatusResponse(source, id);
}, {
response: z.any(),
params: z.object({ id: z.coerce.number(), source: z.string() }),
query: z.object({ isLocal: z.boolean().optional() })
})
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
{
const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs });
const downloadPath = config.get('downloadPath');
await Promise.all(deleted.filter(d => !!d.path_fs).map(async d =>
{
await fs.rm(path.join(downloadPath, d.path_fs!), { recursive: true, force: true });
}));
return status(deleted.length > 0 ? 'OK' : 'Not Modified');
}, {
params: z.object({ id: z.coerce.number(), source: z.string() }),
})
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
{
if (!taskQueue.hasActive())
{
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id));
return status(200);
} else
{
return status('Not Implemented');
}
}, {
params: z.object({ id: z.coerce.number(), source: z.string() }),
response: z.any()
})
.post('/game/:source/:id/play', async ({ params: { id, source }, set }) =>
{
const validCommand = await getValidLaunchCommandsForGame(source, id);
if (validCommand)
{
if (validCommand instanceof Error)
{
return errorToResponse(validCommand, set);
}
else
{
if (activeGame && activeGame.process.killed === false)
{
return status('Conflict', `${activeGame.name} currently running`);
}
const localGame = await db.query.games.findFirst({
where: eq(schema.games.id, validCommand.gameId), columns: {
name: true
}
});
const game = setActiveGame({
process: Bun.spawn({
cmd: validCommand.command.command.split(' '), onExit (subprocess, exitCode, signalCode, error)
{
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
},
}),
name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId,
command: validCommand.command.command
});
await game.process.exited;
if (game.process.exitCode && game.process.exitCode > 0)
{
return status('Internal Server Error');
}
return status('OK');
}
}
}, {
params: z.object({ id: z.coerce.number(), source: z.string() }),
});

View file

@ -0,0 +1,86 @@
import Elysia, { status } from "elysia";
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet } from "@clients/romm";
import z from "zod";
import { count, eq, getTableColumns, notInArray } from "drizzle-orm";
import { db } from "../app";
import { FrontEndPlatformType } from "@shared/constants";
import * as schema from "../schema/app";
export default new Elysia()
.get('/platforms', async () =>
{
const platforms: FrontEndPlatformType[] = [];
let rommPlatformsSet: Set<string> | undefined;
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
if (rommPlatforms)
{
const frontEndPlatforms = rommPlatforms.map(p =>
{
const platform: FrontEndPlatformType = {
slug: p.slug,
name: p.display_name,
family_name: p.family_name,
path_cover: `/api/romm/assets/platforms/${p.slug}.svg`,
game_count: p.rom_count,
updated_at: new Date(p.updated_at),
id: { source: 'romm', id: p.id },
source: null,
source_id: null
};
return platform;
});
rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug));
platforms.push(...frontEndPlatforms);
}
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
.from(schema.platforms)
.leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id))
.groupBy(schema.platforms.id)
.where(notInArray(schema.platforms.slug, Array.from(rommPlatformsSet ?? [])));
platforms.push(...localPlatforms.map(p =>
{
const platform: FrontEndPlatformType = {
slug: p.slug,
name: p.name,
family_name: p.family_name,
path_cover: `/api/romm/platform/local/${p.id}/cover`,
game_count: p.game_count,
updated_at: p.created_at,
id: { source: 'local', id: p.id },
source: null,
source_id: null
};
return platform;
}));
return { platforms };
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
{
const rommPlatform = await getPlatformApiPlatformsIdGet({ path: { id } });
if (rommPlatform.data)
{
return rommPlatform.data;
}
return status("Not Found", rommPlatform.response);
}, { params: z.object({ source: z.string(), id: z.coerce.number() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
{
const coverBlob = await db.query.platforms.findFirst({
columns: {
cover: true, cover_type: true
}, where: eq(schema.platforms.id, id)
});
if (!coverBlob || !coverBlob.cover)
{
return status(404);
}
if (coverBlob.cover_type)
{
set.headers["content-type"] = coverBlob.cover_type;
}
return status(200, coverBlob.cover);
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) });

View file

@ -0,0 +1,219 @@
import path, { basename, dirname } from 'node:path';
import { which } from 'bun';
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import * as schema from '../../schema/emulators';
import { eq } from 'drizzle-orm';
import { config, emulatorsDb } from '../../app';
import os from 'node:os';
export const varRegex = /%([^%]+)%/g;
interface CommandEntry
{
label?: string;
command: string;
valid: boolean;
emulator?: string;
}
export async function getValidLaunchCommands (data: {
systemSlug: string;
gamePath: string;
customEmulatorConfig: {
get: (id: string) => string | undefined,
has: (id: string) => boolean,
};
}): Promise<CommandEntry[]>
{
const system = await emulatorsDb.query.systems.findFirst({ with: { commands: true }, where: eq(schema.systems.name, data.systemSlug) });
if (!system)
{
throw new Error(`Could not find system '${data.systemSlug}'`);
}
if (!system.extension || system.extension.length <= 0)
{
throw new Error(`No extensions listed for system '${data.systemSlug}'`);
}
const downloadPath = config.get('downloadPath');
const gamePath = path.join(downloadPath, data.gamePath);
const validFiles: string[] = [];
if (!existsSync(gamePath))
{
throw new Error(`Provided rom path is missing: '${gamePath}'`);
}
const gamePathStat = await fs.stat(gamePath);
const extensionList = system.extension.join(',');
if (gamePathStat.isDirectory())
{
for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`)))
{
validFiles.push(file);
}
if (validFiles.length <= 0)
{
throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`);
}
} else
{
if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase())))
{
validFiles.push(gamePath);
}
else
{
throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`);
}
}
const formattedCommands = await Promise.all(system.commands.map(async command =>
{
const label = command.label;
const cmd = command.command;
const matches = cmd.match(varRegex);
if (matches)
{
let emulator: string | undefined = undefined;
const varList = await Promise.all(matches.map(async (value) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let exec = await findExec(emulatorName);
if (data.customEmulatorConfig.has(emulatorName))
{
exec = data.customEmulatorConfig.get(emulatorName);
}
emulator = emulatorName;
return [value, exec];
}
const key = value.substring(1, value.length - 1);
return [value, process.env[key]];
}));
const vars = Object.fromEntries(varList);
vars['%ROM%'] = validFiles[0];
vars['%ESPATH%'] = config.get('downloadPath');
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const command = cmd.replace(varRegex, (s) => vars[s] ?? '');
return { label: label ?? undefined, command, valid: !invalid, emulator } satisfies CommandEntry;
}
}));
return formattedCommands.filter(c => !!c);
}
export async function findExec (emulatorName: string)
{
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
if (!emulator)
{
throw new Error(`Could not find emulator ${emulatorName}`);
}
if (os.platform() === 'win32')
{
const regValues = emulator.winregistrypath;
if (regValues.length > 0)
{
for (const node of regValues)
{
const registryValue = await readRegistryValue(node);
if (registryValue)
{
return registryValue;
}
}
}
}
const systempaths = emulator.systempath;
if (systempaths.length > 0)
{
const systemPath = await resolveSystemPath(systempaths);
if (systemPath)
{
return systemPath;
}
}
const staticPaths = emulator.staticpath;
if (staticPaths.length > 0)
{
const staticPath = await resolveStaticPath(staticPaths);
if (staticPath)
{
return staticPath;
}
}
}
async function readRegistryValue (text: string)
{
const params = text.split('|');
const key = dirname(params[0]);
const value = basename(params[0]);
const bin = params.length > 1 ? params[1] : undefined;
const proc = Bun.spawn({
cmd: ["reg", "QUERY", key, "/v", value],
stdout: "pipe",
stderr: "pipe",
});
const output = await new Response(proc.stdout).text();
await proc.exited;
if (!output.includes(value)) return null;
const lines = output.split("\n");
for (const line of lines)
{
if (line.includes(value))
{
const parts = line.trim().split(/\s{4,}/);
return bin ? path.join(parts[2], bin) : parts[2]; // registry value
}
}
return null;
}
async function resolveStaticPath (entries: string[])
{
for (const entry of entries)
{
for await (const match of fs.glob(entry))
{
return match;
}
}
return null;
}
async function resolveSystemPath (entries: string[])
{
for (const entry of entries)
{
try
{
const found = which(entry);
return found;
} catch { }
}
return null;
}

View file

@ -0,0 +1,171 @@
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
import { activeGame, customEmulators, db, events, taskQueue } from "../../app";
import { getValidLaunchCommands } from "./launchGameService";
import * as schema from '../../schema/app';
import { eq } from "drizzle-orm";
import { getErrorMessage } from "@/bun/utils";
import { getLocalGameMatch } from "./utils";
class CommandSearchError extends Error
{
constructor(status: GameStatusType, message: string)
{
super(message);
this.name = status;
}
}
export async function getLocalGame (source: string, id: number)
{
const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug })
.from(schema.games)
.where(getLocalGameMatch(id, source))
.leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id));
if (localGames.length > 0)
{
return localGames[0];
}
return undefined;
}
export async function getValidLaunchCommandsForGame (source: string, id: number)
{
const localGame = await getLocalGame(source, id);
if (localGame)
{
if (localGame.platform_slug)
{
if (localGame.path_fs)
{
try
{
const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
const validCommand = commands.find(c => c.valid);
if (validCommand)
{
return { command: validCommand, gameId: localGame.id, source: source, sourceId: id };
}
else
{
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator).join(',')}`);
}
} catch (error)
{
console.error(error);
return new CommandSearchError('error', getErrorMessage(error));
}
} else
{
return new CommandSearchError('error', 'Missing Path');
}
}
else
{
return new CommandSearchError('error', 'Missing Platform');
}
}
return undefined;
}
export default async function buildStatusResponse (source: string, id: number)
{
let cleanup: (() => void) | undefined;
return new Response(new ReadableStream({
async start (controller)
{
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh')
{
const evntString = event ? `event: ${event}\n` : '';
controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`);
}
const sourceId = `${source}-${id}`;
async function sendLatests ()
{
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
if (activeTask)
{
enqueue({
progress: activeTask.progress,
status: activeTask.state as any
});
} else if (activeGame && activeGame.gameId === localGame?.id)
{
enqueue({ status: 'playing' as GameStatusType, details: 'Playing' });
}
else
{
const validCommand = await getValidLaunchCommandsForGame(source, id);
if (validCommand)
{
if (validCommand instanceof Error)
{
enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message });
}
else
{
enqueue({ status: 'installed', details: validCommand.command.label });
}
} else
{
enqueue({ status: 'install', details: 'Install' });
}
}
}
await sendLatests();
const dispose: Function[] = [];
const handleActiveExit = async () =>
{
await sendLatests();
};
events.on('activegameexit', handleActiveExit);
dispose.push(() => events.off('activegameexit', handleActiveExit));
dispose.push(taskQueue.on('progress', ({ id, progress, state }) =>
{
if (id.endsWith(sourceId))
{
enqueue({ progress, status: state as any });
}
}));
dispose.push(taskQueue.on('completed', ({ id }) =>
{
if (id.endsWith(sourceId))
{
enqueue({}, 'refresh');
}
}));
dispose.push(taskQueue.on('error', ({ id, error }) =>
{
if (id.endsWith(sourceId))
{
enqueue({
status: 'error',
error: error
}, 'error');
}
}));
cleanup = () =>
{
dispose.forEach(f => f());
};
},
cancel (reason)
{
cleanup?.();
cleanup = undefined;
},
}));
}

View file

@ -0,0 +1,65 @@
import getFolderSize from "get-folder-size";
import fs from "node:fs/promises";
import path from "node:path";
import { config } from "../../app";
import { and, eq } from "drizzle-orm";
import * as schema from "../../schema/app";
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
export async function calculateSize (installPath: string | null)
{
if (!installPath) return null;
return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size;
}
export async function checkInstalled (installPath: string | null)
{
if (!installPath) return false;
return fs.exists(path.join(config.get('downloadPath'), installPath));
}
export function getLocalGameMatch (id: number, source: string)
{
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, id);
}
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
{
const game: FrontEndGameType = {
id: { id: rom.id, source: 'romm' },
path_cover: `/api/romm${rom.path_cover_large}`,
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
updated_at: new Date(rom.updated_at),
slug: rom.slug,
platform_id: rom.platform_id,
platform_display_name: rom.platform_display_name,
name: rom.name,
path_fs: null,
path_platform_cover: `/api/romm/assets/platforms/${rom.platform_slug}.svg`,
source: null,
source_id: null
};
return game;
}
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
{
const detailed: FrontEndGameTypeDetailed = {
...convertRomToFrontend(rom),
summary: rom.summary,
fs_size_bytes: rom.fs_size_bytes,
paths_screenshots: rom.merged_screenshots.map(s => `/api/romm${s}`),
local: false,
missing: rom.missing_from_fs
};
if (rom.merged_ra_metadata?.achievements)
{
detailed.achievements = {
unlocked: rom.merged_ra_metadata.achievements?.map(a => a.num_awarded).length,
total: rom.merged_ra_metadata.achievements.length
};
}
return detailed;
}

View file

@ -0,0 +1,180 @@
import { IJob, JobContext } from "../task-queue";
import { mkdir } from 'node:fs/promises';
import { eq, or } from 'drizzle-orm';
import fs from 'node:fs/promises';
import { DownloaderHelper } from 'node-downloader-helper';
import StreamZip from 'node-stream-zip';
import * as schema from "../schema/app";
import * as emulatorSchema from "../schema/emulators";
import path from 'node:path';
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
import { config, db, emulatorsDb, jar } from "../app";
interface JobConfig
{
dryRun?: boolean;
dryDownload?: boolean;
}
export class InstallJob implements IJob
{
public id: number;
public config?: JobConfig;
constructor(id: number, config?: JobConfig)
{
this.id = id;
this.config = config;
}
public async start (cx: JobContext)
{
cx.setProgress(0, 'download');
fs.mkdir(config.get('downloadPath'), { recursive: true });
if (this.config?.dryRun !== true)
{
const downloadPath = config.get('downloadPath');
if (this.config?.dryDownload !== true)
{
// download files for rom
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
downloadUrl.searchParams.set('rom_ids', String(this.id));
const downloader = new DownloaderHelper(downloadUrl.href, downloadPath, {
headers: {
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
},
fileName: `${this.id}.zip`,
// Romm doesn't support resume download
override: true
});
cx.abortSignal.addEventListener('abort', downloader.stop);
downloader.on('progress.throttled', e =>
{
cx.setProgress(e.progress, 'download');
});
downloader.on('error', (e) =>
{
cx.abort(e);
});
const finishPromise = new Promise<string>(resolve =>
{
downloader.on("end", ({ filePath }) => resolve(filePath));
});
await downloader.start().catch(err => console.error(err));
const zipFilePath = await finishPromise;
cx.setProgress(0, 'extract');
const zip = new StreamZip.async({ file: zipFilePath });
const totalCount = await zip.entriesCount;
let extractCount = 0;
zip.on('extract', async (entry, file) =>
{
console.log(`Extracted ${entry.name} to ${file}`);
cx.setProgress(extractCount / totalCount * 100, 'extract');
extractCount++;
});
await zip.extract(null, downloadPath);
await zip.close();
await fs.rm(zipFilePath);
}
const rom = (await getRomApiRomsIdGet({ path: { id: this.id }, throwOnError: true })).data;
const romPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
if (this.config?.dryDownload === true)
{
rom.files.length;
await mkdir(path.join(downloadPath, rom.fs_path, rom.fs_name), { recursive: true });
}
// pre-fetch screenshots
const screenshots = await Promise.all(rom.merged_screenshots.map(s => fetch(`${config.get('rommAddress')}${s}`)));
const rommAddress = config.get('rommAddress');
const coverResponse = await fetch(`${rommAddress}${rom.path_cover_large}`);
if (cx.abortSignal.aborted) return;
await db.transaction(async (tx) =>
{
// Search for existing platform
const platformSearch = [];
if (romPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, romPlatform.igdb_id));
if (romPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, romPlatform.igdb_slug));
if (romPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, romPlatform.ra_id));
if (romPlatform.slug) platformSearch.push(eq(schema.platforms.slug, romPlatform.slug));
if (romPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, romPlatform.moby_id));
const esPlatform = await emulatorsDb
.select({ slug: emulatorSchema.systems.name, romm_slug: emulatorSchema.systemMappings.sourceSlug })
.from(emulatorSchema.systems)
.leftJoin(emulatorSchema.systemMappings, eq(emulatorSchema.systemMappings.source, 'romm'))
.where(eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug));
const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
let platformId: number;
if (!existingPlatform)
{
// Create new local platform
const platformCover = await fetch(`${rommAddress}/assets/platforms/${romPlatform.slug.toLocaleLowerCase()}.svg`);
const platform: typeof schema.platforms.$inferInsert = {
slug: romPlatform.slug,
igdb_id: romPlatform.igdb_id,
igdb_slug: romPlatform.igdb_slug,
ra_id: romPlatform.ra_id,
cover: Buffer.from(await platformCover.arrayBuffer()),
cover_type: platformCover.headers.get('content-type'),
name: romPlatform.name,
family_name: romPlatform.family_name,
es_slug: esPlatform.length > 0 ? esPlatform[0].slug : undefined
};
// TODO: add ES slug once I have better way to query ES
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
platformId = id;
} else
{
platformId = existingPlatform.id;
}
// create the rom
const game: typeof schema.games.$inferInsert = {
source_id: rom.id,
source: 'romm',
slug: rom.slug,
path_fs: path.join(rom.fs_path, rom.fs_name),
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
platform_id: platformId,
igdb_id: rom.igdb_id,
ra_id: rom.ra_id,
summary: rom.summary,
name: rom.name,
cover: Buffer.from(await coverResponse.arrayBuffer()),
cover_type: coverResponse.headers.get('content-type')
};
// Save screenshots and update database
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
{
const screenshot: typeof schema.screenshots.$inferInsert = {
game_id: id,
content: Buffer.from(await response.arrayBuffer()),
type: response.headers.get('content-type')
};
return screenshot;
})));
});
}
}
}

View file

@ -1,16 +1,17 @@
import { RPC_PORT } from "../../shared/constants";
import { settings } from "./settings";
import { romm } from "./clients";
import Elysia from "elysia";
import { cors } from "@elysiajs/cors"; import { cors } from "@elysiajs/cors";
import Elysia from "elysia";
import { RPC_PORT } from "../../shared/constants";
import { host } from "../utils"; import { host } from "../utils";
import clients from "./clients";
import { settings } from "./settings";
import { system } from "./system";
const api = new Elysia({ prefix: "/api", serve: {} }) const api = new Elysia({ serve: {} })
.use(cors()) .use([cors(), clients, settings, system]);
.use(romm)
.use(settings);
export type AppType = typeof api; export type RommAPIType = typeof clients;
export type SettingsAPIType = typeof settings;
export type SystemAPIType = typeof system;
export function RunAPIServer () export function RunAPIServer ()
{ {
@ -19,24 +20,11 @@ export function RunAPIServer ()
apiServer: api.listen({ apiServer: api.listen({
port: RPC_PORT, port: RPC_PORT,
hostname: host, hostname: host,
development: process.env.NODE_ENV === 'development', development: process.env.NODE_ENV === 'development'
fetch (req, server) }),
{ async cleanup ()
if (server.upgrade(req, { {
data: undefined
}))
{
return;
}
return api.fetch(req);
},
websocket: {
message (ws, message)
{
}
},
}
})
}; };
} }

54
src/bun/api/schema/app.ts Normal file
View file

@ -0,0 +1,54 @@
import { sql } from "drizzle-orm";
import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core";
export const games = sqliteTable('games', {
id: integer('id').primaryKey({ autoIncrement: true }),
source_id: integer('source_id').unique(),
source: text("source"),
igdb_id: integer("igdb_id").unique(),
name: text("name"),
ra_id: integer('ra_id').unique(),
path_fs: text("path_fs"),
last_played: integer("last_played", { mode: 'timestamp' }),
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`),
slug: text("slug").unique(),
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
cover: blob("cover", { mode: 'buffer' }),
cover_type: text('type'),
summary: text("summary")
});
export const platforms = sqliteTable('platforms', {
id: integer("id").primaryKey({ autoIncrement: true }),
igdb_id: integer("igdb_id").unique(),
igdb_slug: text("igdb_slug").unique(),
moby_id: integer("moby_id").unique(),
name: text("name").notNull(),
es_slug: text('es_slug').unique(),
ra_id: integer('ra_id').unique(),
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
slug: text("slug").unique().notNull(),
metadata: text("metadata", { mode: 'json' }),
cover: blob("cover", { mode: 'buffer' }),
cover_type: text('type'),
family_name: text("family_name")
});
export const collections_games = sqliteTable('collections_games', {
collection_id: integer('collection_id').notNull().references(() => collections.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
game_id: integer('game_id').notNull().references(() => games.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
});
export const collections = sqliteTable('collections', {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text('name')
});
export const screenshots = sqliteTable('screenshots', {
id: integer("id").primaryKey({ autoIncrement: true }),
game_id: integer('game_id').references(() => games.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
content: blob('content', { mode: 'buffer' }).notNull(),
type: text('type')
});

View file

@ -0,0 +1,43 @@
import { relations, sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const emulators = sqliteTable('emulators', {
name: text().primaryKey().unique(),
systempath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
staticpath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
corepath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
androidpackage: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
winregistrypath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
});
export const systems = sqliteTable('systems', {
name: text().primaryKey().unique(),
fullname: text(),
path: text(),
extension: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`)
});
export const systemsRelations = relations(systems, ({ many }) =>
({
commands: many(commands)
}));
export const systemMappings = sqliteTable('systemMappings', {
source: text(),
sourceSlug: text(),
sourceId: integer(),
system: text().notNull().references(() => systems.name)
});
export const commands = sqliteTable('commands', {
system: text().references(() => systems.name, { onDelete: 'cascade', onUpdate: 'cascade' }),
label: text(),
command: text().notNull()
});
export const commandsRelations = relations(commands, ({ one }) => ({
author: one(systems, {
fields: [commands.system],
references: [systems.name],
}),
}));

133
src/bun/api/secrets.ts Normal file
View file

@ -0,0 +1,133 @@
import Conf from "conf";
import projectPackage from '~/package.json';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
let secrets: ISecrets;
interface ISecrets
{
set (data: { service: string, name: string, value: string; }): Promise<void>;
get (data: { service: string, name: string; }): Promise<string | null>;
delete (data: { service: string, name: string; }): Promise<boolean>;
}
class BunSecrets implements ISecrets
{
public set (data: { service: string, name: string, value: string; })
{
return Bun.secrets.set(data);
}
public get (data: { service: string, name: string; })
{
return Bun.secrets.get(data);
}
public delete (data: { service: string, name: string; })
{
return Bun.secrets.delete(data);
}
}
class FallbackSecrets implements ISecrets
{
config: Conf<Record<string, string>>;
machineKey?: Buffer<ArrayBufferLike>;
constructor()
{
this.config = new Conf<Record<string, string>>({
projectName: projectPackage.name,
projectSuffix: 'bun',
configFileMode: 0o600,
configName: 'secrets'
});
console.log("Secrets Store Located at: ", this.config.path);
}
async getMachineKey ()
{
if (!this.machineKey)
{
let raw: string;
try
{
raw = await fs.readFile("/etc/machine-id", 'utf-8');
} catch (error)
{
raw = [
os.homedir(),
os.userInfo().username,
os.platform(),
os.arch(),
os.cpus().map(c => c.model).join(','),
String(os.totalmem())
].filter(Boolean).join("|");
}
this.machineKey = crypto.createHash('sha256').update(raw.trim()).digest();
}
return this.machineKey;
}
public async set ({ service, name, value }: { service: string, name: string, value: string; })
{
const iv = crypto.randomBytes(16);
const key = await this.getMachineKey();
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([
iv,
cipher.update(value, "utf-8"),
cipher.final()
]);
return this.config.set(`${service}-${name}`, encrypted.toString('base64'));
}
public async get ({ service, name }: { service: string, name: string; })
{
const rawBase = this.config.get(`${service}-${name}`);
if (!rawBase)
{
return null;
}
try
{
const key = await this.getMachineKey();
const raw = Buffer.from(rawBase, 'base64');
const iv = raw.subarray(0, 16);
const ciphertext = raw.subarray(16);
const cipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const data = Buffer.concat([cipher.update(ciphertext), cipher.final()]).toString("utf-8");
return data;
} catch (error)
{
console.error(error);
return null;
}
}
public async delete ({ service, name }: { service: string, name: string; })
{
this.config.delete(`${service}-${name}`);
return true;
}
}
/*
try
{
await Bun.secrets.get({ service: 'test', name: 'test' });
secrets = new BunSecrets();
} catch
{
secrets = new FallbackSecrets();
}*/
secrets = new FallbackSecrets();
export default secrets;

View file

@ -1,18 +1,95 @@
import z from "zod"; import z from "zod";
import { SettingsSchema, SettingsType } from "../../shared/constants"; import { SettingsSchema } from "@shared/constants";
import Conf from "conf";
import projectPackage from '../../../package.json';
import Elysia from "elysia"; import Elysia from "elysia";
import { config, customEmulators, db, emulatorsDb } from "./app";
import * as appSchema from './schema/app';
import { findExec } from "./games/services/launchGameService";
import * as emulatorSchema from "./schema/emulators";
import { eq, inArray } from 'drizzle-orm';
import fs from 'node:fs/promises';
export const config = new Conf<SettingsType>({ export const settings = new Elysia({ prefix: '/api/settings' })
projectName: projectPackage.name, .get('/emulators/automatic', async () =>
projectSuffix: 'bun', {
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any, const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id })
defaults: SettingsSchema.parse({}), .from(appSchema.games)
}); .leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id))
console.log("Config Path Located At: ", config.path); .groupBy(appSchema.platforms.es_slug);
export const settings = new Elysia({ prefix: '/settings' }) const platformLookup = new Map(localGames.map(g => [g.es_slug, g.platform_id]));
const commands = await emulatorsDb
.select({ command: emulatorSchema.commands.command, system_slug: emulatorSchema.systems.name })
.from(emulatorSchema.commands).where(inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.es_slug).map(s => s.es_slug!)))))
.leftJoin(emulatorSchema.systems, eq(emulatorSchema.systems.name, emulatorSchema.commands.system));
const emulatorCounts: Record<string, number> = {};
const emulators = commands
.flatMap(command =>
{
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
if (!matches)
{
return undefined;
}
matches.forEach(m =>
{
emulatorCounts[m] = (emulatorCounts[m] ?? 0) + 1;
});
return matches?.map(m => [m, command.system_slug] as [string, string]);
}
).filter(c => !!c);
const uniqueEmulators = new Map(emulators);
return await Promise.all(Array.from(uniqueEmulators.entries()).map(async ([emulator, system_slug]) =>
{
let execPath: string | undefined;
if (customEmulators.has(emulator))
{
execPath = customEmulators.get(emulator);
} else
{
execPath = await findExec(emulator);
}
let platform: number | null | undefined = null;
if (emulatorCounts[emulator] <= 1)
{
platform = platformLookup.get(system_slug);
}
return { emulator: emulator, path: execPath, exists: !!execPath && await fs.exists(execPath), path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null };
}));
}, {
response: z.array(z.object({ emulator: z.string(), path: z.string().optional(), exists: z.boolean(), path_cover: z.string().nullable() }))
})
.put('/emulators/custom/:id', async ({ params: { id }, body: { value } }) =>
{
return customEmulators.set(id, value);
},
{
body: z.object({ value: z.string() })
})
.delete('/emulators/custom/:id', async ({ params: { id } }) =>
{
return customEmulators.delete(id);
})
.get('/emulators/custom/:id', async ({ params: { id } }) =>
{
return customEmulators.get(id);
},
{
response: z.string()
})
.get('/emulators/custom', async () =>
{
return Object.keys(customEmulators.store);
}, {
response: z.array(z.string())
})
.get("/:id", async ({ params: { id } }) => .get("/:id", async ({ params: { id } }) =>
{ {
const value = config.get(id); const value = config.get(id);
@ -25,5 +102,6 @@ export const settings = new Elysia({ prefix: '/settings' })
config.set(id, value); config.set(id, value);
}, { }, {
params: z.object({ id: z.keyof(SettingsSchema) }), params: z.object({ id: z.keyof(SettingsSchema) }),
body: z.object({ value: z.any() }) body: z.object({ value: z.any() }),
}); });

43
src/bun/api/system.ts Normal file
View file

@ -0,0 +1,43 @@
import Elysia from "elysia";
import open from 'open';
import z from "zod";
import os from 'node:os';
import { events } from "./app";
import { isSteamDeckGameMode } from "../utils";
// steam://open/keyboard?XPosition=%i&YPosition=%i&Width=%i&Height=%i&Mode=%d
export const system = new Elysia({ prefix: '/api/system' })
.post('/show_keyboard', async () =>
{
if (isSteamDeckGameMode())
{
open('steam://open/keyboard');
}
})
.get('/info', () =>
{
return {
homeDir: os.homedir(),
user: os.userInfo().username,
arch: os.arch(),
platform: os.platform(),
hostname: os.hostname(),
steamDeck: process.env.SteamDeck,
machine: os.machine()
};
})
.post('/exit', () =>
{
if (process.env.PUBLIC_ACCESS)
{
return;
}
events.emit('exitapp');
})
.post('/open', async ({ query: { url } }) =>
{
open(url);
}, {
query: z.object({ url: z.url() })
});

207
src/bun/api/task-queue.ts Normal file
View file

@ -0,0 +1,207 @@
import EventEmitter from 'node:events';
export class TaskQueue
{
private activeQueue: { context: JobContext, promise?: Promise<void>; }[] = [];
private queue?: { context: JobContext, promise?: Promise<void>; }[] = [];
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
public enqueue (id: string, job: IJob): Promise<void>
{
this.disposeSafeguard();
if (!this.queue || !this.events) throw new Error("Queue disposed");
const context = new JobContext(id, this.events, job);
this.queue.push({ context });
return this.processQueue();
}
private processQueue (): Promise<void>
{
if (!this.queue) return Promise.resolve();
const top = this.queue.pop();
if (top)
{
const promise = top.context.start();
top.promise = promise;
const index = this.queue.length;
this.activeQueue.push(top);
promise.finally(() =>
{
this.activeQueue.splice(index, 1);
setTimeout(this.processQueue);
});
return promise;
}
return Promise.resolve();
}
private disposeSafeguard ()
{
if (!this.queue) throw new Error("Queue disposed");
}
public hasActive ()
{
return this.activeQueue.length > 0;
}
public waitForJob (id: string): Promise<void>
{
return this.queue?.find(j => j.context.id === id)?.promise ?? Promise.resolve();
}
public findJob (id: string): IPublicJob | undefined
{
return this.queue?.find(j => j.context.id === id)?.context;
}
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);
return () => this.events?.removeListener(event, listener);
}
public once<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never)
{
this.events?.once(event, listener);
}
public async close ()
{
this.queue = [];
this.activeQueue.forEach(c => c.context.abort());
return Promise.all(this.activeQueue.map(c => c.promise));
}
}
export interface EventsList
{
progress: [e: ProgressEvent];
abort: [e: AbortEvent];
completed: [e: CompletedEvent];
error: [e: ErrorEvent];
ended: [e: BaseEvent];
}
interface BaseEvent
{
id: string;
job: IJob;
}
interface ErrorEvent extends BaseEvent
{
error: unknown;
}
interface AbortEvent extends BaseEvent
{
reason?: any;
}
interface ProgressEvent extends BaseEvent
{
progress: number;
state?: string;
}
interface CompletedEvent extends BaseEvent
{
}
export interface IJob
{
start (context: JobContext): Promise<any>;
}
export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted';
export interface IPublicJob
{
progress: number;
state?: string;
status: JobStatus;
job: any;
}
export class JobContext implements IPublicJob
{
private m_id: string;
private m_progress: number = 0;
private m_state?: string;
private running: boolean = false;
private aborted: boolean = false;
private completed: boolean = false;
private error?: any;
private events: EventEmitter<EventsList>;
private abortController: AbortController;
private m_job: IJob;
constructor(id: string, events: EventEmitter<EventsList>, job: IJob)
{
this.m_id = id;
this.m_job = job;
this.abortController = new AbortController();
this.abortController.signal.addEventListener('abort', () =>
{
this.aborted = true;
this.events.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this.m_job } satisfies AbortEvent);
});
this.events = events;
}
public async start (): Promise<void>
{
try
{
await this.m_job.start(this);
this.completed = true;
this.events.emit('completed', { id: this.m_id, job: this.m_job });
} catch (error)
{
console.error(error);
this.events.emit('error', { id: this.m_id, error });
this.error = error;
} finally
{
this.running = false;
this.events.emit('ended', { id: this.m_id, job: this.m_job });
}
}
public get status (): JobStatus
{
if (this.completed) return 'completed';
if (this.error) return 'error';
if (this.aborted) return 'aborted';
if (this.running) return 'running';
return 'waiting';
}
public get id () { return this.m_id; }
public get job () { return this.m_job; }
public get abortSignal () { return this.abortController.signal; }
public get progress () { return this.m_progress; }
public get state () { return this.m_state; }
public setProgress (progress: number, state?: string)
{
this.m_progress = progress;
if (state)
this.m_state = state;
this.events.emit('progress', { id: this.m_id, progress, state: state ?? this.m_state, job: this.m_job });
}
public abort (reason?: any)
{
this.error = reason;
this.abortController.abort(reason);
}
}

View file

@ -2,6 +2,8 @@ import { RunBunServer } from './server';
import { RunAPIServer } from './api/rpc'; import { RunAPIServer } from './api/rpc';
import { spawnBrowser } from './utils/browser-spawner'; import { spawnBrowser } from './utils/browser-spawner';
import { BuildParams } from './utils/browser-params'; import { BuildParams } from './utils/browser-params';
import { cleanup as appCleanup, events } from './api/app';
import os from 'node:os';
const api = RunAPIServer(); const api = RunAPIServer();
let bunServer: { stop: () => void; url: URL; } | undefined; let bunServer: { stop: () => void; url: URL; } | undefined;
@ -13,43 +15,80 @@ if (!Bun.env.PUBLIC_ACCESS)
async function cleanup () async function cleanup ()
{ {
await appCleanup();
bunServer?.stop(); bunServer?.stop();
await api.apiServer.stop(); await api.apiServer.stop();
await api.cleanup();
process.exit(0); process.exit(0);
} }
try if (Bun.env.FORCE_BROWSER)
{ {
const webviewWorker = new Worker(process.env.IS_BINARY ? "./webview-worker.ts" : new URL("./webview-worker", import.meta.url).href, { await runBrowser();
} else
{
try
{
await runWebview();
} catch (error)
{
await runBrowser();
}
}
async function runWebview ()
{
const webviewWorker = new Worker(Bun.env.IS_BINARY ? `./webview/${os.platform()}.ts` : new URL(`./webview/${os.platform()}`, import.meta.url).href, {
smol: true, smol: true,
}); });
webviewWorker.addEventListener('error', console.error);
await new Promise(resolve => webviewWorker.addEventListener('close', resolve)); await new Promise((resolve, reject) =>
{
webviewWorker.addEventListener('error', e =>
{
console.error(e.message);
reject(e.error);
});
webviewWorker.addEventListener('message', (e) =>
{
if (e.data === 'destroyed')
{
resolve(true);
}
});
events.on('exitapp', () =>
{
resolve(true);
});
});
await cleanup(); await cleanup();
} }
catch (error)
async function runBrowser ()
{ {
console.error(error);
const browserParams = await BuildParams(); const browserParams = await BuildParams();
if (!browserParams) if (!browserParams)
{ {
console.error("Could not find valid browser"); console.error("Could not find valid browser");
process.exit(); await cleanup();
} } else
{
const browser = spawnBrowser({
browser: browserParams.browser.type,
args: browserParams.args,
env: browserParams.env,
detached: false,
execPath: browserParams.browser.path,
source: browserParams.browser.source,
ipc (message)
{
console.log(message);
},
onExit: cleanup
});
const browser = spawnBrowser({ events.on('exitapp', () => browser.kill(15));
browser: browserParams.browser.type, }
args: browserParams.args,
env: browserParams.env,
detached: true,
execPath: browserParams.browser.path,
source: browserParams.browser.source,
ipc (message)
{
console.log(message);
},
onExit: cleanup
});
} }

19
src/bun/types.d.ts vendored
View file

@ -1,19 +0,0 @@
declare const IS_BINARY: string;
declare module 'download-chromium' {
export default function download ({
platform,
revision = '499413',
log = false,
onProgress = undefined,
installPath = '{__dirname}/.local-chromium' }: {
platform?: 'linux' | 'mac' | 'win32' | 'win64',
revision?: string,
log?: boolean,
installPath?: string,
onProgress?: (percent: number, transferred: number, total: number) => void;
}): Promise<string>
{
};
}

8
src/bun/types/types.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
declare const IS_BINARY: string;
export type ActiveGame = {
process: Bun.Subprocess;
gameId: number;
name: string;
command: string;
};

View file

@ -16,4 +16,15 @@ export function checkRunning (pid: number)
{ {
return error.code === 'EPERM'; return error.code === 'EPERM';
} }
}
export function getErrorMessage (error: unknown): string
{
if (error instanceof Error) return error.message;
return String(error);
}
export function isSteamDeckGameMode ()
{
return !!Bun.env.SteamDeck;
} }

View file

@ -2,8 +2,8 @@ import { SERVER_URL } from "../../shared/constants";
import os from 'node:os'; import os from 'node:os';
import path, { dirname } from 'node:path'; import path, { dirname } from 'node:path';
import { getBrowserPath } from "./get-browser"; import { getBrowserPath } from "./get-browser";
import { config } from "../api/settings"; import { host, isSteamDeckGameMode } from "../utils";
import { host } from "../utils"; import { config } from "../api/app";
export async function BuildParams () export async function BuildParams ()
{ {
@ -42,7 +42,15 @@ export async function BuildParams ()
args.push('--disable-component-update'); args.push('--disable-component-update');
args.push('--allow-insecure-localhost'); args.push('--allow-insecure-localhost');
args.push('--auto-accept-camera-and-microphone-capture'); args.push('--auto-accept-camera-and-microphone-capture');
args.push(`--window-size=${config.get('windowSize.width')},${config.get('windowSize.height')}`);
if (isSteamDeckGameMode())
{
args.push('--kiosk');
} else
{
args.push(`--window-size=${config.get('windowSize.width')},${config.get('windowSize.height')}`);
}
args.push('--password-store=basic'); args.push('--password-store=basic');
args.push('--block-new-web-contents'); args.push('--block-new-web-contents');
args.push('--bwsi'); args.push('--bwsi');
@ -82,8 +90,8 @@ export async function BuildParams ()
if (os.platform() === 'linux') if (os.platform() === 'linux')
{ {
args.push("--disable-web-security"); //args.push("--disable-web-security");
args.push("--no-sandbox"); //args.push("--no-sandbox");
} }
} }

17
src/bun/webview/base.ts Normal file
View file

@ -0,0 +1,17 @@
import { SERVER_URL } from "@/shared/constants";
import Webview from "@rcompat/webview";
import { host } from "../utils";
export default function (webview: Webview)
{
self.addEventListener('message', (e) =>
{
console.log("Terminate");
if (e.data === 'exit')
{
webview.destroy();
}
});
webview.navigate(SERVER_URL(host));
webview.run();
}

7
src/bun/webview/linux.ts Normal file
View file

@ -0,0 +1,7 @@
import Webview from "@rcompat/webview";
import platform from "@rcompat/webview/linux-x64";
import webviewWorkerBase from "./base";
console.log("Launching Webview");
const webview = new Webview({ debug: import.meta.env.NODE_ENV === 'development', platform });
webviewWorkerBase(webview);

View file

@ -1,9 +1,7 @@
import Webview from "@rcompat/webview"; import Webview from "@rcompat/webview";
import platform from "@rcompat/webview/windows-x64"; import platform from "@rcompat/webview/windows-x64";
import { SERVER_URL } from "../shared/constants"; import webviewWorkerBase from "./base";
import { host } from "./utils";
console.log("Launching Webview"); console.log("Launching Webview");
const webview = new Webview({ debug: import.meta.env.NODE_ENV === 'development', platform }); const webview = new Webview({ debug: import.meta.env.NODE_ENV === 'development', platform });
webview.navigate(SERVER_URL(host)); webviewWorkerBase(webview);
webview.run();

View file

@ -1,4 +1,5 @@
import React, { createContext, Ref, useContext, useState } from 'react'; import classNames from 'classnames';
import React, { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { useSessionStorage } from 'usehooks-ts'; import { useSessionStorage } from 'usehooks-ts';
@ -13,15 +14,23 @@ export function AnimatedBackground (data: {
animated?: boolean, animated?: boolean,
}) })
{ {
const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>( const blurBackground = true;
const animateBackground = true;
const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
`${data.backgroundKey!}-last`, `${data.backgroundKey!}-last`,
data.backgroundUrl, data.backgroundUrl,
) : useState<string | undefined>(); ) : useState<string | undefined>();
const [backgroundUrl, setBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>( const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
data.backgroundKey!, data.backgroundKey!,
data.backgroundUrl, data.backgroundUrl,
) : useState(data.backgroundUrl); ) : useState<string | undefined>();
useEffect(() =>
{
setBackgroundUrl(data.backgroundUrl);
}, [data.backgroundUrl]);
function handleSetBackground (url: string) function handleSetBackground (url: string)
{ {
@ -36,6 +45,20 @@ export function AnimatedBackground (data: {
color-mix(in srgb, var(--color-base-100) 80%, transparent) color-mix(in srgb, var(--color-base-100) 80%, transparent)
), url('${url}') center / cover`; ), url('${url}') center / cover`;
let backgroundElements: JSX.Element | undefined = undefined;
if (true)
{
backgroundElements = <div id="container">
<div id="container-inside">
<div className={bgColor} id="circle-small"></div>
<div className={bgColor} id="circle-medium"></div>
<div className={bgColor} id="circle-large"></div>
<div className={bgColor} id="circle-xlarge"></div>
<div className={bgColor} id="circle-xxlarge"></div>
</div>
</div>;
}
return ( return (
<AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}> <AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}>
<div ref={data.ref} <div ref={data.ref}
@ -43,17 +66,9 @@ export function AnimatedBackground (data: {
> >
{!!lastBackgroundUrl && <div className='absolute w-full h-full' style={{ background: backgroundStyle(lastBackgroundUrl), zIndex: -4 }}></div>} {!!lastBackgroundUrl && <div className='absolute w-full h-full' style={{ background: backgroundStyle(lastBackgroundUrl), zIndex: -4 }}></div>}
{!!backgroundUrl && <div key={backgroundUrl} className='absolute w-full h-full animate__animated animate__fadeIn' style={{ background: backgroundStyle(backgroundUrl), zIndex: -3 }}></div>} {!!backgroundUrl && <div key={backgroundUrl} className='absolute w-full h-full animate__animated animate__fadeIn' style={{ background: backgroundStyle(backgroundUrl), zIndex: -3 }}></div>}
<div className="absolute w-full h-full backdrop-blur-3xl" style={{ zIndex: -2 }}></div> {blurBackground && <div className={"absolute w-full h-full backdrop-blur-3xl"} style={{ zIndex: -2 }}></div>}
{data.animated && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}> {data.animated && animateBackground && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
<div id="container"> {backgroundElements}
<div id="container-inside">
<div className={bgColor} id="circle-small"></div>
<div className={bgColor} id="circle-medium"></div>
<div className={bgColor} id="circle-large"></div>
<div className={bgColor} id="circle-xlarge"></div>
<div className={bgColor} id="circle-xxlarge"></div>
</div>
</div>
</div>} </div>}
{data.children} {data.children}
</div> </div>

View file

@ -3,17 +3,16 @@ import
FocusContext, FocusContext,
useFocusable, useFocusable,
} from "@noriginmedia/norigin-spatial-navigation"; } from "@noriginmedia/norigin-spatial-navigation";
import { GameMeta } from "../../shared/constants"; import { FrontEndId, GameMeta } from "../../shared/constants";
import GameCard, { GameCardSkeleton } from "./GameCard"; import GameCard, { GameCardParams } from "./GameCard";
import { JSX, useEffect, useMemo, useState } from "react"; import { JSX, useState } from "react";
import { useLocalStorage } from "usehooks-ts";
import { useScrollSave } from "../scripts/utils";
import classNames from "classnames"; import classNames from "classnames";
import { twMerge } from "tailwind-merge";
export interface GameMetaExtra extends GameMeta export interface GameMetaExtra extends GameMeta
{ {
preview?: JSX.Element; preview?: GameCardParams['preview'];
badge?: JSX.Element; badges?: JSX.Element[];
focusKey: string; focusKey: string;
} }
@ -22,8 +21,9 @@ export function CardList (data: {
type?: string; type?: string;
games: GameMetaExtra[]; games: GameMetaExtra[];
grid?: boolean; grid?: boolean;
onSelectGame?: (id: number) => void; onSelectGame?: (id: string) => void;
onGameFocus?: (id: number) => void; onGameFocus?: (id: string) => void;
className?: string;
}) })
{ {
const { ref, focusKey } = useFocusable({ const { ref, focusKey } = useFocusable({
@ -32,7 +32,7 @@ export function CardList (data: {
function BuildGame (g: GameMetaExtra, i: number) function BuildGame (g: GameMetaExtra, i: number)
{ {
let preview: JSX.Element | string | undefined = g.preview; let preview: GameCardParams['preview'] = g.preview;
if (!preview && g.previewUrl) if (!preview && g.previewUrl)
{ {
preview = g.previewUrl; preview = g.previewUrl;
@ -48,11 +48,17 @@ export function CardList (data: {
subtitle={g.subtitle ?? ""} subtitle={g.subtitle ?? ""}
onFocus={() => onFocus={() =>
{ {
g.onFocus?.();
data.onGameFocus?.(g.id); data.onGameFocus?.(g.id);
(document.querySelector(":root") as HTMLElement).style.setProperty('--selected-card-offset', `${i}s`);
}}
onAction={() =>
{
g.onSelect?.();
data.onSelectGame?.(g.id);
}} }}
onAction={() => data.onSelectGame?.(g.id)}
preview={preview} preview={preview}
badge={g.badge} badges={g.badges}
id={g.id} id={g.id}
/> />
); );
@ -64,8 +70,9 @@ export function CardList (data: {
id={`card-list-${data.id}`} id={`card-list-${data.id}`}
ref={ref} ref={ref}
save-child-focus="session" save-child-focus="session"
className={classNames("my-6 items-center justify-center-safe h-(--game-card-height) ", className={twMerge("my-6 items-center justify-center-safe h-(--game-card-height) ",
data.grid ? "card-grid h-fit gap-5" : 'card-list gap-6' data.grid ? "card-grid h-fit gap-5" : 'card-list gap-6',
data.className
)} )}
onKeyDown={(e) => onKeyDown={(e) =>
{ {

View file

@ -3,9 +3,7 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga
import { HeaderUI } from './Header'; import { HeaderUI } from './Header';
import { GameList, GameListFilter } from './GameList'; import { GameList, GameListFilter } from './GameList';
import { Search, Settings2 } from 'lucide-react'; import { Search, Settings2 } from 'lucide-react';
import ShortcutPrompt from './ShortcutPrompt'; import { JSX, Suspense } from 'react';
import { selfFocusSmart } from '../scripts/utils';
import { JSX, Suspense, useEffect, useState } from 'react';
import Shortcuts from './Shortcuts'; import Shortcuts from './Shortcuts';
import { AutoFocus } from './AutoFocus'; import { AutoFocus } from './AutoFocus';
@ -21,7 +19,7 @@ export interface CollectionsDetailParams
export function CollectionsDetail (data: CollectionsDetailParams) export function CollectionsDetail (data: CollectionsDetailParams)
{ {
const focusKey = `game-list-${data.id}-${data.filters.platformIds?.join()}-${data.filters.collectionId}`; const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`;
const { ref, focusSelf } = useFocusable({ const { ref, focusSelf } = useFocusable({
focusKey, focusKey,
preferredChildFocusKey: `${focusKey}-list`, preferredChildFocusKey: `${focusKey}-list`,
@ -46,7 +44,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<div> <div>
{data.footer} {data.footer}
</div> </div>
<Shortcuts /> <Shortcuts shortcuts={[{ icon: 'steamdeck_button_b', label: 'Back' }]} />
</footer> </footer>
</AnimatedBackground> </AnimatedBackground>
</FocusContext> </FocusContext>

View file

@ -0,0 +1,107 @@
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { createContext, JSX, useContext, useEffect } from "react";
import { twMerge } from "tailwind-merge";
import { useEventListener } from "usehooks-ts";
import { X } from "lucide-react";
const ContextDialogContext = createContext({} as {
close: () => void,
id: string;
});
export function ContextList (data: { options: DialogEntry[]; className?: string; showCloseButton?: boolean; })
{
const context = useContext(ContextDialogContext);
return <ul className={twMerge("list max-h-[70vh] overflow-y-auto", data.className)}>
{data.options.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={context.close} id="close" content="Close" />}
</ul>;
}
export function OptionElement (data: DialogEntry & { onFocus?: () => void; className?: string; })
{
const context = useContext(ContextDialogContext);
const handleFocus = () =>
{
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
data.onFocus?.();
};
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
const { ref, focused, focusSelf } = useFocusable({
focusKey: `${context.id}-list-option-${data.id}`,
onEnterPress: handleAction,
onFocus: handleFocus
});
const colors = {
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused }),
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused }),
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused }),
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused }),
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused }),
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused })
};
return <li ref={ref}
onClick={handleAction}
className={
twMerge("flex cursor-pointer")}>
<p className={twMerge("flex w-full h-14 items-center px-5 rounded-2xl transition-all gap-2",
colors[data.type],
classNames({ "font-semibold": focused }),
data.className)}>
{data.icon}
{data.content}
</p>
</li>;
}
export interface DialogEntry
{
id: string,
content: string | JSX.Element;
icon?: string | JSX.Element;
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void;
}
export function ContextDialog (data: { id: string, children: any | any[], open: boolean, close: () => void; })
{
const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, focusKey: `${data.id}-context-dialog`, isFocusBoundary: true });
useEffect(() =>
{
if (data.open)
{
focusSelf();
}
}, [data.open]);
useEventListener('cancel', (e) =>
{
if (data.open)
{
e.stopPropagation();
data.close();
}
}, ref);
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("absolute modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
classNames({ "opacity-0": !data.open }))
}
onClick={() =>
{
if (data.open) data.close();
}}>
<FocusContext value={focusKey}>
<ContextDialogContext value={{ id: data.id, close: data.close }} >
<div
className={twMerge("bg-base-100/80 delay-200 rounded-4xl p-6 min-w-[30vw] cursor-auto", data.open ? "animate-scale-delayed" : "opacity-0")}
style={{ backdropFilter: 'blur(24px)' }}
onClick={(e) => e.stopPropagation()}
>
{data.children}
</div>
</ContextDialogContext>
</FocusContext>
</dialog>;
}

View file

@ -27,10 +27,10 @@ function FilterCat (
className={classNames( className={classNames(
"flex px-4 h-12 items-center justify-center rounded-full transition-all", "flex px-4 h-12 items-center justify-center rounded-full transition-all",
{ {
"bg-primary text-primary-content drop-shadow-sm cursor-default": "bg-base-content px-3 text-base-300 drop-shadow cursor-default":
focused || data.active, focused || data.active,
"ring-base-content ring-7": focused, "ring-primary ring-7": focused,
"hover:bg-base-300 cursor-pointer": !focused, "hover:bg-base-content/40 cursor-pointer": !focused,
}, },
)} )}
> >

View file

@ -16,23 +16,30 @@ export function GameCardSkeleton ()
); );
} }
export default function GameCard (data: { export interface GameCardParams
{
title: string; title: string;
type?: string; type?: string;
subtitle: string; subtitle: string | JSX.Element;
preview?: string | JSX.Element; preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element);
focusKey: string; focusKey: string;
index: number; index: number;
id: number; id: string;
badge?: JSX.Element; badges?: JSX.Element[];
onFocus?: (id: number) => void; className?: string;
onFocus?: (id: string) => void;
onBlur?: (id: string) => void;
onAction?: () => void; onAction?: () => void;
}) clickFocuses?: boolean;
}
export default function GameCard (data: GameCardParams)
{ {
const { ref, focused, focusSelf } = useFocusable({ const { ref, focused, focusSelf } = useFocusable({
focusKey: data.focusKey, focusKey: data.focusKey,
onFocus: () => data.onFocus?.(data.id), onFocus: () => data.onFocus?.(data.id),
onEnterPress: () => data.onAction?.(), onEnterPress: () => data.onAction?.(),
onBlur: () => data.onBlur?.(data.id)
}); });
useEffect(() => useEffect(() =>
@ -59,33 +66,48 @@ export default function GameCard (data: {
}} }}
onFocus={focusSelf} onFocus={focusSelf}
onDoubleClick={data.onAction} onDoubleClick={data.onAction}
onClick={focused ? data.onAction : focusSelf} onClick={() =>
{
focusSelf();
data.onAction?.();
}}
className={twMerge( className={twMerge(
`game-card game-card-height flex flex-col justify-end`, `game-card game-card-height flex flex-col justify-end z-5`,
'max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)', 'max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)',
"overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer", "overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer",
focused ? focused ?
`animate-wiggle ring-7 bg-base-content text-base-300 ring-primary drop-shadow-xl drop-shadow-base-300/60 scale-102 z-10` : `focused animate-wiggle ring-7 bg-base-content text-base-300 ring-primary drop-shadow-xl drop-shadow-black/30 scale-102 z-10` :
"bg-base-300 text-base-content", "bg-base-300 hover:bg-base-100 hover:scale-102 text-base-content",
classNames({ classNames({
"h-(--game-card-height)": typeof data.preview === "string" "h-(--game-card-height)": typeof data.preview === "string"
}) }),
data.className
)} )}
> >
<div className={twMerge("overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all", focused ? "mt-2 mx-2" : "mt-2 mx-2")}> <div className={twMerge("overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all", focused ? "mt-2 mx-2" : "mt-2 mx-2")}>
{typeof data.preview === "string" ? ( {typeof data.preview === "string" ? (
<img src={data.preview}></img> <img width={5192} height={5192} className={classNames({ "animate-rotate-small": focused })} src={data.preview} ></img>
) : ( ) : (
data.preview typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
)}</div> )}</div>
<div className="h-0 flex pr-2 justify-end items-center">{data.badge}</div> <div className="h-0 flex pr-2 justify-end items-center">
{data.badges?.map(b =>
<div
className={
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 mr-4 transition-colors",
classNames({ "bg-primary text-primary-content": focused }))}
>
{b}
</div>)
}
</div>
<div className="flex flex-col p-4"> <div className="flex flex-col p-4">
<div className="text-xl font-bold text-nowrap text-ellipsis overflow-hidden"> <div className="text-xl font-bold text-nowrap text-ellipsis overflow-hidden">
{data.title} {data.title}
</div> </div>
<div className="text-s">{data.subtitle}</div> <div className="text-s">{data.subtitle}</div>
</div> </div>
</li> </li >
); );
} }

View file

@ -1,15 +1,15 @@
import { keepPreviousData, useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { getRomsApiRomsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
import { GameMetaExtra, CardList } from "./CardList"; import { GameMetaExtra, CardList } from "./CardList";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants"; import { FrontEndId, RPC_URL } from "../../shared/constants";
import { useLocation, useNavigate } from "@tanstack/react-router"; import { useLocation, useNavigate } from "@tanstack/react-router";
import { Suspense, useEffect } from "react";
import { SaveSource } from "../scripts/spatialNavigation"; import { SaveSource } from "../scripts/spatialNavigation";
import { gamesQueryOptions } from "../query-options"; import { rommApi } from "../scripts/clientApi";
import { HardDrive } from "lucide-react";
import { JSX } from "react";
export interface GameListFilter export interface GameListFilter
{ {
platformIds?: number[]; platformId?: number;
collectionId?: number; collectionId?: number;
} }
@ -19,30 +19,39 @@ export interface GameListParams
filters?: GameListFilter, filters?: GameListFilter,
grid?: boolean, grid?: boolean,
setBackground?: (url: string) => void; setBackground?: (url: string) => void;
onGameSelect?: (id: number) => void; onGameSelect?: (id: FrontEndId) => void;
className?: string;
} }
export function GameList (data: GameListParams) export function GameList (data: GameListParams)
{ {
const games = useSuspenseQuery(gamesQueryOptions(data.filters)); const games = useSuspenseQuery({
queryKey: ['games', data.filters ?? 'all'],
queryFn: () => rommApi.api.romm.games.get({
query: {
platform_id: data.filters?.platformId,
collection_id: data.filters?.collectionId
}
}).then(d => d.data)
});
const navigator = useNavigate(); const navigator = useNavigate();
const location = useLocation(); const location = useLocation();
const handleFocus = (id: number) => const handleFocus = (id: FrontEndId) =>
{ {
const game = games.data?.items.find((g) => g.id === id); const game = games.data?.games.find((g) => g.id === id);
if (game) if (game)
{ {
data.setBackground?.( data.setBackground?.(
`${RPC_URL(__HOST__)}/api/romm${game.path_cover_small}`, `${RPC_URL(__HOST__)}${game.path_cover}`,
); );
} }
}; };
function handleDefaultSelect (id: number) function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: number | null)
{ {
SaveSource('details', location.pathname); SaveSource('details');
navigator({ to: '/game/$id', params: { id: String(id) }, viewTransition: { types: ['zoom-in'] } }); navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
}; };
return ( return (
@ -51,23 +60,34 @@ export function GameList (data: GameListParams)
id={data.id} id={data.id}
type="game" type="game"
grid={data.grid} grid={data.grid}
games={games.data.items.sort( className={data.className}
(a, b) => games={games.data?.games
Date.parse(b.rom_user.last_played ?? b.updated_at) -
Date.parse(a.rom_user.last_played ?? a.updated_at),
)
.map( .map(
(g) => (g) =>
({ {
id: g.id, const badges: JSX.Element[] = [];
if (g.id.source === 'local')
{
badges.push(<HardDrive className="size-8 m-1" />);
}
return {
id: `game-${g.id.source}-${g.id.id}`,
focusKey: g.slug ?? `game-${g.id}`, focusKey: g.slug ?? `game-${g.id}`,
title: g.name ?? "", title: g.name ?? "",
subtitle: g.platform_display_name ?? "", subtitle: (
previewUrl: `${RPC_URL(__HOST__)}/api/romm${g.path_cover_large}`, <div className="flex gap-1 items-center">
}) satisfies GameMetaExtra, {!!g.path_platform_cover && <img className="size-4" src={`${RPC_URL(__HOST__)}${g.path_platform_cover}`} />}
)} <p className="opacity-80">{g.platform_display_name}</p>
onGameFocus={handleFocus} </div>
onSelectGame={id => data.onGameSelect ? data.onGameSelect(id) : handleDefaultSelect(id)} ),
previewUrl: `${RPC_URL(__HOST__)}${g.path_cover}`,
badges: badges,
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id) : handleDefaultSelect(g.id, g.source, g.source_id),
onFocus: () => handleFocus(g.id)
} satisfies GameMetaExtra;
},
) ?? []}
/> />
</> </>
); );

View file

@ -50,7 +50,6 @@ function HeaderAvatar (data: {
id={data.id} id={data.id}
ref={ref} ref={ref}
onClick={data.onSelect} onClick={data.onSelect}
style={{ viewTransitionName: data.id }}
className={classNames( className={classNames(
`avatar indicator ring-base-100 ring-offset-base-100 size-14 rounded-full flex items-center justify-center`, `avatar indicator ring-base-100 ring-offset-base-100 size-14 rounded-full flex items-center justify-center`,
bgColors[data.type ?? "none"], bgColors[data.type ?? "none"],
@ -92,6 +91,7 @@ export interface HeaderButton
id: string; id: string;
icon: JSX.Element; icon: JSX.Element;
external?: boolean; external?: boolean;
action?: () => void;
} }
export interface HeaderAccount export interface HeaderAccount
@ -135,7 +135,7 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
], ],
action: () => action: () =>
{ {
SaveSource('settings', location.pathname); SaveSource('settings');
navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } }); navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
}, },
status: user.data ? "status-success" : 'status-error', status: user.data ? "status-success" : 'status-error',
@ -182,6 +182,7 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
id={b.id} id={b.id}
icon={b.icon} icon={b.icon}
external={b.external} external={b.external}
action={b.action}
/>)} />)}
</div> </div>
</div> </div>

View file

@ -0,0 +1,72 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { getPlatformsApiPlatformsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
import { DefaultRommStaleTime, GameMeta, RPC_URL } from "../../shared/constants";
import { CardList, GameMetaExtra } from "./CardList";
import classNames from "classnames";
import { rommApi } from "../scripts/clientApi";
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; })
{
const navigate = useNavigate();
const { data: platforms } = useSuspenseQuery(
{
queryKey: ['platform', 'all'],
queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.platforms.get();
if (error) throw error;
return data.platforms;
},
refetchOnWindowFocus: false,
staleTime: DefaultRommStaleTime,
});
return (
<CardList
type="platform"
id={data.id}
className={data.className}
games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
.map((g) => ({
id: g.slug,
focusKey: g.slug,
title: g.name,
subtitle: g.family_name ?? "",
previewUrl: "",
badges: [(<span className="text-lg font-bold p-2 rounded-full">
{g.game_count}
</span>)],
onFocus: () => data.setBackground(
`https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`,
),
onSelect: () =>
{
navigate({ to: `/platform/${g.source ?? g.id.source}/${g.source_id ?? g.id.id}`, viewTransition: { types: ['zoom-in'] } });
},
preview:
({ focused }) => <div
className="flex h-60 p-6 bg-base-100 justify-center"
style={{
background: `linear-gradient(
color-mix(in srgb, var(--color-base-content) 60%, transparent),
color-mix(in srgb, var(--color-base-300) 60%, transparent)
), url(https://picsum.photos/id/${8 + g.slug.length}/300/300.webp?blur=10) center / cover`,
backgroundBlendMode: "screen",
boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)'
}}
>
<img className={classNames("drop-shadow-2xl", { "animate-rotate": focused })}
src={`${RPC_URL(__HOST__)}${g.path_cover}`}
></img>
</div>
,
} satisfies GameMetaExtra))}
onSelectGame={(id) =>
{
}}
/>
);
}

View file

@ -14,7 +14,7 @@ export default function ShortcutPrompt (data: {
<span <span
onClick={data.onClick} onClick={data.onClick}
className={twMerge( className={twMerge(
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm", "flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm ring-[1px] ring-base-content/10 drop-shadow-black/30",
"sm:text-sm", "sm:text-sm",
data.className, data.className,
classNames({ classNames({

View file

@ -1,14 +1,18 @@
import React from 'react';
import ShortcutPrompt from './ShortcutPrompt'; import ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon';
export default function Shortcuts () export interface Shortcut
{
icon: IconType;
label: string;
action?: () => void;
}
export default function Shortcuts (data: { shortcuts: Shortcut[]; })
{ {
return ( return (
<div style={{ viewTransitionName: 'shortcuts' }} className="flex gap-2"> <div style={{ viewTransitionName: 'shortcuts' }} className="flex gap-2">
<ShortcutPrompt icon="steamdeck_button_a" label="Continue" /> {data.shortcuts.map((s, i) => <ShortcutPrompt key={i} onClick={s.action} icon={s.icon} label={s.label} />)}
<ShortcutPrompt icon="steamdeck_button_b" label="Back" />
<ShortcutPrompt icon="steamdeck_button_x" label="Close" />
<ShortcutPrompt icon="steamdeck_button_y" label="Options" />
</div> </div>
); );
} }

View file

@ -0,0 +1,29 @@
.ball {
border-radius: 50%;
animation: bounce 0.6s 32 alternate;
}
.ball:nth-child(1) {
background: var(--color-accent);
animation-delay: 0s;
}
.ball:nth-child(2) {
background: var(--color-secondary);
animation-delay: 0.2s;
}
.ball:nth-child(3) {
background: var(--color-primary);
animation-delay: 0.4s;
}
@keyframes bounce {
from {
transform: translateY(0);
}
to {
transform: translateY(-30px);
}
}

View file

@ -0,0 +1,10 @@
import './dots.css';
export default function DotsLoading ()
{
return <div className="flex gap-3 justify-center animation_alternate items-center pt-8">
<div className="ball size-6"></div>
<div className="ball size-6"></div>
<div className="ball size-6"></div>
</div>;
}

View file

@ -0,0 +1,28 @@
import { twMerge } from "tailwind-merge";
import
{
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
export function Button (data: { id: string, children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
{
const { ref, focused } = useFocusable({
focusKey: data.id,
onEnterPress: data.onAction,
onFocus: data.onFocus,
focusable: !data.disabled
});
return <button
ref={ref}
onClick={data.onAction}
disabled={data.disabled}
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
"btn-accent": focused
}, data.className))}
type={data.type}
>
{data.children}
</button>;
}

View file

@ -1,7 +1,9 @@
import classNames from "classnames"; import classNames from "classnames";
import { ChangeEventHandler, FocusEventHandler, HTMLInputTypeAttribute, JSX, useRef } from "react"; import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace"; import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
export function OptionInput (data: { export function OptionInput (data: {
name: string; name: string;
@ -11,10 +13,18 @@ export function OptionInput (data: {
icon?: JSX.Element; icon?: JSX.Element;
value?: string; value?: string;
defaultValue?: string; defaultValue?: string;
autocomplete?: HTMLInputAutoCompleteAttribute;
onBlur?: FocusEventHandler<HTMLInputElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
}) })
{ {
const { ref, focused } = useFocusable({
focusKey: data.name, onEnterPress: () =>
{
inputRef.current?.focus();
systemApi.api.system.show_keyboard.post();
}
});
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const option = useOptionContext({ const option = useOptionContext({
onOptionEnterPress () onOptionEnterPress ()
@ -24,10 +34,11 @@ export function OptionInput (data: {
}); });
return ( return (
<label className="flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent"> <label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
<span className={twMerge("text-base-content/80", classNames({ classNames({ "[&_input]:not-focus:ring-7 [&_input]:not-focus:ring-accent": focused }))}>
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused "text-primary-content": option.focused
}))}>{data.icon}</span> }))}>{data.icon}</span>}
<input <input
ref={inputRef} ref={inputRef}
id={data.name} id={data.name}
@ -35,12 +46,13 @@ export function OptionInput (data: {
value={data.value} value={data.value}
defaultValue={data.defaultValue} defaultValue={data.defaultValue}
type={data.type} type={data.type}
autoComplete={data.autocomplete}
onFocus={() => option.focus()} onFocus={() => option.focus()}
placeholder={data.placeholder} placeholder={data.placeholder}
onChange={data.onChange} onChange={data.onChange}
onBlur={data.onBlur} onBlur={data.onBlur}
className={twMerge( className={twMerge(
"input grow rounded-full ring-primary-content focus:ring-3", "input grow rounded-full ring-primary-content focus:ring-7",
data.className, data.className,
)} )}
/> />

View file

@ -42,8 +42,9 @@ export function OptionSpace (data: {
id?: string; id?: string;
className?: string; className?: string;
focusable?: boolean; focusable?: boolean;
children: JSX.Element; children?: any | any[];
label?: string | JSX.Element; label?: string | JSX.Element;
saveLastFocusedChild?: boolean;
}) })
{ {
const eventTarget = useMemo(() => new EventTarget(), []); const eventTarget = useMemo(() => new EventTarget(), []);
@ -51,6 +52,11 @@ export function OptionSpace (data: {
focusKey: data.id, focusKey: data.id,
focusable: data.focusable !== false, focusable: data.focusable !== false,
trackChildren: true, trackChildren: true,
saveLastFocusedChild: data.saveLastFocusedChild ?? false,
onFocus ()
{
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
},
onEnterPress () onEnterPress ()
{ {
eventTarget.dispatchEvent(new CustomEvent("onEnterPress")); eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));

View file

@ -0,0 +1,72 @@
import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react";
import { SettingsType } from "../../../shared/constants";
import { useMutation, useQuery } from "@tanstack/react-query";
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
import { settingsApi } from "../../scripts/clientApi";
type KeysWithValueAssignableTo<T, Value> = {
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
}[keyof T];
export function SettingsOption (data: {
label: string;
id: KeysWithValueAssignableTo<SettingsType, string>;
type: HTMLInputTypeAttribute;
placeholder?: string;
icon?: JSX.Element;
})
{
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
useQuery({
enabled: !!data.id,
queryKey: ["setting", data.id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings({ id: data.id! }).get();
if (error) throw error;
if (!dirty)
{
setLocalValue(String(value.value));
}
return value.value;
},
});
const setSettingMutation = useMutation({
mutationKey: ["setting", data.id],
mutationFn: async (value: any) =>
{
const response = await settingsApi.api.settings({ id: data.id! }).post({ value });
if (response.error) throw response.error;
return response.data;
}
});
const handleSave = useCallback(() =>
{
if (dirty)
{
setDirty(false);
setSettingMutation.mutate(localValue);
}
}, [dirty, setDirty, localValue]);
return (
<OptionSpace label={data.label}>
<OptionInput
icon={data.icon}
name={data.id ?? ""}
type={data.type}
placeholder={data.placeholder}
onBlur={handleSave}
onChange={(e) =>
{
setLocalValue(e.currentTarget.value);
setDirty(true);
}}
value={localValue}
/>
</OptionSpace>
);
}

View file

@ -1,76 +0,0 @@
import classNames from "classnames";
import { CircleX, Cross, X } from "lucide-react";
import { createContext, JSX, useContext, useEffect, useState } from "react";
import toast, { ToastBar, Toaster } from "react-hot-toast";
let toasterGlobalId = 0;
const ToastersContext = createContext(
{} as {
showToaster: (data: Toast) => void;
},
);
export function useToasters ()
{
const toasters = useContext(ToastersContext);
return { ...toasters };
}
interface Toast
{
message: string | JSX.Element;
type: "success" | "info" | "error" | "warning";
duration?: number;
icon?: JSX.Element;
}
interface ToastExtra extends Toast
{
timeout?: NodeJS.Timeout;
id: number;
}
function ToastComponent (data: { toast: Toast; })
{
return (
<div className={classNames(`alert alert-${data.toast.type} `)}>
<span>
{data.toast.icon}
{data.toast.message}
</span>
</div>
);
}
export function Toasters ()
{
const [visibleToasters, setVisible] = useState<ToastExtra[]>([]);
useEffect(() =>
{
return () =>
{
visibleToasters.filter((t) => t.timeout).forEach((t) => clearTimeout(t.timeout));
};
}, [setVisible]);
return (
<Toaster toastOptions={{
className: "bg-base-300 text-base-content", success: {
className: 'bg-success'
}
}}>
{(t) => <ToastBar toast={t} >
{({ icon, message }) => (
<>
{icon}
{message}
{t.type !== 'loading' && (
<button className="size-6 p-0 rounded-full cursor-pointer text-base-100" onClick={() => toast.dismiss(t.id)}><CircleX className="size-5" /></button>
)}
</>
)}
</ToastBar>}
</Toaster>
);
}

View file

@ -11,10 +11,13 @@
import { Route as rootRouteImport } from './../routes/__root' import { Route as rootRouteImport } from './../routes/__root'
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 SettingsDirectoriesRouteImport } from './../routes/settings/directories'
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts' import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
import { Route as PlatformIdRouteImport } from './../routes/platform/$id' import { Route as SettingsAboutRouteImport } from './../routes/settings/about'
import { Route as GameIdRouteImport } from './../routes/game/$id' import { Route as CollectionIdRouteImport } from './../routes/collection.$id'
import { Route as CollectionIdRouteImport } from './../routes/collection/$id' import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id'
import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id'
import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id'
const SettingsRouteRoute = SettingsRouteRouteImport.update({ const SettingsRouteRoute = SettingsRouteRouteImport.update({
id: '/settings', id: '/settings',
@ -26,51 +29,75 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SettingsDirectoriesRoute = SettingsDirectoriesRouteImport.update({
id: '/directories',
path: '/directories',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsAccountsRoute = SettingsAccountsRouteImport.update({ const SettingsAccountsRoute = SettingsAccountsRouteImport.update({
id: '/accounts', id: '/accounts',
path: '/accounts', path: '/accounts',
getParentRoute: () => SettingsRouteRoute, getParentRoute: () => SettingsRouteRoute,
} as any) } as any)
const PlatformIdRoute = PlatformIdRouteImport.update({ const SettingsAboutRoute = SettingsAboutRouteImport.update({
id: '/platform/$id', id: '/about',
path: '/platform/$id', path: '/about',
getParentRoute: () => rootRouteImport, getParentRoute: () => SettingsRouteRoute,
} as any)
const GameIdRoute = GameIdRouteImport.update({
id: '/game/$id',
path: '/game/$id',
getParentRoute: () => rootRouteImport,
} as any) } as any)
const CollectionIdRoute = CollectionIdRouteImport.update({ const CollectionIdRoute = CollectionIdRouteImport.update({
id: '/collection/$id', id: '/collection/$id',
path: '/collection/$id', path: '/collection/$id',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const PlatformSourceIdRoute = PlatformSourceIdRouteImport.update({
id: '/platform/$source/$id',
path: '/platform/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
const LauncherSourceIdRoute = LauncherSourceIdRouteImport.update({
id: '/launcher/$source/$id',
path: '/launcher/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
const GameSourceIdRoute = GameSourceIdRouteImport.update({
id: '/game/$source/$id',
path: '/game/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren '/settings': typeof SettingsRouteRouteWithChildren
'/collection/$id': typeof CollectionIdRoute '/collection/$id': typeof CollectionIdRoute
'/game/$id': typeof GameIdRoute '/settings/about': typeof SettingsAboutRoute
'/platform/$id': typeof PlatformIdRoute
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren '/settings': typeof SettingsRouteRouteWithChildren
'/collection/$id': typeof CollectionIdRoute '/collection/$id': typeof CollectionIdRoute
'/game/$id': typeof GameIdRoute '/settings/about': typeof SettingsAboutRoute
'/platform/$id': typeof PlatformIdRoute
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/settings': typeof SettingsRouteRouteWithChildren '/settings': typeof SettingsRouteRouteWithChildren
'/collection/$id': typeof CollectionIdRoute '/collection/$id': typeof CollectionIdRoute
'/game/$id': typeof GameIdRoute '/settings/about': typeof SettingsAboutRoute
'/platform/$id': typeof PlatformIdRoute
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute
'/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@ -78,33 +105,43 @@ export interface FileRouteTypes {
| '/' | '/'
| '/settings' | '/settings'
| '/collection/$id' | '/collection/$id'
| '/game/$id' | '/settings/about'
| '/platform/$id'
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories'
| '/game/$source/$id'
| '/launcher/$source/$id'
| '/platform/$source/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/settings' | '/settings'
| '/collection/$id' | '/collection/$id'
| '/game/$id' | '/settings/about'
| '/platform/$id'
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories'
| '/game/$source/$id'
| '/launcher/$source/$id'
| '/platform/$source/$id'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/settings' | '/settings'
| '/collection/$id' | '/collection/$id'
| '/game/$id' | '/settings/about'
| '/platform/$id'
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories'
| '/game/$source/$id'
| '/launcher/$source/$id'
| '/platform/$source/$id'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
SettingsRouteRoute: typeof SettingsRouteRouteWithChildren SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
CollectionIdRoute: typeof CollectionIdRoute CollectionIdRoute: typeof CollectionIdRoute
GameIdRoute: typeof GameIdRoute GameSourceIdRoute: typeof GameSourceIdRoute
PlatformIdRoute: typeof PlatformIdRoute LauncherSourceIdRoute: typeof LauncherSourceIdRoute
PlatformSourceIdRoute: typeof PlatformSourceIdRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -123,6 +160,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/settings/directories': {
id: '/settings/directories'
path: '/directories'
fullPath: '/settings/directories'
preLoaderRoute: typeof SettingsDirectoriesRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/accounts': { '/settings/accounts': {
id: '/settings/accounts' id: '/settings/accounts'
path: '/accounts' path: '/accounts'
@ -130,19 +174,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsAccountsRouteImport preLoaderRoute: typeof SettingsAccountsRouteImport
parentRoute: typeof SettingsRouteRoute parentRoute: typeof SettingsRouteRoute
} }
'/platform/$id': { '/settings/about': {
id: '/platform/$id' id: '/settings/about'
path: '/platform/$id' path: '/about'
fullPath: '/platform/$id' fullPath: '/settings/about'
preLoaderRoute: typeof PlatformIdRouteImport preLoaderRoute: typeof SettingsAboutRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof SettingsRouteRoute
}
'/game/$id': {
id: '/game/$id'
path: '/game/$id'
fullPath: '/game/$id'
preLoaderRoute: typeof GameIdRouteImport
parentRoute: typeof rootRouteImport
} }
'/collection/$id': { '/collection/$id': {
id: '/collection/$id' id: '/collection/$id'
@ -151,15 +188,40 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CollectionIdRouteImport preLoaderRoute: typeof CollectionIdRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/platform/$source/$id': {
id: '/platform/$source/$id'
path: '/platform/$source/$id'
fullPath: '/platform/$source/$id'
preLoaderRoute: typeof PlatformSourceIdRouteImport
parentRoute: typeof rootRouteImport
}
'/launcher/$source/$id': {
id: '/launcher/$source/$id'
path: '/launcher/$source/$id'
fullPath: '/launcher/$source/$id'
preLoaderRoute: typeof LauncherSourceIdRouteImport
parentRoute: typeof rootRouteImport
}
'/game/$source/$id': {
id: '/game/$source/$id'
path: '/game/$source/$id'
fullPath: '/game/$source/$id'
preLoaderRoute: typeof GameSourceIdRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
interface SettingsRouteRouteChildren { interface SettingsRouteRouteChildren {
SettingsAboutRoute: typeof SettingsAboutRoute
SettingsAccountsRoute: typeof SettingsAccountsRoute SettingsAccountsRoute: typeof SettingsAccountsRoute
SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute
} }
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsAboutRoute: SettingsAboutRoute,
SettingsAccountsRoute: SettingsAccountsRoute, SettingsAccountsRoute: SettingsAccountsRoute,
SettingsDirectoriesRoute: SettingsDirectoriesRoute,
} }
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
@ -170,8 +232,9 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
SettingsRouteRoute: SettingsRouteRouteWithChildren, SettingsRouteRoute: SettingsRouteRouteWithChildren,
CollectionIdRoute: CollectionIdRoute, CollectionIdRoute: CollectionIdRoute,
GameIdRoute: GameIdRoute, GameSourceIdRoute: GameSourceIdRoute,
PlatformIdRoute: PlatformIdRoute, LauncherSourceIdRoute: LauncherSourceIdRoute,
PlatformSourceIdRoute: PlatformSourceIdRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View file

@ -3,17 +3,96 @@
@plugin "daisyui"; @plugin "daisyui";
@theme { @theme {
--color-dark: #333333;
--color-light: #464646;
--color-light: #828282;
--color-light2: #bcbcbc;
--color-primary: #E5FF00;
--color-alt: #4656E6;
--color-alert: #E60012;
--game-card-height: calc(var(--spacing) * 100); --game-card-height: calc(var(--spacing) * 100);
--game-card-width: calc(var(--spacing) * 64); --game-card-width: calc(var(--spacing) * 64);
--animate-wiggle: wiggle 0.3s ease-in-out 1; --animate-wiggle: wiggle 0.3s ease-in-out 1;
--animate-rotate: rotate 0.3s ease-in-out 1 0.2s;
--animate-rotate-small: rotate-small 0.3s ease-in-out 1 0.2s;
--animate-scale: scale 0.3s ease-in-out 1;
--animate-slide-up: slide-up 0.2s ease-in-out 1;
--animate-scale-delayed: scale 0.3s ease-in-out 1 100ms;
--animate-scale-small: scale-small 0.3s ease-in-out 1;
--animate-fade-out: fade-out 0.3s ease-out 1;
@keyframes slide-up {
0% {
opacity: 0;
transform: translate(0, 10px)
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes scale-small {
0%,
100% {
transform: scale(100%);
}
50% {
transform: scale(102%);
}
}
@keyframes scale {
0%,
100% {
transform: scale(100%);
}
50% {
transform: scale(105%);
}
}
@keyframes rotate-small {
0%,
50%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(0.2deg);
}
75% {
transform: rotate(-0.2deg);
}
}
@keyframes rotate {
0%,
50%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(1deg);
}
75% {
transform: rotate(-1deg);
}
}
@keyframes wiggle { @keyframes wiggle {
@ -39,11 +118,27 @@ html {
} }
@layer components { @layer components {
.background {
-webkit-backface-visibility: hidden;
-webkit-perspective: 1000;
-webkit-transform: translate3d(0, 0, 0);
-webkit-transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000;
transform: translate3d(0, 0, 0);
transform: translateZ(0);
}
.game-card { .game-card {
@apply rounded-2xl; @apply rounded-2xl;
} }
.menu-icon svg { .menu-icon svg {
@apply sm:size-7 md:size-9 transition-all;
}
.menu-icon.focus svg {
@apply sm:size-8 md:size-10; @apply sm:size-8 md:size-10;
} }

View file

@ -10,28 +10,15 @@ import
} 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 } from "@tanstack/react-query";
import { AppType } from "../bun/api/rpc";
import { RPC_URL } from "../shared/constants"; import { RPC_URL } from "../shared/constants";
import "./scripts/gamepads"; import "./scripts/gamepads";
import "./scripts/windowEvents"; import "./scripts/windowEvents";
import { Toasters } from "./contexts/ToasterContext";
import { client as rommClient } from "../clients/romm/client.gen"; import { client as rommClient } from "../clients/romm/client.gen";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import "./scripts/spatialNavigation"; import "./scripts/spatialNavigation";
import
{
treaty
} from '@elysiajs/eden';
const hashHistory = createHashHistory({}); const hashHistory = createHashHistory({});
export const client = treaty<AppType>(RPC_URL(__HOST__), {
keepDomain: true,
fetch: {
credentials: 'include',
}
});
rommClient.setConfig({ rommClient.setConfig({
baseUrl: `${RPC_URL(__HOST__)}/api/romm`, baseUrl: `${RPC_URL(__HOST__)}/api/romm`,
credentials: "include", credentials: "include",
@ -51,8 +38,7 @@ export const Router = createRouter({
history: hashHistory, history: hashHistory,
defaultPreload: "intent", defaultPreload: "intent",
context: { queryClient }, context: { queryClient },
scrollRestoration: true, scrollRestoration: false,
scrollToTopSelectors: ["[save-scroll]"],
defaultNotFoundComponent: () => defaultNotFoundComponent: () =>
{ {
return ( return (
@ -86,7 +72,6 @@ if (!rootElement.innerHTML)
root.render( root.render(
<StrictMode> <StrictMode>
<RouterProvider router={Router} /> <RouterProvider router={Router} />
<Toasters />
</StrictMode>, </StrictMode>,
); );
} }

View file

@ -1,8 +1,8 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEventListener, useSessionStorage } from 'usehooks-ts'; import { useEventListener, useSessionStorage } from 'usehooks-ts';
import { CollectionsDetail } from '../../components/CollectionsDetail'; import { CollectionsDetail } from '../components/CollectionsDetail';
import { getRomsApiRomsGetOptions } from '../../../clients/romm/@tanstack/react-query.gen'; import { getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
import { DefaultRommStaleTime } from '../../../shared/constants'; import { DefaultRommStaleTime } from '../../shared/constants';
export const Route = createFileRoute('/collection/$id')({ export const Route = createFileRoute('/collection/$id')({
component: RouteComponent, component: RouteComponent,
@ -20,7 +20,7 @@ function RouteComponent ()
undefined, undefined,
); );
const navigate = useNavigate(); const navigate = useNavigate();
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } })); useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ["zoom-out"] } }));
return ( return (
<CollectionsDetail setBackground={setBackground} filters={{ collectionId: Number(id) }} /> <CollectionsDetail setBackground={setBackground} filters={{ collectionId: Number(id) }} />

View file

@ -1,241 +0,0 @@
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { getRomApiRomsIdGetOptions } from "../../../clients/romm/@tanstack/react-query.gen";
import { DefaultRommStaleTime, RPC_URL } from "../../../shared/constants";
import { twJoin, twMerge } from "tailwind-merge";
import { JSX, Ref, RefObject, useEffect, useMemo, useRef, useState } from "react";
import { FocusContext, getCurrentFocusKey, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { Clock, HardDrive, Image, Play, Settings, Trophy } from "lucide-react";
import ShortcutPrompt from "../../components/ShortcutPrompt";
import { HeaderUI } from "../../components/Header";
import prettyBytes from 'pretty-bytes';
import { DetailedRomSchema } from "../../../clients/romm";
import { useEventListener } from "usehooks-ts";
import { PopSource } from "../../scripts/spatialNavigation";
import { gameQueryOptions } from "../../query-options";
import { AnimatedBackground } from "../../components/AnimatedBackground";
export const Route = createFileRoute("/game/$id")({
loader: ({ params, context }) => context.queryClient.fetchQuery(gameQueryOptions(Number(params.id))),
component: GameDetailsUI,
pendingComponent: GameDetailsUIPending
});
function GameDetailsUIPending ()
{
return <AnimatedBackground>
<div className="flex flex-col p-2 px-3 w-full h-full">
<HeaderUI />
<div className="flex flex-col justify-center items-center grow">
<span className="loading loading-dots loading-xl"></span>
</div>
</div>
</AnimatedBackground>;
}
export function GameDetailsUI ()
{
// In a component!
const { id } = Route.useParams();
const data = Route.useLoaderData();
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
const backgroundImage = `${RPC_URL(__HOST__)}/api/romm${data.path_cover_small}`;
const mainAreaRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
focusSelf();
}, []);
return (
<AnimatedBackground backgroundUrl={backgroundImage}>
<div className="z-0">
<FocusContext value={focusKey}>
<div className="px-3 py-2" ref={mainAreaRef}>
<HeaderUI />
<Details mainAreaRef={mainAreaRef} game={data} />
</div>
<div className="divider"><Image className="size-16" />Screenshots</div>
<Screenshots screenshots={data.merged_screenshots} />
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
<div className="flex gap-2 text-sm">
</div>
<Shortcuts />
</footer>
</FocusContext>
</div>
</AnimatedBackground>
);
}
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game: DetailedRomSchema; })
{
const { ref, focusKey } = useFocusable({
focusKey: 'main-details', onFocus: () =>
{
data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
},
preferredChildFocusKey: "play-btn",
saveLastFocusedChild: false
});
const navigate = useNavigate();
const platformCoverImg = `${RPC_URL(__HOST__)}/api/romm/assets/platforms/${data.game.platform_slug}.svg`;
const gameCoverImg = `${RPC_URL(__HOST__)}/api/romm${data.game.path_cover_large}`;
useEventListener("cancel", () =>
{
navigate({ to: PopSource('details') ?? '/', viewTransition: { types: ['zoom-out'] } });
});
return <main ref={ref} className="flex p-3 flex-col h-[75vh]">
<FocusContext value={focusKey}>
<section className="flex my-4 p-12 pt-4 gap-12 h-full rounded-4xl z-0">
<div className="flex flex-col gap-6">
<img className="h-full w-auto rounded-3xl drop-shadow-2xl drop-shadow-base-300/40 object-contain" src={gameCoverImg}></img>
</div>
<div className="flex-1 flex flex-col gap-6 pt-16">
<div className="flex gap-6">
<Detail icon={<Clock />} >{data.game.rom_user.last_played ? new Date(data.game.rom_user.last_played).toDateString() : "Never"}</Detail>
<Detail icon={<HardDrive />} >{prettyBytes(data.game.fs_size_bytes)}</Detail>
<Detail icon={<img className="size-6" src={platformCoverImg}></img>} >{data.game.platform_display_name}</Detail>
</div>
<p className="text-base-content/80 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden">
{data.game.summary}
</p>
<ActionButtons game={data.game} />
</div>
</section>
</FocusContext>
</main>;
}
function Screenshot (data: { url: string; index: number; setFocused?: (index: number) => void; })
{
const { ref, focused, focusSelf } = useFocusable({
focusKey: `screenshot-${data.index}`,
onFocus: () =>
{
(ref.current as HTMLElement).scrollIntoView({ inline: 'center', behavior: 'smooth' });
data.setFocused?.(data.index);
}
});
return <img onClick={focusSelf} ref={ref} className={twJoin("h-[60vh] rounded-3xl", classNames({
"ring-7 ring-primary": focused,
"cursor-pointer": !focused
}))} src={`${RPC_URL(__HOST__)}/api/romm${data.url}`}></img>;
}
function Screenshots (data: { screenshots: string[]; })
{
const [focusedScreenshot, setFocusedScreenshot] = useState(-1);
const { ref, focusKey } = useFocusable({
focusKey: 'screenshot-list',
onFocus: () => (ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' }),
onBlur: () => setFocusedScreenshot(-1)
});
return <div ref={ref} className="flex flex-col p-16 pt-2 w-full z-0">
<FocusContext value={focusKey}>
<div className="flex gap-6 px-16 py-2 overflow-hidden">
{data.screenshots.map((s, i) => <Screenshot setFocused={setFocusedScreenshot} index={i} url={s} />)}
</div>
<div className="flex gap-2 py-6 justify-center items-center h-3">{data.screenshots.map((s, i) =>
{
const focused = i === focusedScreenshot;
return <button onClick={() => setFocus(`screenshot-${i}`)} className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
}))}></button>;
})}</div>
</FocusContext>
</div>;
}
function PlayButton ()
{
const { focused, ref } = useFocusable({
focusKey: "play-btn"
});
return (
<div ref={ref} className="flex gap-3 items-center font-semibold">
<button className={twMerge("bg-primary p-6 rounded-full cursor-pointer",
"hover:bg-base-content hover:text-base-200 hover:ring-7 hover:ring-primary",
classNames({
"bg-base-content text-base-200 ring-7 ring-primary": focused
}))}><Play className="size-8" /></button>
<p className="text-4xl">Play</p>
</div>
);
}
//<PlayButton />
function ActionButtons (data: { game: DetailedRomSchema; })
{
const [hoverText, setHoverText] = useState<string | undefined>(undefined);
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
return <div ref={ref} className="flex gap-4 items-center">
<FocusContext value={focusKey}>
<ActionButton onFocus={() => setHoverText("")} type='primary' id="play"><Play /></ActionButton>
<div className="divider divider-horizontal m-0"></div>
{!!data.game.merged_ra_metadata?.achievements && <ActionButton onFocus={() => setHoverText("Achievements")} type="base" id="achievements" >
<div className="flex flex-col gap-2 items-center text-2xl">
<div className="flex flex-row">
<Trophy />
{`${data.game.merged_ra_metadata.achievements.filter(a => a.type).length}/${data.game.merged_ra_metadata.achievements.length}`}
</div>
<progress className="progress progress-secondary w-full" value={50} max="100"></progress>
</div>
</ActionButton>}
<ActionButton onFocus={() => setHoverText("Settings")} type="base" id="settings" icon={<Settings />} />
{!!hoverText && <p className="py-2 px-4 bg-accent text-accent-content rounded-full">{hoverText}</p>}
</FocusContext>
</div>;
}
function Shortcuts ()
{
const { ref, focusKey } = useFocusable({ focusKey: "action-buttons" });
return <div ref={ref} className="flex gap-2" style={{ viewTransitionName: 'shortcuts' }}>
<FocusContext value={focusKey}>
<ShortcutPrompt icon="steamdeck_button_a" label="Continue" />
<ShortcutPrompt icon="steamdeck_button_b" label="Back" />
<ShortcutPrompt icon="steamdeck_button_x" label="Close" />
<ShortcutPrompt icon="steamdeck_button_y" label="Options" />
</FocusContext>
</div>;
}
function Detail (data: { icon: JSX.Element; children?: any | any[]; })
{
return (
<div className="flex gap-2">
{data.icon}
{data.children}
</div>
);
}
function ActionButton (data: { id: string, icon?: JSX.Element, children?: any | any[]; className?: string; type: "primary" | 'base' | "accent"; onFocus?: () => void; })
{
const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus });
const styles = {
primary: twMerge("bg-primary text-primary-content rounded-full size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary",
classNames({
"bg-base-content text-base-300 ring-7 ring-primary": focused
})),
base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({
"bg-base-content text-base-300 ring-7 ring-primary": focused
})),
accent: twMerge("bg-primary text-primary-content ", classNames({
"bg-base-content text-base-300 ring-7 ring-primary": focused
}))
};
return (
<button ref={ref} className={twMerge("header-icon flex flex-col gap-2 px-5 py-4 rounded-3xl text-2xl justify-center items-center cursor-pointer",
"hover:ring-7 hover:ring-primary", styles[data.type], data.className)}>
{data.icon}
{data.children}
</button>
);
}

View file

@ -0,0 +1,490 @@
import { createFileRoute } from "@tanstack/react-router";
import { FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
import { twJoin, twMerge } from "tailwind-merge";
import { JSX, RefObject, useEffect, useRef, useState } from "react";
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { Clock, CloudDownload, Download, Folder, HardDrive, Image, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react";
import { HeaderUI } from "../../components/Header";
import prettyBytes from 'pretty-bytes';
import { useEventListener } from "usehooks-ts";
import { PopSource, SaveSource, useFocusEventListener } from "../../scripts/spatialNavigation";
import { AnimatedBackground } from "../../components/AnimatedBackground";
import { rommApi } from "../../scripts/clientApi";
import toast from "react-hot-toast";
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Router } from "../..";
import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog";
import Shortcuts from "../../components/Shortcuts";
const placeholderText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam eleifend ante magna, id euismod quam tempus sit amet. Maecenas sem lectus, euismod imperdiet volutpat ac, posuere in turpis. Vestibulum commodo lacinia lectus sit amet ultricies. Integer euismod consequat elit, sit amet dapibus libero fermentum nec. Aliquam accumsan placerat dui a maximus. Nunc lectus urna, scelerisque a magna non, imperdiet lobortis turpis. Aliquam magna dui, porttitor in nisl vitae, pretium fringilla sem. ";
const gameQuery = (source: string, id: number) => queryOptions({
queryKey: ['game', source, id],
queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.game({ source })({ id }).get();
if (error) throw error;
return data;
}
});
export const Route = createFileRoute("/game/$source/$id")({
loader: ({ params, context }) =>
{
context.queryClient.prefetchQuery(gameQuery(params.source, Number(params.id)));
},
component: GameDetailsUI,
pendingComponent: GameDetailsUIPending,
});
function GameDetailsUIPending ()
{
return <AnimatedBackground>
<div className="flex flex-col p-2 px-3 w-full h-full">
<HeaderUI />
<div className="flex flex-col justify-center items-center grow">
<span className="loading loading-dots loading-xl"></span>
</div>
</div>
</AnimatedBackground>;
}
export function GameDetailsUI ()
{
const { source, id } = Route.useParams();
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
const backgroundImage = data?.path_cover ? `${RPC_URL(__HOST__)}${data?.path_cover}` : undefined;
const mainAreaRef = useRef<HTMLDivElement>(null);
useEventListener("cancel", (e) =>
{
e.stopPropagation();
HandleGoBack();
}, ref);
useEffect(() =>
{
if (isSuccess)
{
focusSelf();
}
}, [isSuccess]);
return (
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage}>
<div className="z-0 overflow-y-scroll">
<FocusContext value={focusKey}>
<div className="px-3 py-2" ref={mainAreaRef}>
<HeaderUI />
<Details mainAreaRef={mainAreaRef} game={data} />
</div>
<div className="divider"><div className="flex items-center gap-3 opacity-60"><Image className="size-6" />Screenshots</div></div>
{!!data && <Screenshots screenshots={data.paths_screenshots} />}
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
<div className="flex gap-2 text-sm">
</div>
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_a', label: "Play" }]} />
</footer>
</FocusContext>
</div>
</AnimatedBackground>
);
}
function HandleGoBack ()
{
Router.navigate({ to: PopSource('details') ?? '/', viewTransition: { types: ['zoom-out'] } });
}
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
{
const { ref, focusKey } = useFocusable({
focusKey: 'main-details', onFocus: () =>
{
data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
},
preferredChildFocusKey: "play-btn",
saveLastFocusedChild: false
});
const platformCoverImg = `${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`;
const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined;
let fileSizeIcon: JSX.Element | undefined;
if (!data.game)
{
fileSizeIcon = <span className="loading loading-spinner loading-lg"></span>;
} else if (data.game.missing)
{
fileSizeIcon = <TriangleAlert />;
} else if (data.game.local)
{
fileSizeIcon = <HardDrive />;
} else
{
fileSizeIcon = <CloudDownload />;
}
return <main ref={ref} className="flex p-3 flex-col h-[75vh]">
<FocusContext value={focusKey}>
<section className="flex my-4 p-12 pt-4 gap-12 h-full rounded-4xl z-0">
<div className="flex gap-6 overflow-hidden bg-base-300 justify-end h-full rounded-3xl aspect-3/4">
{gameCoverImg ?
<img className="drop-shadow-2xl drop-shadow-base-300/40 h-full" src={gameCoverImg}></img> :
<div className="skeleton w-full h-full"></div>
}
</div>
<div className="flex-2 flex flex-col gap-6 pt-16">
<div className="flex gap-6">
<Detail icon={<Clock />} >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"}</Detail>
{!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) &&
<div className={classNames({ "text-error": data.game.missing })}>
<div className="tooltip" data-tip={data.game.path_fs}>
<Detail icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</Detail>
</div>
</div>}
<Detail icon={<img className="size-6" src={platformCoverImg}></img>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</Detail>
<Detail icon={
<Store />
} >
{data.game?.source ?? data.game?.id.source}
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
</div>
<p className="text-base-content/80 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden">
{data.game?.summary ?? <div className="flex flex-col gap-4 w-full">
<div className="skeleton h-4 w-[30%]"></div>
<div className="skeleton h-4 w-[80%]"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-[60%]"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-[80%]"></div>
</div>}
</p>
{!!data.game && <ActionButtons key="actions" game={data.game} />}
</div>
</section>
</FocusContext>
</main>;
}
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
{
const { ref, focused, focusSelf } = useFocusable({
focusKey: `screenshot-${data.index}`,
onFocus: () =>
{
(ref.current as HTMLElement).scrollIntoView({ inline: 'center', behavior: 'smooth' });
data.setFocused?.(data.index);
}
}); 4096;
return <img className={twJoin("h-[60vh] rounded-3xl", classNames({
"ring-7 ring-primary": focused,
"cursor-pointer": !focused
}))} onClick={focusSelf} ref={ref} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />;
}
function Screenshots (data: { screenshots: string[]; })
{
const scrollRef = useRef(null);
const [focusedScreenshot, setFocusedScreenshot] = useState(-1);
const { ref, focusKey } = useFocusable({
focusKey: 'screenshot-list',
onFocus: () => (ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' }),
onBlur: () => setFocusedScreenshot(-1)
});
return <div ref={ref} className="flex flex-col p-16 pt-2 w-full z-0">
<FocusContext value={focusKey}>
<div
ref={scrollRef}
className="flex gap-6 px-16 py-2 overflow-hidden justify-center-safe"
>
{data.screenshots.map((s, i) => <Screenshot key={s} setFocused={setFocusedScreenshot} index={i} path={s} />)}
</div>
<div className="flex gap-2 py-6 justify-center items-center h-3">{data.screenshots.map((s, i) =>
{
const focused = i === focusedScreenshot;
return <button key={i} onClick={() => setFocus(`screenshot-${i}`)} className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
}))}></button>;
})}</div>
</FocusContext>
</div>;
}
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; })
{
if (!data.game.achievements)
{
return false;
}
return <ActionButton key="achievements" square tooltip="Achievements" type="base" id="achievements" >
<div className="flex flex-col gap-2 items-center text-2xl">
<div className="flex flex-row">
<Trophy />
{`${data.game.achievements.unlocked}/${data.game.achievements.total}`}
</div>
<progress className="progress progress-secondary w-full" value={50} max="100"></progress>
</div>
</ActionButton>;
}
function MainActions (data: { game: FrontEndGameTypeDetailed; })
{
const { source, id } = Route.useParams();
const installMutation = useMutation({
mutationKey: ['install'],
mutationFn: async () =>
{
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).install.post();
if (error) throw error;
}
});
const playMutation = useMutation({
mutationKey: ['play'],
mutationFn: async () =>
{
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post();
if (error) throw error;
}
});
const [progress, setProgress] = useState<number | undefined>(undefined);
const [status, setStatus] = useState<GameStatusType | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [details, setDetails] = useState<string | undefined>(undefined);
const queryClient = useQueryClient();
useEffect(() =>
{
const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${data.game.id.source}/${data.game.id.id}`);
es.onmessage = ({ data }) =>
{
const stats = JSON.parse(data) as GameInstallProgress;
setProgress(stats.progress);
setStatus(stats.status);
setDetails(stats.details);
setError(stats.error);
};
es.addEventListener('refresh', () =>
{
queryClient.invalidateQueries({ queryKey: ['game', data.game.id] });
location.reload();
});
es.onerror = (event) =>
{
const error = (event as any).data?.error;
if (error)
{
toast.error(error);
}
};
return () => es.close();
}, [data.game.id]);
let progressIcon: JSX.Element | undefined = undefined;
switch (status)
{
case 'download':
progressIcon = <Download />;
break;
case 'extract':
progressIcon = <PackageOpen />;
break;
}
let mainButton: JSX.Element | undefined = undefined;
if (status === 'installed')
{
mainButton = <ActionButton onAction={() =>
{
playMutation.mutate();
SaveSource('launch');
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
}} tooltip={details} key="primary" type='primary' id="mainAction"><Play /></ActionButton>;
}
else if (error)
{
mainButton = <ActionButton
key="error"
tooltip={error}
tooltip_type="error"
type='error'
onAction={() =>
{
if (status === 'missing-emulator')
{
SaveSource('settings');
Router.navigate({ to: '/settings/directories', viewTransition: { types: ['zoom-in'] } });
}
}}
id="mainAction">
<TriangleAlert />
</ActionButton>;
}
else
{
mainButton = <ActionButton
key={status ?? 'unknown'}
disabled={installMutation.isPending}
onAction={() =>
{
if (status === 'install')
{
installMutation.mutate();
}
}}
tooltip={details ?? status}
type='primary'
id="mainAction">
{status === 'install' ? <Download /> : <span className="loading loading-spinner loading-lg"></span>}
</ActionButton>;
}
return <div className="flex gap-2">
{mainButton}
<div className="divider divider-horizontal m-0"></div>
{progress !== null && !!progressIcon && <ActionButton 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 className="flex flex-row">
{progressIcon}
</div>
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
</div>
</ActionButton>}
</div>;
}
function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
{
const [hoverText, setHoverText] = useState<string | undefined>(undefined);
const [hoverTextType, setHoverTextType] = useState<string>('accent');
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
const [open, setOpen] = useState(false);
const deleteMutation = useMutation({
mutationKey: ['delete', data.game.id],
mutationFn: () => rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).delete(),
onSuccess: () =>
{
location.reload();
console.log("Deleted");
}
});
const contextOptions: DialogEntry[] = [];
if (data.game.local)
{
contextOptions.push({
id: 'delete',
action: () =>
{
deleteMutation.mutate();
},
icon: <Trash />,
content: "Delete",
type: 'error'
});
}
const handleTooltipSet = (e: HTMLElement) =>
{
const dataTooltip = e.getAttribute('data-tooltip');
setHoverText(dataTooltip ?? undefined);
setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent');
};
useFocusEventListener('focuschanged', (e) =>
{
if (e.target instanceof HTMLElement)
{
handleTooltipSet(e.target);
}
}, ref);
const tooltipStyles = {
base: 'bg-base-100 text-base-content',
accent: 'bg-accent text-accent-content',
error: 'bg-error text-error-content'
};
return <div ref={ref} className="flex overflow-hidden p-2 gap-4 h-32 items-center">
<FocusContext value={focusKey}>
<MainActions game={data.game} />
<AchievementsInfo game={data.game} />
<ActionButton tooltip="Settings" onAction={() => setOpen(true)} type="base" id="settings" icon={<Settings />} >
</ActionButton >
<ContextDialog id="settings-context" open={open} close={() =>
{
setOpen(false);
setFocus("settings");
}}>
<ContextList options={contextOptions} />
</ContextDialog>
{!!hoverText && <p className={twMerge("flex py-2 px-4 rounded-4xl text-wrap wrap-anywhere", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
</FocusContext>
</div>;
}
function Detail (data: { icon: JSX.Element; children?: any | any[]; })
{
return (
<div className="flex gap-2">
{data.icon}
{data.children}
</div>
);
}
function ActionButton (data: {
id: string,
icon?: JSX.Element,
children?: any | any[];
className?: string;
type: "primary" | 'base' | "accent" | 'error';
square?: boolean,
onFocus?: () => void;
tooltip?: string,
tooltip_type?: 'accent' | 'error';
onAction?: () => void;
disabled?: boolean;
})
{
const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
const styles = {
primary: twMerge("bg-primary text-primary-content",
classNames({
"bg-base-content text-base-300 ring-7 ring-primary": focused
})),
base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({
"bg-base-content text-base-300 ring-7 ring-primary": focused
})),
accent: twMerge("bg-primary text-primary-content ", classNames({
"bg-base-content text-base-300 ring-7 ring-primary": focused
})),
error: twMerge("bg-error text-error-content ", classNames({
"bg-error text-error-content ring-7 ring-primary": focused
})),
};
return (
<button
disabled={data.disabled}
ref={ref}
onClick={data.onAction}
data-tooltip={data.tooltip}
data-tooltip_type={data.tooltip_type}
className={twMerge("header-icon flex flex-col gap-2 px-5 py-4 rounded-3xl text-2xl justify-center items-center cursor-pointer disabled:opacity-30",
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
{data.icon}
{data.children}
</button>
);
}

View file

@ -1,4 +1,4 @@
import { JSX, Suspense, useContext } from "react"; import { JSX, Suspense, useContext, useState } from "react";
import import
{ {
Gamepad2, Gamepad2,
@ -16,7 +16,7 @@ import
useLocation, useLocation,
useNavigate, useNavigate,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import import
{ {
FocusContext, FocusContext,
@ -24,13 +24,12 @@ import
} from "@noriginmedia/norigin-spatial-navigation"; } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames"; import classNames from "classnames";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants"; import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { useLocalStorage, useSessionStorage } from "usehooks-ts"; import { useEventListener, useLocalStorage } from "usehooks-ts";
import import
{ {
getCollectionsApiCollectionsGetOptions, getCollectionsApiCollectionsGetOptions,
getPlatformsApiPlatformsGetOptions,
} from "../../clients/romm/@tanstack/react-query.gen"; } from "../../clients/romm/@tanstack/react-query.gen";
import { CardList } from "../components/CardList"; import { CardList, GameMetaExtra } from "../components/CardList";
import { HeaderUI } from "../components/Header"; import { HeaderUI } from "../components/Header";
import { FilterUI } from "../components/Filters"; import { FilterUI } from "../components/Filters";
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground"; import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
@ -42,6 +41,8 @@ import SaveScroll from "../components/SaveScroll";
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary"; import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Shortcuts from "../components/Shortcuts"; import Shortcuts from "../components/Shortcuts";
import { PlatformsList } from "../components/PlatformsList";
import { systemApi } from "../scripts/clientApi";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: ConsoleHomeUI, component: ConsoleHomeUI,
@ -60,64 +61,7 @@ const filters = {
}, },
}; };
function PlatformList (data: { id: string, setBackground: (url: string) => void; }) function CollectionList (data: { id: string, setBackground: (url: string) => void; className?: string; })
{
const navigate = useNavigate();
const { data: platforms } = useSuspenseQuery({
...getPlatformsApiPlatformsGetOptions(),
refetchOnWindowFocus: false,
staleTime: DefaultRommStaleTime,
});
return (
<CardList
type="platform"
id={data.id}
games={platforms.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
.map((g) => ({
id: g.id,
focusKey: g.slug,
title: g.display_name,
subtitle: g.family_name ?? "",
previewUrl: g.url_logo ?? "",
badge: (
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
{g.rom_count}
</span>
),
preview: (
<div
className="flex h-60 p-6 bg-base-100 justify-center items-center"
style={{
background: `linear-gradient(
color-mix(in srgb, var(--color-base-content) 60%, transparent),
color-mix(in srgb, var(--color-base-300) 60%, transparent)
), url(https://picsum.photos/id/${10 + g.id}/300/300.webp?blur=10) center / cover`,
backgroundBlendMode: "screen",
}}
>
<img
src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${g.slug.toLocaleLowerCase()}.svg`}
></img>
</div>
),
}))}
onSelectGame={(id) =>
{
navigate({ to: `/platform/${id}`, viewTransition: { types: ['zoom-in'] } });
}}
onGameFocus={(id) =>
{
data.setBackground(
`https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`,
);
}}
/>
);
}
function CollectionList (data: { id: string, setBackground: (url: string) => void; })
{ {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: collections } = useSuspenseQuery({ const { data: collections } = useSuspenseQuery({
@ -130,19 +74,20 @@ function CollectionList (data: { id: string, setBackground: (url: string) => voi
<CardList <CardList
type="collection" type="collection"
id={data.id} id={data.id}
className={data.className}
games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at)) games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
.map((g) => ({ .map((g) => ({
id: g.id, id: String(g.id),
title: g.name, title: g.name,
focusKey: `collection-${g.id}`, focusKey: `collection-${g.id}`,
subtitle: g.user__username, subtitle: g.user__username,
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`, previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
badge: ( badges: [
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2"> <span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
{g.rom_count} {g.rom_count}
</span> </span>
), ],
}))} } satisfies GameMetaExtra))}
onSelectGame={(id) => onSelectGame={(id) =>
{ {
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } }); navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
@ -171,21 +116,45 @@ function HomeList (data: {
}) })
{ {
const bg = useContext(AnimatedBackgroundContext); const bg = useContext(AnimatedBackgroundContext);
const { ref, focused, focusKey, focusSelf } = useFocusable({ const { ref, focused, focusKey, focusSelf } = useFocusable({
focusKey: "home-list", focusKey: "home-list",
preferredChildFocusKey: `${data.selectedFilter}-list` preferredChildFocusKey: `${data.selectedFilter}-list`
}); });
const lists = { const lists = {
consoles: <PlatformList id={"consoles-list"} setBackground={bg.setBackground} />, consoles: <PlatformsList className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
games: <GameList id="games-list" setBackground={bg.setBackground} />, games: <GameList className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
collections: <CollectionList id={"collections-list"} setBackground={bg.setBackground} />, collections: <CollectionList className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
}; };
useEventListener('wheel', e =>
{
const deltaY = e.deltaY;
const deltaYSign = Math.sign(e.deltaY);
if (deltaYSign == -1)
{
(ref.current as HTMLElement)?.scrollBy({
top: 0,
left: deltaY,
behavior: 'auto'
});
} else
{
(ref.current as HTMLElement)?.scrollBy({
top: 0,
left: deltaY,
behavior: 'auto'
});
}
});
return ( return (
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<div ref={ref} className="flex overflow-x-scroll no-scrollbar pb-3 mb-1 justify-center-safe"> <div ref={ref} className="flex overflow-x-scroll no-scrollbar pb-3 mb-1 justify-center-safe" style={{
mask: `linear-gradient(to right, rgba(0,0,0,0.8) 0%, black 10%, black 90%, rgba(0,0,0,0.8) 100%)`
}}>
<div className="flex px-16"> <div className="flex px-16">
<ErrorBoundary fallback={<HomeListError focused={focused} />}> <ErrorBoundary fallback={<HomeListError focused={focused} />}>
<Suspense key={data.selectedFilter} fallback={<LoadingCardList placeholderCount={8} />}> <Suspense key={data.selectedFilter} fallback={<LoadingCardList placeholderCount={8} />}>
@ -206,6 +175,14 @@ export default function ConsoleHomeUI ()
keyof typeof filters keyof typeof filters
>("home-filter-selected", "games"); >("home-filter-selected", "games");
const closeMutation = useMutation({
mutationKey: ['close'], mutationFn: async () =>
{
const { error } = await systemApi.api.system.exit.post();
if (error) throw error;
}
});
const { ref, focusKey, focusSelf } = useFocusable({ const { ref, focusKey, focusSelf } = useFocusable({
forceFocus: true, forceFocus: true,
autoRestoreFocus: false, autoRestoreFocus: false,
@ -220,7 +197,7 @@ export default function ConsoleHomeUI ()
<div className="px-3 w-full pt-2"> <div className="px-3 w-full pt-2">
<HeaderUI buttons={[ <HeaderUI buttons={[
{ id: "search", icon: <Search /> }, { id: "search", icon: <Search /> },
{ id: "power-button", icon: <Power />, external: true } { id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() }
]} /> ]} />
</div> </div>
<div className="flex w-full flex-col grow justify-evenly"> <div className="flex w-full flex-col grow justify-evenly">
@ -243,7 +220,7 @@ export default function ConsoleHomeUI ()
<footer className="px-2 pb-2 flex items-center justify-between"> <footer className="px-2 pb-2 flex items-center justify-between">
<div className="flex gap-2 text-sm"> <div className="flex gap-2 text-sm">
</div> </div>
<Shortcuts /> <Shortcuts shortcuts={[{ icon: 'steamdeck_button_a', label: 'Select' }]} />
</footer> </footer>
</FocusContext.Provider> </FocusContext.Provider>
</AnimatedBackground> </AnimatedBackground>
@ -282,7 +259,7 @@ function MainMenu (data: {})
<CircleIcon <CircleIcon
action={() => action={() =>
{ {
SaveSource('settings', location.pathname); SaveSource('settings');
navigate({ to: "/settings/accounts", viewTransition: { types: ['zoom-in'] } }); navigate({ to: "/settings/accounts", viewTransition: { types: ['zoom-in'] } });
}} }}
icon={<Settings />} icon={<Settings />}
@ -319,7 +296,7 @@ function CircleIcon (data: {
'sm:w-14 sm:h-14', 'sm:w-14 sm:h-14',
typeClasses[data.type ?? "none"], classNames( typeClasses[data.type ?? "none"], classNames(
{ {
"ring-7 ring-primary drop-shadow-2xl": focused, "focus ring-7 ring-primary drop-shadow-2xl animate-scale": focused,
"hover:ring-7 hover:ring-primary": true, "hover:ring-7 hover:ring-primary": true,
}) })
)} )}

View file

@ -0,0 +1,58 @@
import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
import { createFileRoute } from '@tanstack/react-router';
import { GameInstallProgress, RPC_URL } from '@/shared/constants';
import DotsLoading from '../components/backgrounds/dots';
import { useEventListener } from 'usehooks-ts';
import { Router } from '..';
import { useEffect, useState } from 'react';
import { rommApi } from '../scripts/clientApi';
import { useQuery } from '@tanstack/react-query';
export const Route = createFileRoute('/launcher/$source/$id')({
component: RouteComponent,
});
function RouteComponent ()
{
function HandleGoBack ()
{
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
}
const { source, id } = Route.useParams();
const { data } = useQuery({ queryKey: ['romm', 'game'], queryFn: () => rommApi.api.romm.game({ source })({ id }).get() });
useEventListener("cancel", (e) =>
{
e.stopPropagation();
HandleGoBack();
});
useEffect(() =>
{
const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${source}/${id}`);
es.onmessage = ({ data }) =>
{
const stats = JSON.parse(data) as GameInstallProgress;
if (stats.status !== 'playing')
{
HandleGoBack();
}
};
es.addEventListener('refresh', HandleGoBack);
es.onerror = HandleGoBack;
return () => es.close();
}, []);
return <AnimatedBackground backgroundKey='game-details'>
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>
<DotsLoading />
<h1 className='font-semibold'>Launching {data?.data?.name} ...</h1>
</div>
</AnimatedBackground>;
}

View file

@ -0,0 +1,54 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEventListener, useSessionStorage } from "usehooks-ts";
import { CollectionsDetail } from "../components/CollectionsDetail";
import { useSuspenseQuery } from "@tanstack/react-query";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { Suspense } from "react";
import { rommApi } from "../scripts/clientApi";
export const Route = createFileRoute("/platform/$source/$id")({
component: RouteComponent
});
function PlatformTitle ()
{
const { source, id } = Route.useParams();
const { data: platform } = useSuspenseQuery({
queryKey: ['platform', source, id], queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
if (error) throw error;
return data;
}, staleTime: DefaultRommStaleTime
});
return <div className="flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
<div className="divider mb-6 mt-0">
<img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
{platform.display_name}
</div>
</div>;
}
function RouteComponent ()
{
const { id } = Route.useParams();
const [, setBackground] = useSessionStorage<string | undefined>(
"home-background",
undefined,
);
const navigate = useNavigate();
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } }));
return (
<div className="w-full h-full">
<CollectionsDetail
title={<Suspense><PlatformTitle /></Suspense>}
setBackground={setBackground}
filters={{ platformId: Number(id) }}
/>
</div>
);
}

View file

@ -1,62 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import
{
getPlatformApiPlatformsIdGetOptions,
getRomsApiRomsGetOptions,
} from "../../../clients/romm/@tanstack/react-query.gen";
import { useEventListener, useSessionStorage } from "usehooks-ts";
import { CollectionsDetail } from "../../components/CollectionsDetail";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { DefaultRommStaleTime, RPC_PORT, RPC_URL } from "../../../shared/constants";
import { Suspense } from "react";
export const Route = createFileRoute("/platform/$id")({
component: RouteComponent
});
function PlatformSlug ()
{
const { id } = Route.useParams();
const { data: platform } = useSuspenseQuery({ ...getPlatformApiPlatformsIdGetOptions({ path: { id: Number(id) } }), staleTime: DefaultRommStaleTime });
return <div className="flex gap-2 pr-4 pl-2 text-2xl font-semibold text-base-content items-center justify-center drop-shadow drop-shadow-base-300/10 ">
<img className="size-10 rounded-full bg-base-100 p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
{platform.display_name}
</div>;
}
function PlatformTitle ()
{
const { id } = Route.useParams();
const { data: platform } = useSuspenseQuery({ ...getPlatformApiPlatformsIdGetOptions({ path: { id: Number(id) } }), staleTime: DefaultRommStaleTime });
return <div className="flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
<div className="divider mb-6 mt-0">
<img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
{platform.display_name}
</div>
</div>;
}
function RouteComponent ()
{
const { id } = Route.useParams();
const [, setBackground] = useSessionStorage<string | undefined>(
"home-background",
undefined,
);
const navigate = useNavigate();
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } }));
return (
<div className="w-full h-full">
<CollectionsDetail
title={<Suspense><PlatformTitle /></Suspense>}
setBackground={setBackground}
filters={{ platformIds: [Number(id)] }}
/>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { rommApi, systemApi } from '@/mainview/scripts/clientApi';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/settings/about')({
component: RouteComponent,
});
function RouteComponent ()
{
const { data: systemInfo } = useQuery({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() });
return <div className="overflow-x-auto">
<table className="table">
<tbody>
<tr>
<th>Agent</th>
<td>{navigator.userAgent}</td>
</tr>
{/* row 2 */}
<tr>
<th>Platform</th>
<td>{navigator.platform}</td>
</tr>
<tr>
<th>Resolution</th>
<td>{screen.width}x{screen.height}</td>
</tr>
<tr>
<th>Window</th>
<td>{window.innerWidth}x{window.innerHeight}</td>
</tr>
{/* row 3 */}
<tr>
<th>User</th>
<td>{systemInfo?.data?.user}</td>
</tr>
<tr>
<th>Architecture</th>
<td>{systemInfo?.data?.arch}</td>
</tr>
<tr>
<th>System</th>
<td>{systemInfo?.data?.platform}</td>
</tr>
<tr>
<th>Hostname</th>
<td>{systemInfo?.data?.hostname}</td>
</tr>
<tr>
<th>Machine</th>
<td>{systemInfo?.data?.machine}</td>
</tr>
<tr>
<th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
</tr>
</tbody>
</table>
</div>;
}

View file

@ -3,119 +3,31 @@ import
FocusContext, FocusContext,
useFocusable, useFocusable,
} from "@noriginmedia/norigin-spatial-navigation"; } from "@noriginmedia/norigin-spatial-navigation";
import { useIsMutating, useMutation, useQuery, UseQueryResult } from "@tanstack/react-query"; import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import classNames from "classnames"; import classNames from "classnames";
import { Cross, Delete, Key, Link, Lock, Save, Trash, User, X } from "lucide-react"; import { Key, Link, Lock, Save, Trash, User, X } from "lucide-react";
import import
{ {
HTMLInputTypeAttribute,
JSX,
useCallback,
useEffect, useEffect,
useState,
} from "react"; } from "react";
import { client } from "../.."; import { RPC_URL } from "../../../shared/constants";
import { RPC_URL, SettingsType } from "../../../shared/constants";
import import
{ {
getCurrentUserApiUsersMeGetOptions, getCurrentUserApiUsersMeGetOptions,
statsApiStatsGetOptions, statsApiStatsGetOptions,
} from "../../../clients/romm/@tanstack/react-query.gen"; } from "../../../clients/romm/@tanstack/react-query.gen";
import { UserSchema } from "../../../clients/romm";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import z from "zod"; import z from "zod";
import { OptionSpace } from "../../components/options/OptionSpace"; import { OptionSpace } from "../../components/options/OptionSpace";
import { OptionInput } from "../../components/options/OptionInput";
import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm"; import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm";
import { twMerge } from "tailwind-merge"; import { rommApi, settingsApi } from "../../scripts/clientApi";
import { Button } from "../../components/options/Button";
export const Route = createFileRoute("/settings/accounts")({ export const Route = createFileRoute("/settings/accounts")({
component: RouteComponent, component: RouteComponent,
}); });
type KeysWithValueAssignableTo<T, Value> = {
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
}[keyof T];
function Option (data: {
label: string;
id: KeysWithValueAssignableTo<SettingsType, string>;
type: HTMLInputTypeAttribute;
placeholder?: string;
icon?: JSX.Element;
})
{
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
useQuery({
enabled: !!data.id,
queryKey: ["setting", data.id],
queryFn: async () =>
{
const value = await client.api.settings({ id: data.id! }).get().then(d => d.data?.value);
if (!dirty)
{
setLocalValue(String(value));
}
return value;
},
});
const setSettingMultation = useMutation({
mutationKey: ["setting", data.id],
mutationFn: (value: any) =>
client.api.settings({ id: data.id! }).post({ value }).then(d => d.status)
});
const handleSave = useCallback(() =>
{
if (dirty)
{
setDirty(false);
setSettingMultation.mutate(localValue);
}
}, [dirty, setDirty, localValue]);
return (
<OptionSpace label={data.label}>
<OptionInput
icon={data.icon}
name={data.id ?? ""}
type={data.type}
placeholder={data.placeholder}
onBlur={handleSave}
onChange={(e) =>
{
setLocalValue(e.currentTarget.value);
setDirty(true);
}}
value={localValue}
/>
</OptionSpace>
);
}
function Button (data: { children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
{
const { ref, focused } = useFocusable({
focusKey: data.type,
onEnterPress: data.onAction,
onFocus: data.onFocus,
focusable: !data.disabled
});
return <button
ref={ref}
onClick={data.onAction}
disabled={data.disabled}
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
"btn-accent": focused
}, data.className))}
type={data.type}
>
{data.children}
</button>;
}
function LoginControls (data: { hasPassword: boolean; }) function LoginControls (data: { hasPassword: boolean; })
{ {
const user = useQuery({ const user = useQuery({
@ -128,7 +40,7 @@ function LoginControls (data: { hasPassword: boolean; })
context.state.canSubmit; context.state.canSubmit;
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0; const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationKey: ["romm", "auth", "logout"], mutationFn: () => client.api.romm.logout.post(), mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(),
onSuccess: async (d, v, r, c) => onSuccess: async (d, v, r, c) =>
{ {
c.client.invalidateQueries({ queryKey: ["romm", "auth"] }); c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
@ -167,10 +79,9 @@ function RouteComponent ()
preferredChildFocusKey: focus preferredChildFocusKey: focus
}); });
const { data: hasPassword } = useQuery({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => client.api.romm.login.get().then(d => d.data?.hasPassword as boolean) }); const { data: hasPassword } = useQuery({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) });
const { data: hostname } = useQuery({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => client.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }); const { data: hostname } = useQuery({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
const { data: username } = useQuery({ queryKey: ['romm', 'auth', 'username'], queryFn: () => client.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }); const { data: username } = useQuery({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
const loginForm = useSettingsForm({ const loginForm = useSettingsForm({
defaultValues: { defaultValues: {
@ -210,7 +121,7 @@ function RouteComponent ()
mutationKey: ["romm", "login"], mutationKey: ["romm", "login"],
mutationFn: (data: z.infer<typeof dataSchema>) => mutationFn: (data: z.infer<typeof dataSchema>) =>
{ {
return client.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname }); return rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
}, },
onSuccess: (d, v, r, c) => onSuccess: (d, v, r, c) =>
{ {

View file

@ -0,0 +1,242 @@
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { createFileRoute } from '@tanstack/react-router';
import { SettingsOption } from '../../components/options/SettingsOption';
import { OptionSpace } from '../../components/options/OptionSpace';
import { OptionInput } from '../../components/options/OptionInput';
import { useMutation, useQuery } from '@tanstack/react-query';
import { settingsApi } from '../../scripts/clientApi';
import { useCallback, useState } from 'react';
import { Button } from '../../components/options/Button';
import { Check, ChevronDown, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
import classNames from 'classnames';
import { twMerge } from 'tailwind-merge';
import { RPC_URL } from '../../../shared/constants';
import emulators from '@emulators';
export const Route = createFileRoute('/settings/directories')({
component: RouteComponent,
pendingComponent: EmulatorsPending,
});
function EmulatorsPending ()
{
return <div className="flex flex-col p-2 px-3 w-full h-full">
<div className="flex flex-col justify-center items-center grow">
<span className="loading loading-dots loading-xl"></span>
</div>
</div>;
}
function EmulatorListCat (data: { selected: string, set: (c: string) => void; })
{
const { ref, focusKey } = useFocusable({ focusKey: 'categories' });
return <ul className='flex gap-1' ref={ref}>
<FocusContext value={focusKey}>
{[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c =>
<OptionElement key={c} className={classNames('p-2 justify-center size-8 text-base-content bg-base-300 text-lg', { "ring-4 ring-primary": data.selected === c })} onFocus={() => data.set(c)} content={c} id={c} action={(ctx) => ctx.focus()} type="primary" />
)}
</FocusContext>
</ul>;
}
function EmulatorListType (data: { category: string, action: (e: string) => void, })
{
const { ref, focusKey } = useFocusable({ focusKey: 'list-section' });
return <div ref={ref} className='grow'>
<FocusContext value={focusKey}>
<ContextList className='h-[60vh]' options={Object.keys(emulators).filter(e => e.startsWith(data.category)).map(e => ({
id: e,
action: (ctx) =>
{
data.action(e);
ctx.close();
},
type: 'primary',
content: e
} satisfies DialogEntry))} />
</FocusContext>
</div>;
}
function NewEmulatorPath (data: {})
{
const [newEmulatorTypeOpen, setNewEmulatorTypeOpen] = useState(false);
const [newEmulatorContextCat, setNewEmulatorContextCat] = useState('A');
const handleCloseContext = () =>
{
setNewEmulatorTypeOpen(false);
setFocus('emulator');
};
const addOverrideMutation = useMutation({
mutationKey: ['emulator', 'custom', 'add'],
mutationFn: async (id: string) =>
{
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
if (error) throw error;
return data;
},
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
});
return <OptionSpace label={"Custom Emulator Path"}>
<Button disabled={addOverrideMutation.isPending} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
Emulator
<ChevronDown />
</Button>
<ContextDialog open={newEmulatorTypeOpen} id='new-emulator-type-context' close={handleCloseContext}>
<div className='flex flex-col'>
<EmulatorListCat selected={newEmulatorContextCat} set={setNewEmulatorContextCat} />
<div className="divider mb-1 mt-2"></div>
<EmulatorListType category={newEmulatorContextCat} action={e =>
{
addOverrideMutation.mutate(e);
}} />
</div>
</ContextDialog>
</OptionSpace>;
}
function EmulatorPath (data: { id: string; })
{
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
const { data: remoteValue } = useQuery({
enabled: !!data.id,
queryKey: ["emulator", data.id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).get();
if (error) throw error;
return value;
},
});
const setSettingMutation = useMutation({
mutationKey: ["emulator", data.id, 'set'],
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: data.id }).put({ value }),
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
}
});
const deleteMutation = useMutation({
mutationKey: ["emulator", data.id, 'delete'],
mutationFn: async () =>
{
const { error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).delete();
if (error) throw error;
},
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
}
});
const handleSave = useCallback(() =>
{
if (dirty)
{
setDirty(false);
setSettingMutation.mutate(localValue ?? '');
}
}, [dirty, setDirty, localValue]);
return (
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}>
<div className='flex gap-2'>
<OptionInput
name={data.id ?? ""}
type="text"
onBlur={handleSave}
autocomplete="off"
defaultValue={remoteValue}
onChange={(e) =>
{
setLocalValue(e.currentTarget.value);
setDirty(true);
}}
value={localValue}
/>
<Button id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
<Trash />
</Button>
</div>
</OptionSpace>
);
}
function EmulatorBadge (data: { path?: string, exists: boolean, emulator: string; pathCover?: string; })
{
const { ref, focused } = useFocusable({
focusKey: `badge-${data.emulator}`, onFocus: () =>
{
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
<div ref={ref} className={
twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden',
classNames({
"bg-base-200/50": !data.path,
"border-dashed border-base-content/40 border-2": focused
}))
}>
<p className='flex gap-2 font-semibold'>
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
{data.emulator}
</p>
{data.path ? <small className={classNames('opacity-60 max-w-full overflow-clip text-nowrap text-ellipsis', { 'text-error': !data.exists })}>{data.path}</small> : ""}
</div>
</div>;
}
function EmulatorBadges (data: { path?: string; })
{
const { data: autoEmulators } = useQuery({ queryKey: ['auto-emulators'], queryFn: async () => settingsApi.api.settings.emulators.automatic.get() });
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators?.data && autoEmulators.data.length > 0 });
return <div ref={ref} className='flex flex-wrap gap-2 justify-center-safe'>
<FocusContext value={focusKey}>
{autoEmulators?.data?.map(e => <EmulatorBadge pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
</FocusContext>
</div>;
}
function RouteComponent ()
{
const { focus } = Route.useSearch();
const { ref, focusKey, focusSelf } = useFocusable({
preferredChildFocusKey: focus
});
const { data: customEmulators } = useQuery({
queryKey: ['custom-emulators'], queryFn: async () =>
{
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
if (error) throw error;
return data;
}
});
return <FocusContext value={focusKey}>
<ul ref={ref} className="list rounded-box gap-2">
<div className="divider text-2xl mt-0 md:mt-4">
<div className="flex flex-col">
<h3>Romm</h3>
</div>
</div>
<SettingsOption label="Download Path" id="downloadPath" type="text" />
<div className="divider text-2xl mt-0 md:mt-4">
<div className="flex flex-col">
<h3>Emulatos</h3>
</div>
</div>
<EmulatorBadges />
<div className="divider text-base-content/40">Overrides</div>
<NewEmulatorPath />
{!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)}
</ul>
</FocusContext>;
}

View file

@ -17,16 +17,18 @@ import
{ {
ArrowBigLeft, ArrowBigLeft,
FingerprintPattern, FingerprintPattern,
HardDrive,
Info, Info,
MonitorCog, MonitorCog,
} from "lucide-react"; } from "lucide-react";
import { JSX, useEffect } from "react"; import { JSX, useEffect, useRef } from "react";
import { useEventListener } from "usehooks-ts"; import { useEventListener } from "usehooks-ts";
import ShortcutPrompt from "../../components/ShortcutPrompt"; import ShortcutPrompt from "../../components/ShortcutPrompt";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import z from "zod"; import z from "zod";
import { SettingsSchema } from "../../../shared/constants"; import { SettingsSchema } from "../../../shared/constants";
import { PopSource } from "../../scripts/spatialNavigation"; import { PopSource } from "../../scripts/spatialNavigation";
import { Router } from "../..";
export const Route = createFileRoute("/settings")({ export const Route = createFileRoute("/settings")({
component: SettingsUI, component: SettingsUI,
@ -78,8 +80,9 @@ function MenuItem (data: {
className={twMerge( className={twMerge(
"group rounded-full p-3 pl-5 text-base-content/80", "group rounded-full p-3 pl-5 text-base-content/80",
classNames({ classNames({
"bg-primary/40 text-primary-content": !focused && acitve, "bg-primary text-primary-content": acitve,
"bg-primary text-primary-content font-semibold": focused, "font-semibold ring-7 ring-primary-content": focused,
"bg-secondary text-secondary-content ring-primary": data.return && focused,
}), }),
data.linkClassName, data.linkClassName,
)} )}
@ -100,7 +103,7 @@ function SettingsMenu (data: {})
const { ref, focusKey } = useFocusable({ const { ref, focusKey } = useFocusable({
focusable: true, focusable: true,
focusKey: 'settings-menu', focusKey: 'settings-menu',
preferredChildFocusKey: "/settings/accounts" preferredChildFocusKey: location.hash.replace("#", '')
}); });
return <ul return <ul
@ -120,6 +123,12 @@ function SettingsMenu (data: {})
label="Visual" label="Visual"
icon={<MonitorCog />} icon={<MonitorCog />}
/> />
<MenuItem
focusSelect
route="/settings/directories"
label="Directories"
icon={<HardDrive />}
/>
<MenuItem <MenuItem
focusSelect focusSelect
route="/settings/about" route="/settings/about"
@ -138,15 +147,32 @@ function SettingsMenu (data: {})
</ul>; </ul>;
} }
function HandleGoBack ()
{
if (document.activeElement && document.activeElement !== document.body && document.activeElement instanceof HTMLElement)
{
document.activeElement.blur();
} else
{
const source = PopSource('settings');
if (source)
{
console.log("Found source ", source, " to go back to");
}
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
}
}
export function SettingsUI () export function SettingsUI ()
{ {
const navigate = useNavigate();
const { ref, focusKey, focusSelf } = useFocusable({ const { ref, focusKey, focusSelf } = useFocusable({
focusKey: "settings-page-layout", focusKey: "settings-page-layout",
preferredChildFocusKey: 'settings-menu' preferredChildFocusKey: 'settings-menu'
}); });
useEventListener("cancel", () => navigate({ to: PopSource('settings') ?? "/", viewTransition: { types: ['zoom-out'] } })); useEventListener("cancel", HandleGoBack, ref);
useEffect(() => useEffect(() =>
{ {
focusSelf(); focusSelf();
@ -166,7 +192,7 @@ export function SettingsUI ()
</div> </div>
<div className="divider divider-end"> <div className="divider divider-end">
<ShortcutPrompt <ShortcutPrompt
onClick={() => navigate({ to: "/" })} onClick={HandleGoBack}
icon="steamdeck_button_b" icon="steamdeck_button_b"
label="Back" label="Back"
/> />

View file

@ -0,0 +1,22 @@
import { treaty } from "@elysiajs/eden";
import { RommAPIType, SettingsAPIType, SystemAPIType } from "../../bun/api/rpc";
import { RPC_URL } from "../../shared/constants";
export const rommApi = treaty<RommAPIType>(RPC_URL(__HOST__), {
keepDomain: true,
fetch: {
credentials: 'include',
}
});
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), {
keepDomain: true,
fetch: {
credentials: 'include',
}
});
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), {
keepDomain: true,
fetch: {
credentials: 'include',
}
});

View file

@ -1,15 +1,19 @@
import { navigateByDirection } from "@noriginmedia/norigin-spatial-navigation"; import { getCurrentFocusKey, navigateByDirection, SpatialNavigation } from "@noriginmedia/norigin-spatial-navigation";
import { dispatchFocusedEvent, GetFocusedElement } from "./spatialNavigation";
let loopStarted = false; let loopStarted = false;
window.addEventListener("gamepadconnected", (evt) => { window.addEventListener("gamepadconnected", (evt) =>
if (!loopStarted) { {
requestAnimationFrame(updateStatus); if (!loopStarted)
loopStarted = true; {
} requestAnimationFrame(updateStatus);
loopStarted = true;
}
}); });
window.addEventListener("gamepaddisconnected", (evt) => { window.addEventListener("gamepaddisconnected", (evt) =>
{
}); });
const throttleMap = new Map<string, number>(); const throttleMap = new Map<string, number>();
@ -21,10 +25,10 @@ function throttleNav (key: string, dir: string, event: Event)
const currentDate = new Date(); const currentDate = new Date();
const lastTime = throttleMap.get(key); const lastTime = throttleMap.get(key);
const acceleration = throttleAcceleration.get(key) ?? 0; const acceleration = throttleAcceleration.get(key) ?? 0;
const speed = Math.max(maxSpeed - (maxSpeed - minSpeed) * (acceleration / 6),minSpeed); const speed = Math.max(maxSpeed - (maxSpeed - minSpeed) * (acceleration / 6), minSpeed);
if ((currentDate.getTime() - (lastTime ?? 0) > speed)) if ((currentDate.getTime() - (lastTime ?? 0) > speed))
{ {
navigateByDirection(dir, { event }) navigateByDirection(dir, { event });
throttleMap.set(key, currentDate.getTime()); throttleMap.set(key, currentDate.getTime());
throttleAcceleration.set(key, acceleration + 1); throttleAcceleration.set(key, acceleration + 1);
} }
@ -34,11 +38,17 @@ window.addEventListener('keydown', e =>
{ {
if (e.key === 'Escape') if (e.key === 'Escape')
{ {
window.dispatchEvent(new Event('cancel')); const focusedElement = GetFocusedElement(getCurrentFocusKey());
const finalTarget = focusedElement ?? window;
const evn = new Event('cancel', { bubbles: true, cancelable: true });
finalTarget.dispatchEvent(evn);
} }
}) });
function updateStatus () {
function updateStatus ()
{
for (const gamepad of navigator.getGamepads().filter(g => !!g)) for (const gamepad of navigator.getGamepads().filter(g => !!g))
{ {
const gamepadEvent = new GamepadEvent('gamepad-navigation', { gamepad, }); const gamepadEvent = new GamepadEvent('gamepad-navigation', { gamepad, });
@ -47,14 +57,14 @@ function updateStatus () {
{ {
if (!throttleMap.has('enter')) if (!throttleMap.has('enter'))
{ {
window.dispatchEvent(new KeyboardEvent('keydown',{key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true})); dispatchFocusedEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }), window);
throttleMap.set('enter', 0); throttleMap.set('enter', 0);
} }
} else } else
{ {
if (throttleMap.delete('enter')) if (throttleMap.delete('enter'))
{ {
window.dispatchEvent(new KeyboardEvent('keyup', {key: 'Enter'})); dispatchFocusedEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
} }
} }
@ -62,7 +72,8 @@ function updateStatus () {
{ {
if (!throttleMap.has('cancel')) if (!throttleMap.has('cancel'))
{ {
window.dispatchEvent(new Event('cancel')); const evn = new Event('cancel', { bubbles: true, cancelable: true });
dispatchFocusedEvent(evn);
throttleMap.set('cancel', 0); throttleMap.set('cancel', 0);
} }
} else } else
@ -70,79 +81,87 @@ function updateStatus () {
throttleMap.delete('cancel'); throttleMap.delete('cancel');
} }
if (gamepad.buttons[12].pressed) const activeFocus = GetFocusedElement(getCurrentFocusKey());
if (activeFocus instanceof HTMLInputElement)
{ {
throttleNav('gp-up', "up", gamepadEvent);
} else
{
throttleAcceleration.delete('gp-up');
throttleMap.delete('gp-up');
}
if (gamepad.buttons[13].pressed)
{
throttleNav('gp-down', "down", gamepadEvent);
} else
{
throttleAcceleration.delete('gp-down');
throttleMap.delete('gp-down');
}
if (gamepad.buttons[14].pressed)
{
throttleNav('gp-left', "left", gamepadEvent);
} else
{
throttleAcceleration.delete('gp-left');
throttleMap.delete('gp-left');
}
if (gamepad.buttons[15].pressed)
{
throttleNav('gp-right', "right", gamepadEvent);
} else
{
throttleAcceleration.delete('gp-right');
throttleMap.delete('gp-right');
}
const deadzone = 0.5; } else
const cancelDeadzone = 0.3;
function AxisControls ()
{ {
if (gamepad.axes[0] > deadzone) if (gamepad.buttons[12].pressed)
{ {
throttleNav('gpa-right', "right", gamepadEvent); throttleNav('gp-up', "up", gamepadEvent);
return;
}
else if (gamepad.axes[0] < -deadzone)
{
throttleNav('gpa-left', "left", gamepadEvent);
return;
}
else if ((throttleMap.has('gpa-left') || throttleMap.has('gpa-left')) && gamepad.axes[0] < cancelDeadzone && gamepad.axes[0] > -cancelDeadzone)
{
throttleAcceleration.delete('gpa-right');
throttleAcceleration.delete('gpa-left');
throttleMap.delete('gpa-left');
throttleMap.delete('gpa-left');
}
if (gamepad.axes[1] > deadzone)
{
throttleNav('gpa-down', "down", gamepadEvent);
}
else if (gamepad.axes[1] < -deadzone)
{
throttleNav('gpa-up', "up", gamepadEvent);
} else } else
{ {
throttleAcceleration.delete('gpa-up'); throttleAcceleration.delete('gp-up');
throttleAcceleration.delete('gpa-down'); throttleMap.delete('gp-up');
throttleMap.delete('gpa-up');
throttleMap.delete('gpa-down');
} }
if (gamepad.buttons[13].pressed)
{
throttleNav('gp-down', "down", gamepadEvent);
} else
{
throttleAcceleration.delete('gp-down');
throttleMap.delete('gp-down');
}
if (gamepad.buttons[14].pressed)
{
throttleNav('gp-left', "left", gamepadEvent);
} else
{
throttleAcceleration.delete('gp-left');
throttleMap.delete('gp-left');
}
if (gamepad.buttons[15].pressed)
{
throttleNav('gp-right', "right", gamepadEvent);
} else
{
throttleAcceleration.delete('gp-right');
throttleMap.delete('gp-right');
}
const deadzone = 0.5;
const cancelDeadzone = 0.3;
function AxisControls ()
{
if (gamepad.axes[0] > deadzone)
{
throttleNav('gpa-right', "right", gamepadEvent);
return;
}
else if (gamepad.axes[0] < -deadzone)
{
throttleNav('gpa-left', "left", gamepadEvent);
return;
}
else if ((throttleMap.has('gpa-left') || throttleMap.has('gpa-left')) && gamepad.axes[0] < cancelDeadzone && gamepad.axes[0] > -cancelDeadzone)
{
throttleAcceleration.delete('gpa-right');
throttleAcceleration.delete('gpa-left');
throttleMap.delete('gpa-left');
throttleMap.delete('gpa-left');
}
if (gamepad.axes[1] > deadzone)
{
throttleNav('gpa-down', "down", gamepadEvent);
}
else if (gamepad.axes[1] < -deadzone)
{
throttleNav('gpa-up', "up", gamepadEvent);
} else
{
throttleAcceleration.delete('gpa-up');
throttleAcceleration.delete('gpa-down');
throttleMap.delete('gpa-up');
throttleMap.delete('gpa-down');
}
}
AxisControls();
} }
AxisControls();
} }
requestAnimationFrame(updateStatus); requestAnimationFrame(updateStatus);

View file

@ -1,25 +1,28 @@
import import
{ {
getCurrentFocusKey,
init, init,
SpatialNavigation, SpatialNavigation,
} from "@noriginmedia/norigin-spatial-navigation"; } from "@noriginmedia/norigin-spatial-navigation";
import { RefObject, useEffect } from "react";
init({ init({
shouldFocusDOMNode: false, shouldFocusDOMNode: false,
throttle: 200, throttle: 200
}); });
let addFocusable = SpatialNavigation.addFocusable.bind(SpatialNavigation); let addFocusable = SpatialNavigation.addFocusable.bind(SpatialNavigation);
let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation); let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation);
let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNavigation);
type SaveFocusType = "session" | "local"; type SaveFocusType = "session" | "local";
type HistorySourceType = "settings" | 'details'; type HistorySourceType = "settings" | 'details' | 'launch';
const historySourceMap = new Map<string, string>(); const historySourceMap = new Map<string, string>();
export function SaveSource (id: HistorySourceType, url: string) export function SaveSource (id: HistorySourceType, url?: string)
{ {
historySourceMap.set(id, url); historySourceMap.set(id, url ?? location.hash.replace("#", ''));
} }
export function HasSource (id: HistorySourceType) export function HasSource (id: HistorySourceType)
@ -29,11 +32,49 @@ export function HasSource (id: HistorySourceType)
export function PopSource (id: HistorySourceType) export function PopSource (id: HistorySourceType)
{ {
if (!historySourceMap.has(id))
{
return undefined;
}
const source = historySourceMap.get(id); const source = historySourceMap.get(id);
historySourceMap.delete(id); historySourceMap.delete(id);
return source; return source;
} }
export function GetFocusedElement (focusKey: string)
{
return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement;
}
export function dispatchFocusedEvent (event: Event, override?: Element | Window)
{
const focusedElement = GetFocusedElement(getCurrentFocusKey());
const finalTarget = override ?? focusedElement ?? window;
return finalTarget.dispatchEvent(event);
}
export interface FocusEventMap
{
'focuschanged': Event;
}
export function useFocusEventListener<K extends keyof FocusEventMap, O extends HTMLElement> (eventName: K, handler: (event: FocusEventMap[K]) => void, element?: RefObject<O | null | undefined>): void
{
useEffect(() =>
{
const finalElement = element ? element.current : window;
finalElement?.addEventListener(eventName, handler);
return () => finalElement?.removeEventListener(eventName, handler);
}, [eventName, handler, element?.current]);
}
SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) =>
{
setCurrentFocusedKey(newFocusKey, focusDetails);
dispatchFocusedEvent(new Event('focuschanged', { bubbles: true }));
};
SpatialNavigation.addFocusable = (toAdd) => SpatialNavigation.addFocusable = (toAdd) =>
{ {
addFocusable(toAdd); addFocusable(toAdd);

View file

@ -56,3 +56,8 @@ export function useScrollSave (data: ScrollSaveParams)
return { ref: data.ref }; return { ref: data.ref };
} }
export function serverOp ()
{
}

View file

@ -1,8 +1,8 @@
import { client } from "../index"; import { settingsApi } from "./clientApi";
window.addEventListener("resize", () => window.addEventListener("resize", () =>
{ {
client.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } }); settingsApi.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } });
}); });
let lastWindowPosX: number = window.screenX; let lastWindowPosX: number = window.screenX;
@ -11,7 +11,7 @@ var screenPositionInternal: NodeJS.Timeout = setInterval(() =>
{ {
if (lastWindowPosX != window.screenX || lastWindowPosY != window.screenY) if (lastWindowPosX != window.screenX || lastWindowPosY != window.screenY)
{ {
client.api.settings({ id: 'windowPosition' }).post({ value: { x: window.screenX, y: window.screenY } }); settingsApi.api.settings({ id: 'windowPosition' }).post({ value: { x: window.screenX, y: window.screenY } });
} }
lastWindowPosX = window.screenX; lastWindowPosX = window.screenX;

View file

@ -1,4 +1,9 @@
declare const __HOST__: string; declare const __HOST__: string;
declare const __EMULATORS__: Record<string, string>;
declare module "@emulators" {
const data: Record<string, string>;
export default data;
}
global global
{ {

View file

@ -1,4 +1,5 @@
import { JSX } from 'react';
import * as z from 'zod'; import * as z from 'zod';
export const SERVER_PORT = 5173; export const SERVER_PORT = 5173;
@ -9,14 +10,15 @@ export const RPC_URL = (host: string) => `http://${host}:${RPC_PORT}`;
export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`; export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`;
export const DefaultRommStaleTime = 60 * 1000; // A minute export const DefaultRommStaleTime = 60 * 1000; // A minute
export const GameMetaSchema = z.object({ export interface GameMeta
id: z.number(), {
title: z.string(), id: string,
subtitle: z.string(), onSelect?: () => void,
previewUrl: z.url().optional() onFocus?: () => void,
}); title: string,
subtitle: string | JSX.Element,
export type GameMeta = z.infer<typeof GameMetaSchema>; previewUrl?: string;
};
export const SettingsSchema = z.object({ export const SettingsSchema = z.object({
rommAddress: z.url().optional(), rommAddress: z.url().optional(),
@ -24,6 +26,78 @@ export const SettingsSchema = z.object({
disableBlur: z.boolean().default(false), disableBlur: z.boolean().default(false),
windowSize: z.object({ width: z.number(), height: z.number() }).default({ width: 1280, height: 800 }), windowSize: z.object({ width: z.number(), height: z.number() }).default({ width: 1280, height: 800 }),
windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), windowPosition: z.object({ x: z.number(), y: z.number() }).optional(),
downloadPath: z.string().default('./downloads')
}); });
export type SettingsType = z.infer<typeof SettingsSchema>; export const CustomEmulatorSchema = z.record(z.string(), z.string());
export interface FrontEndId
{
id: number;
source: string;
}
export interface FrontEndPlatformType
{
id: FrontEndId;
source: string | null;
source_id: number | null;
slug: string;
name: string;
family_name?: string | null;
path_cover: string | null;
game_count: number;
updated_at: Date;
}
export interface FrontEndGameType
{
platform_display_name: string | null,
path_platform_cover: string | null;
id: FrontEndId,
source: string | null,
source_id: number | null,
path_fs: string | null,
path_cover: string | null,
last_played: Date | null,
updated_at: Date,
slug: string | null,
name: string | null,
platform_id: number | null,
};
export interface FrontEndGameTypeDetailed extends FrontEndGameType
{
summary: string | null;
fs_size_bytes: number | null;
missing: boolean;
local: boolean;
paths_screenshots: string[];
achievements?: {
unlocked: number;
total: number;
};
};
export type SettingsType = z.infer<typeof SettingsSchema>;
export interface GameInstallProgress
{
progress?: number;
status?: GameStatusType;
details?: string;
error?: any;
}
export type GameInstallProgressEvent = 'refresh';
export const PlatformSchema = z.object({ slug: z.string() });
export const GameLaunchSchema = z.object({ platform: PlatformSchema, id: z.number(), slug: z.string(), directory: z.string() });
export const GameflowPluginSchema = z.object({
id: z.string(),
name: z.string(),
getSupportedPlatform: z.function({ output: z.array(PlatformSchema) }),
launchGame: z.function({ input: [GameLaunchSchema] })
});
export interface GameflowPlugin extends z.infer<typeof GameflowPluginSchema> { }
export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing';

View file

@ -0,0 +1,3 @@
import { GameflowPlugin } from "./constants";
export type GameflowPluginType = GameflowPlugin;

View file

@ -0,0 +1,19 @@
import { expect, test, mock } from 'bun:test';
test("uses custom emulator", async () =>
{
const { getValidLaunchCommands: getLaunchCommands } = await import('../bun/api/games/services/launchGameService');
const commands = await getLaunchCommands({
systemSlug: 'ps2',
gamePath: './src/tests/mock-roms/mock-rom.iso',
customEmulatorConfig: new Map([['PCSX2', "./src/tests/mock-roms/pcsx2.exe"]])
});
expect(commands)
.toSatisfy((d) =>
!!d?.find(c =>
c?.command.includes("./src/tests/mock-roms/mock-rom.iso") &&
c.command.includes("./src/tests/mock-roms/pcsx2.exe")
)
);
});

View file

2
src/tests/preload.ts Normal file
View file

@ -0,0 +1,2 @@
import { mock } from 'bun:test';

View file

@ -2,7 +2,11 @@
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
@ -14,7 +18,25 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
"paths": {
"@/*": [
"./src/*"
],
"~/*": [
"./*"
],
"@shared/*": [
"./src/shared/*"
],
"@clients/*": [
"./src/clients/*"
],
}
}, },
"include": ["src", "vite.config.ts", "vite-env-override.d.ts"] "include": [
"src",
"vite.config.ts",
"vite-env-override.d.ts"
]
} }

4
vendors/es-de/README.md vendored Normal file
View file

@ -0,0 +1,4 @@
# ES-DE
Resources used from https://gitlab.com/es-de/emulationstation-de
Configuration is compiled into a sqlite tables for backend use along with json emulator lists for the frontend

76
vendors/es-de/emulators.darwin.x64.json vendored Normal file
View file

@ -0,0 +1,76 @@
{
"OS-SHELL": "Operating system shell",
"RETROARCH": "RETROARCH",
"3DSEN": "Nintendo NES and Famicom emulator 3dSen",
"ACE-DL": "Amstrad CPC emulator ACE-DL",
"AETHERSX2": "Sony PlayStation 2 emulator AetherSX2",
"AMIBERRY": "Commodore Amiga emulator Amiberry",
"ARES": "Multi-system emulator ares",
"ATARI800": "Atari 8-bit computer emulator Atari800",
"AZAHAR": "Nintendo 3DS emulator Azahar",
"BASILISKII": "Macintosh 68k emulator Basilisk II",
"CEMU": "Nintendo Wii U emulator Cemu",
"CITRA": "Nintendo 3DS emulator Citra",
"CPCEMU": "Amstrad CPC emulator CPCemu",
"DOLPHIN": "Nintendo GameCube and Wii emulator Dolphin",
"DOSBOX-PURE-UNLEASHED": "DOS emulator DOSBox Pure Unleashed",
"DOSBOX-STAGING": "DOS emulator DOSBox Staging",
"DOSBOX-X": "DOS emulator DOSBox-X",
"DREAMM": "LucasArts game engine DREAMM",
"DUCKSTATION": "Sony PlayStation 1 emulator DuckStation",
"EASYRPG": "EasyRPG game engine",
"EKA2L1": "Symbian and Nokia N-Gage emulator EKA2L1",
"FLYCAST": "Sega Dreamcast emulator Flycast",
"FLYCAST-DOJO": "Sega Dreamcast emulator Flycast Dojo",
"FS-UAE": "Commodore Amiga emulator FS-UAE",
"FUSE": "Sinclair ZX Spectrum emulator Fuse",
"GARGOYLE": "Interactive fiction player Gargoyle",
"HATARI": "Atari ST/STE/TT/Falcon emulator Hatari",
"HYPSEUS-SINGE": "Hypseus Singe, fork of arcade LaserDisc emulator Daphne",
"KEGS": "Apple IIGS emulator KEGS",
"LIME3DS": "Nintendo 3DS emulator Lime3DS",
"MAME": "Arcade emulator MAME",
"MANDARINE": "Nintendo 3DS emulator Mandarine",
"MEDNAFEN": "Multi-system emulator Mednafen",
"MELONDS": "Nintendo DS and DSi emulator melonDS",
"MGBA": "Nintendo Game Boy Advance emulator mGBA",
"MUPEN64PLUS": "Nintendo 64 emulator Mupen64Plus",
"OPENMSX": "MSX home computer emulator openMSX",
"ORICUTRON": "Tangerine Computer Systems emulator Oricutron",
"PANDA3DS": "Nintendo 3DS emulator Panda3DS",
"PARALLEL-LAUNCHER": "Nintendo 64 emulator Parallel Launcher",
"PCSX2": "Sony PlayStation 2 emulator PCSX2",
"PICO-8": "PICO-8 Fantasy Console (game engine)",
"PLAY!": "Sony PlayStation 2 emulator Play!",
"PPSSPP": "Sony PlayStation Portable emulator PPSSPP",
"PRBOOM-PLUS": "Doom engine source port PrBoom+",
"QUASI88": "NEC PC-8800 series emulator QUASI88",
"REDREAM": "Sega Dreamcast emulator Redream",
"RPCS3": "Sony PlayStation 3 emulator RPCS3",
"RUFFLE": "Adobe Flash player Ruffle",
"RYUJINX": "Nintendo Switch emulator Ryujinx",
"SAMEBOY": "Nintendo Game Boy and Game Boy Color emulator SameBoy",
"SIMCOUPE": "SAM Coupé emulator SimCoupé",
"SCUMMVM": "ScummVM game engine",
"SHADPS4": "Sony PlayStation 4 emulator shadPS4",
"SHADPS4-GUI": "Sony PlayStation 4 emulator shadPS4 (GUI)",
"SHEEPSHAVER": "Apple Macintosh PowerPC emulator SheepShaver",
"SIXTYFORCE": "Nintendo 64 emulator sixtyforce",
"SKYEMU": "Nintendo Game Boy, Game Boy Color, Game Boy Advance and DS emulator SkyEmu",
"SNES9X": "Super Nintendo emulator Snes9x",
"SOLARUS": "Solarus game engine",
"STEAM": "Valve Steam",
"STELLA": "Atari 2600 emulator Stella",
"SUPERMODEL": "Sega Model 3 emulator Supermodel",
"TIC-80": "TIC-80 Fantasy Computer (game engine)",
"VBA-M": "Nintendo Game Boy Advance emulator VBA-M",
"VICE-X64SC": "Commodore 8-bit computer emulator VICE (x64sc accurate)",
"VICE-XPLUS4": "Commodore 8-bit computer emulator VICE (xplus4)",
"VICE-XVIC": "Commodore 8-bit computer emulator VICE (xvic)",
"VISUAL-PINBALL": "Pinball simulator Visual Pinball",
"VITA3K": "Sony PlayStation Vita emulator Vita3K",
"XEMU": "Microsoft Xbox emulator xemu",
"XROAR": "Dragon and Tandy 8-bit computer emulator XRoar",
"YMIR": "Sega Saturn emulator Ymir",
"ZESARUX": "Sinclair ZX Spectrum emulator ZEsarUX"
}

Binary file not shown.

18
vendors/es-de/emulators.haiku.x64.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
"OS-SHELL": "Operating system shell",
"RETROARCH": "RETROARCH",
"ADVANCEMAME": "Arcade emulator AdvanceMAME",
"DOSBOX-X": "DOS emulator DOSBox-X",
"FINALBURN-NEO": "Arcade emulator FinalBurn Neo",
"FS-UAE": "Commodore Amiga emulator FS-UAE",
"FUSE": "Sinclair ZX Spectrum emulator Fuse",
"MAME": "Arcade emulator MAME",
"MEDNAFEN": "Multi-system emulator Mednafen",
"MELONDS": "Nintendo DS and DSi emulator melonDS",
"MGBA": "Nintendo Game Boy Advance emulator mGBA",
"MUPEN64PLUS": "Nintendo 64 emulator Mupen64Plus",
"PPSSPP": "Sony PlayStation Portable emulator PPSSPP",
"SCUMMVM": "ScummVM game engine",
"SOLARUS": "Solarus game engine",
"ZESARUX": "Sinclair ZX Spectrum emulator ZEsarUX"
}

BIN
vendors/es-de/emulators.haiku.x64.sqlite vendored Normal file

Binary file not shown.

55
vendors/es-de/emulators.linux.arm.json vendored Normal file
View file

@ -0,0 +1,55 @@
{
"OS-SHELL": "Operating system shell",
"RETROARCH": "RETROARCH",
"ACE-DL": "Amstrad CPC emulator ACE-DL",
"AMIBERRY": "Commodore Amiga emulator Amiberry",
"ARES": "Multi-system emulator ares",
"ATARI800": "Atari 8-bit computer emulator Atari800",
"AZAHAR": "Nintendo 3DS emulator Azahar",
"BIGPEMU": "Atari Jaguar emulator BigPEmu",
"BSNES": "Super Nintendo/Super Famicom emulator bsnes",
"CPCEMU": "Amstrad CPC emulator CPCemu",
"DESMUME": "Nintendo DS emulator DeSmuME",
"DOLPHIN": "Nintendo GameCube and Wii emulator Dolphin",
"DOSBOX-PURE-UNLEASHED": "DOS emulator DOSBox Pure Unleashed",
"DOSBOX-STAGING": "DOS emulator DOSBox Staging",
"DOSBOX-X": "DOS emulator DOSBox-X",
"DREAMM": "LucasArts game engine DREAMM",
"DUCKSTATION": "Sony PlayStation 1 emulator DuckStation",
"EASYRPG": "EasyRPG game engine",
"FLYCAST": "Sega Dreamcast emulator Flycast",
"FS-UAE": "Commodore Amiga emulator FS-UAE",
"FUSE": "Sinclair ZX Spectrum emulator Fuse",
"GARGOYLE": "Interactive fiction player Gargoyle",
"GOPHER64": "Nintendo 64 emulator Gopher64",
"HATARI": "Atari ST/STE/TT/Falcon emulator Hatari",
"MAME": "Arcade emulator MAME",
"MEDNAFEN": "Multi-system emulator Mednafen",
"MELONDS": "Nintendo DS and DSi emulator melonDS",
"MESEN": "Multi-system emulator Mesen",
"MGBA": "Nintendo Game Boy Advance emulator mGBA",
"MUPEN64PLUS": "Nintendo 64 emulator Mupen64Plus",
"NESTOPIA-UE": "Nintendo NES and Famicom emulator Nestopia UE",
"OPENMSX": "MSX home computer emulator openMSX",
"PICO-8": "PICO-8 Fantasy Console (game engine)",
"PPSSPP": "Sony PlayStation Portable emulator PPSSPP",
"PRBOOM-PLUS": "Doom engine source port PrBoom+",
"PRIMEHACK": "PrimeHack, fork of Nintendo GameCube and Wii emulator Dolphin",
"ROSALIES-MUPEN-GUI": "Nintendo 64 emulator Rosalie's Mupen GUI",
"RPCS3": "Sony PlayStation 3 emulator RPCS3",
"RUFFLE": "Adobe Flash player Ruffle",
"RYUJINX": "Nintendo Switch emulator Ryujinx",
"SAMEBOY": "Nintendo Game Boy and Game Boy Color emulator SameBoy",
"SCUMMVM": "ScummVM game engine",
"SNES9X": "Super Nintendo emulator Snes9x",
"STEAM": "Valve Steam",
"STELLA": "Atari 2600 emulator Stella",
"SUPERMODEL": "Sega Model 3 emulator Supermodel",
"TIC-80": "TIC-80 Fantasy Computer (game engine)",
"VBA-M": "Nintendo Game Boy Advance emulator VBA-M",
"VICE-X64SC": "Commodore 8-bit computer emulator VICE (x64sc accurate)",
"VICE-XPLUS4": "Commodore 8-bit computer emulator VICE (xplus4)",
"VICE-XVIC": "Commodore 8-bit computer emulator VICE (xvic)",
"XEMU": "Microsoft Xbox emulator xemu",
"YMIR": "Sega Saturn emulator Ymir"
}

BIN
vendors/es-de/emulators.linux.arm.sqlite vendored Normal file

Binary file not shown.

119
vendors/es-de/emulators.linux.x64.json vendored Normal file
View file

@ -0,0 +1,119 @@
{
"OS-SHELL": "Operating system shell",
"RETROARCH": "RETROARCH",
"3DSEN": "Nintendo NES and Famicom emulator 3dSen",
"3DSEN-WINDOWS": "Nintendo NES and Famicom emulator 3dSen",
"A7800": "Atari 7800 emulator A7800",
"ACE-DL": "Amstrad CPC emulator ACE-DL",
"ADVANCEMAME": "Arcade emulator AdvanceMAME",
"AMIBERRY": "Commodore Amiga emulator Amiberry",
"ARES": "Multi-system emulator ares",
"ATARI800": "Atari 8-bit computer emulator Atari800",
"AZAHAR": "Nintendo 3DS emulator Azahar",
"AZAHARPLUS": "Nintendo 3DS emulator AzaharPlus",
"BASILISKII": "Macintosh 68k emulator Basilisk II",
"BIGPEMU": "Atari Jaguar emulator BigPEmu",
"BIGPEMU-WINDOWS": "Atari Jaguar emulator BigPEmu",
"BLASTEM": "Sega Mega Drive/Genesis emulator BlastEm",
"BSNES": "Super Nintendo/Super Famicom emulator bsnes",
"CEMU": "Nintendo Wii U emulator Cemu",
"CITRA": "Nintendo 3DS emulator Citra",
"COLEM": "Coleco ColecoVision and Adam emulator ColEm",
"CPCEMU": "Amstrad CPC emulator CPCemu",
"CSPECT": "Sinclair ZX Spectrum Next emulator #CSpect",
"DEMUL-WINDOWS": "Sega Dreamcast emulator Demul",
"DESMUME": "Nintendo DS emulator DeSmuME",
"DOLPHIN": "Nintendo GameCube and Wii emulator Dolphin",
"DOSBOX-PURE-UNLEASHED": "DOS emulator DOSBox Pure Unleashed",
"DOSBOX-STAGING": "DOS emulator DOSBox Staging",
"DOSBOX-X": "DOS emulator DOSBox-X",
"DREAMM": "LucasArts game engine DREAMM",
"DUCKSTATION": "Sony PlayStation 1 emulator DuckStation",
"EASYRPG": "EasyRPG game engine",
"EKA2L1": "Symbian and Nokia N-Gage emulator EKA2L1",
"EKA2L1-WINDOWS": "Symbian and Nokia N-Gage emulator EKA2L1",
"FINALBURN-NEO": "Arcade emulator FinalBurn Neo",
"FLYCAST": "Sega Dreamcast emulator Flycast",
"FLYCAST-DOJO": "Sega Dreamcast emulator Flycast Dojo",
"FS-UAE": "Commodore Amiga emulator FS-UAE",
"FUSE": "Sinclair ZX Spectrum emulator Fuse",
"GARGOYLE": "Interactive fiction player Gargoyle",
"GEARBOY": "Nintendo Game Boy and Game Boy Color emulator Gearboy",
"GOPHER2600": "Atari 2600 emulator Gopher2600",
"GOPHER64": "Nintendo 64 emulator Gopher64",
"HATARI": "Atari ST/STE/TT/Falcon emulator Hatari",
"HYPSEUS-SINGE": "Hypseus Singe, fork of arcade LaserDisc emulator Daphne",
"IZAPPLE2": "Apple II emulator izapple2",
"JGENESIS": "Multi-system emulator jgenesis",
"KEGS": "Apple IIGS emulator KEGS",
"KRONOS": "Sega Saturn emulator Kronos",
"LIGHTSPARK": "Adobe Flash player Lightspark",
"LIME3DS": "Nintendo 3DS emulator Lime3DS",
"LINAPPLE": "Apple II emulator LinApple",
"LINDBERGH-LOADER": "Sega Lindbergh emulator Lindbergh Loader",
"M2EMULATOR-WINDOWS": "Sega Model 2 Emulator",
"MAME": "Arcade emulator MAME",
"MANDARINE": "Nintendo 3DS emulator Mandarine",
"MEDNAFEN": "Multi-system emulator Mednafen",
"MELONDS": "Nintendo DS and DSi emulator melonDS",
"MESEN": "Multi-system emulator Mesen",
"MFME-WINDOWS": "Fruit machine emulator MFME",
"MGBA": "Nintendo Game Boy Advance emulator mGBA",
"MUPEN64PLUS": "Nintendo 64 emulator Mupen64Plus",
"NESTOPIA-UE": "Nintendo NES and Famicom emulator Nestopia UE",
"NOODS": "Nintendo DS and Game Boy Advance emulator NooDS",
"OPENMSX": "MSX home computer emulator openMSX",
"ORICUTRON": "Tangerine Computer Systems emulator Oricutron",
"PANDA3DS": "Nintendo 3DS emulator Panda3DS",
"PARALLEL-LAUNCHER": "Nintendo 64 emulator Parallel Launcher",
"PCSX2": "Sony PlayStation 2 emulator PCSX2",
"PCSX2-LEGACY": "Sony PlayStation 2 emulator PCSX2 (legacy)",
"PICO-8": "PICO-8 Fantasy Console (game engine)",
"PLASTIC": "Nintendo NES and Famicom emulator Plastic",
"PLAY!": "Sony PlayStation 2 emulator Play!",
"PPSSPP": "Sony PlayStation Portable emulator PPSSPP",
"PRBOOM-PLUS": "Doom engine source port PrBoom+",
"PRIMEHACK": "PrimeHack, fork of Nintendo GameCube and Wii emulator Dolphin",
"PROTON": "Microsoft Windows compatibility layer Proton",
"PUNES": "Nintendo NES and Famicom emulator puNES",
"QUASI88": "NEC PC-8800 series emulator QUASI88",
"REDREAM": "Sega Dreamcast emulator Redream",
"ROSALIES-MUPEN-GUI": "Nintendo 64 emulator Rosalie's Mupen GUI",
"RPCS3": "Sony PlayStation 3 emulator RPCS3",
"RUFFLE": "Adobe Flash player Ruffle",
"RYUJINX": "Nintendo Switch emulator Ryujinx",
"SAMEBOY": "Nintendo Game Boy and Game Boy Color emulator SameBoy",
"SCUMMVM": "ScummVM game engine",
"SDL2TRS": "Tandy TRS-80 emulator sdl2trs",
"SHADPS4": "Sony PlayStation 4 emulator shadPS4",
"SHADPS4-GUI": "Sony PlayStation 4 emulator shadPS4 (GUI)",
"SHEEPSHAVER": "Apple Macintosh PowerPC emulator SheepShaver",
"SIMCOUPE": "SAM Coupé emulator SimCoupé",
"SIMPLE64": "Nintendo 64 emulator simple64",
"SKYEMU": "Nintendo Game Boy, Game Boy Color, Game Boy Advance and DS emulator SkyEmu",
"SNES9X": "Super Nintendo emulator Snes9x",
"SOLARUS": "Solarus game engine",
"STEAM": "Valve Steam",
"STELLA": "Atari 2600 emulator Stella",
"SUPERMODEL": "Sega Model 3 emulator Supermodel",
"TIC-80": "TIC-80 Fantasy Computer (game engine)",
"TRIFORCE": "Triforce, fork of Nintendo GameCube and Wii emulator Dolphin",
"TSUGARU": "Fujitsu FM Towns emulator Tsugaru",
"VBA-M": "Nintendo Game Boy Advance emulator VBA-M",
"VICE-X64SC": "Commodore 8-bit computer emulator VICE (x64sc accurate)",
"VICE-XPLUS4": "Commodore 8-bit computer emulator VICE (xplus4)",
"VICE-XVIC": "Commodore 8-bit computer emulator VICE (xvic)",
"VIRCON32": "Vircon32 Virtual Console",
"VISUAL-PINBALL": "Pinball simulator Visual Pinball",
"VITA3K": "Sony PlayStation Vita emulator Vita3K",
"WINE": "Microsoft Windows compatibility layer Wine",
"XEMU": "Microsoft Xbox emulator xemu",
"XENIA": "Microsoft Xbox 360 emulator xenia",
"XENIA-EDGE": "Microsoft Xbox 360 emulator Xenia Edge",
"XENIA-WINDOWS": "Microsoft Xbox 360 emulator xenia",
"XM6PRO68K-WINDOWS": "Sharp X68000 emulator XM6 Pro-68k",
"XM6TYPEG-WINDOWS": "Sharp X68000 emulator XM6 TypeG",
"XROAR": "Dragon and Tandy 8-bit computer emulator XRoar",
"YMIR": "Sega Saturn emulator Ymir",
"ZESARUX": "Sinclair ZX Spectrum emulator ZEsarUX"
}

BIN
vendors/es-de/emulators.linux.x64.sqlite vendored Normal file

Binary file not shown.

119
vendors/es-de/emulators.win32.x64.json vendored Normal file
View file

@ -0,0 +1,119 @@
{
"OS-SHELL": "Operating system shell",
"RETROARCH": "RETROARCH",
"3DSEN": "Nintendo NES and Famicom emulator 3dSen",
"A7800": "Atari 7800 emulator A7800",
"ACE-DL": "Amstrad CPC emulator ACE-DL",
"ADVANCEMAME": "Arcade emulator AdvanceMAME",
"ALTIRRA": "Atari 8-bit computer emulator Altirra",
"APPLEWIN": "Apple II emulator AppleWin",
"ARCADEFLASHWEB": "Adobe Flash player ArcadeFlashWeb",
"ARES": "Multi-system emulator ares",
"ATARI800": "Atari 8-bit computer emulator Atari800",
"AZAHAR": "Nintendo 3DS emulator Azahar",
"AZAHARPLUS": "Nintendo 3DS emulator AzaharPlus",
"BASILISKII": "Macintosh 68k emulator Basilisk II",
"BEEBEM": "Acorn Computers BBC Micro emulator BeebEm",
"BIGPEMU": "Atari Jaguar emulator BigPEmu",
"BLASTEM": "Sega Mega Drive/Genesis emulator BlastEm",
"BSNES": "Super Nintendo/Super Famicom emulator bsnes",
"CEMU": "Nintendo Wii U emulator Cemu",
"CITRA": "Nintendo 3DS emulator Citra",
"COLEM": "Coleco ColecoVision and Adam emulator ColEm",
"CPCEMU": "Amstrad CPC emulator CPCemu",
"CSPECT": "Sinclair ZX Spectrum Next emulator #CSpect",
"CXBX-RELOADED": "Microsoft Xbox emulator Cxbx-Reloaded",
"DEMUL": "Sega Dreamcast emulator Demul",
"DOLPHIN": "Nintendo GameCube and Wii emulator Dolphin",
"DOSBOX-PURE-UNLEASHED": "DOS emulator DOSBox Pure Unleashed",
"DOSBOX-STAGING": "DOS emulator DOSBox Staging",
"DOSBOX-X": "DOS emulator DOSBox-X",
"DREAMM": "LucasArts game engine DREAMM",
"DUCKSTATION": "Sony PlayStation 1 emulator DuckStation",
"EASYRPG": "EasyRPG game engine",
"EKA2L1": "Symbian and Nokia N-Gage emulator EKA2L1",
"FINALBURN-NEO": "Arcade emulator FinalBurn Neo",
"FLYCAST": "Sega Dreamcast emulator Flycast",
"FLYCAST-DOJO": "Sega Dreamcast emulator Flycast Dojo",
"FS-UAE": "Commodore Amiga emulator FS-UAE",
"FUSE": "Sinclair ZX Spectrum emulator Fuse",
"FUTURE-PINBALL": "Pinball simulator Future Pinball",
"FUTURE-PINBALL-FPLOADER": "Pinball simulator Future Pinball (FPLoader)",
"GARGOYLE": "Interactive fiction player Gargoyle",
"GEARBOY": "Nintendo Game Boy and Game Boy Color emulator Gearboy",
"GOPHER2600": "Atari 2600 emulator Gopher2600",
"GOPHER64": "Nintendo 64 emulator Gopher64",
"HATARI": "Atari ST/STE/TT/Falcon emulator Hatari",
"HYPSEUS-SINGE": "Hypseus Singe, fork of arcade LaserDisc emulator Daphne",
"IZAPPLE2": "Apple II emulator izapple2",
"JGENESIS": "Multi-system emulator jgenesis",
"KEGS": "Apple IIGS emulator KEGS",
"KEMULATOR": "Java 2 Micro Edition emulator KEmulator",
"KRONOS": "Sega Saturn emulator Kronos",
"LIME3DS": "Nintendo 3DS emulator Lime3DS",
"M2EMULATOR": "Sega Model 2 Emulator",
"MAME": "Arcade emulator MAME",
"MANDARINE": "Nintendo 3DS emulator Mandarine",
"MEDNAFEN": "Multi-system emulator Mednafen",
"MELONDS": "Nintendo DS and DSi emulator melonDS",
"MESEN": "Multi-system emulator Mesen",
"MFME": "Fruit machine emulator MFME",
"MGBA": "Nintendo Game Boy Advance emulator mGBA",
"MUPEN64PLUS": "Nintendo 64 emulator Mupen64Plus",
"NOODS": "Nintendo DS and Game Boy Advance emulator NooDS",
"OPENMSX": "MSX home computer emulator openMSX",
"ORICUTRON": "Tangerine Computer Systems emulator Oricutron",
"PANDA3DS": "Nintendo 3DS emulator Panda3DS",
"PARALLEL-LAUNCHER": "Nintendo 64 emulator Parallel Launcher",
"PCSX2": "Sony PlayStation 2 emulator PCSX2",
"PCSX2-LEGACY": "Sony PlayStation 2 emulator PCSX2 (legacy)",
"PICO-8": "PICO-8 Fantasy Console (game engine)",
"PLAY!": "Sony PlayStation 2 emulator Play!",
"PPSSPP": "Sony PlayStation Portable emulator PPSSPP",
"PRBOOM-PLUS": "Doom engine source port PrBoom+",
"PRIMEHACK": "PrimeHack, fork of Nintendo GameCube and Wii emulator Dolphin",
"PROJECT64": "Nintendo 64 emulator Project64",
"PUNES": "Nintendo NES and Famicom emulator puNES",
"QUASI88": "NEC PC-8800 series emulator QUASI88",
"REDREAM": "Sega Dreamcast emulator Redream",
"ROSALIES-MUPEN-GUI": "Nintendo 64 emulator Rosalie's Mupen GUI",
"RPCS3": "Sony PlayStation 3 emulator RPCS3",
"RUFFLE": "Adobe Flash player Ruffle",
"RYUJINX": "Nintendo Switch emulator Ryujinx",
"SAMEBOY": "Nintendo Game Boy and Game Boy Color emulator SameBoy",
"SCUMMVM": "ScummVM game engine",
"SDL2TRS": "Tandy TRS-80 emulator sdl2trs",
"SHADPS4": "Sony PlayStation 4 emulator shadPS4",
"SHADPS4-GUI": "Sony PlayStation 4 emulator shadPS4 (GUI)",
"SHEEPSHAVER": "Apple Macintosh PowerPC emulator SheepShaver",
"SIMCOUPE": "SAM Coupé emulator SimCoupé",
"SIMPLE64": "Nintendo 64 emulator simple64",
"SKYEMU": "Nintendo Game Boy, Game Boy Color, Game Boy Advance and DS emulator SkyEmu",
"SNES9X": "Super Nintendo emulator Snes9x",
"SOLARUS": "Solarus game engine",
"SSF": "Sega Saturn emulator SSF",
"STEAM": "Valve Steam",
"STELLA": "Atari 2600 emulator Stella",
"SUPERMODEL": "Sega Model 3 emulator Supermodel",
"SUPERMODEL-DOJO": "Sega Model 3 emulator Supermodel Dojo",
"TIC-80": "TIC-80 Fantasy Computer (game engine)",
"TRIFORCE": "Triforce, fork of Nintendo GameCube and Wii emulator Dolphin",
"TSUGARU": "Fujitsu FM Towns emulator Tsugaru",
"VBA-M": "Nintendo Game Boy Advance emulator VBA-M",
"VICE-X64SC": "Commodore 8-bit computer emulator VICE (x64sc accurate)",
"VICE-XPLUS4": "Commodore 8-bit computer emulator VICE (xplus4)",
"VICE-XVIC": "Commodore 8-bit computer emulator VICE (xvic)",
"VIRCON32": "Vircon32 Virtual Console",
"VISUAL-PINBALL": "Pinball simulator Visual Pinball",
"VITA3K": "Sony PlayStation Vita emulator Vita3K",
"WINARCADIA": "Emerson Arcadia 2001 emulator WinArcadia",
"XEMU": "Microsoft Xbox emulator xemu",
"XENIA": "Microsoft Xbox 360 emulator xenia",
"XENIA-EDGE": "Microsoft Xbox 360 emulator Xenia Edge",
"XM6PRO68K": "Sharp X68000 emulator XM6 Pro-68k",
"XM6TYPEG": "Sharp X68000 emulator XM6 TypeG",
"XROAR": "Dragon and Tandy 8-bit computer emulator XRoar",
"YABASANSHIRO-2": "Sega Saturn emulator Yaba Sanshiro 2",
"YMIR": "Sega Saturn emulator Ymir",
"ZESARUX": "Sinclair ZX Spectrum emulator ZEsarUX"
}

BIN
vendors/es-de/emulators.win32.x64.sqlite vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,523 @@
<?xml version="1.0"?>
<!-- This is the ES-DE Frontend find rules configuration file for Android -->
<ruleList>
<emulator name="RETROARCH">
<rule type="androidpackage">
<!-- 64-bit and 32-bit releases from the retroarch.com website -->
<entry>com.retroarch.aarch64/com.retroarch.browser.retroactivity.RetroActivityFuture</entry>
<entry>com.retroarch.ra32/com.retroarch.browser.retroactivity.RetroActivityFuture</entry>
<!-- All other releases -->
<entry>com.retroarch/com.retroarch.browser.retroactivity.RetroActivityFuture</entry>
</rule>
</emulator>
<emulator name="2600-EMU">
<!-- Atari 2600 emulator 2600.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.A2600Emu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="AETHERSX2">
<!-- Sony PlayStation 2 emulator AetherSX2 -->
<rule type="androidpackage">
<entry>xyz.aethersx2.android/.EmulationActivity</entry>
</rule>
</emulator>
<emulator name="APS3E">
<!-- Sony PlayStation 3 emulator aPS3e -->
<rule type="androidpackage">
<entry>aenu.aps3e.premium/aenu.aps3e.EmulatorActivity</entry>
<entry>aenu.aps3e/aenu.aps3e.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="ARMSX2">
<!-- Sony PlayStation 2 emulator ARMSX2 -->
<rule type="androidpackage">
<entry>come.nanodata.armsx2/kr.co.iefriends.pcsx2.MainActivity</entry>
<entry>come.nanodata.armsx2.debug/kr.co.iefriends.pcsx2.MainActivity</entry>
</rule>
</emulator>
<emulator name="AZAHAR">
<!-- Nintendo 3DS emulator Azahar -->
<rule type="androidpackage">
<entry>io.github.lime3ds.android/org.citra.citra_emu.activities.EmulationActivity</entry>
</rule>
</emulator>
<emulator name="AZAHARPLUS">
<!-- Nintendo 3DS emulator AzaharPlus -->
<rule type="androidpackage">
<entry>io.github.azaharplus.android/org.citra.citra_emu.activities.EmulationActivity</entry>
</rule>
</emulator>
<emulator name="C64-EMU">
<!-- Commodore 8-bit computer emulator C64.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.C64Emu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="CEMU">
<!-- Nintendo Wii U emulator Cemu -->
<rule type="androidpackage">
<entry>info.cemu.cemu/info.cemu.cemu.emulation.EmulationActivity</entry>
<entry>info.cemu.Cemu/info.cemu.Cemu.emulation.EmulationActivity</entry>
</rule>
</emulator>
<emulator name="CITRA">
<!-- Nintendo 3DS emulator Citra -->
<rule type="androidpackage">
<entry>org.citra.citra_emu/.activities.EmulationActivity</entry>
<entry>org.citra.citra_emu/.ui.main.MainActivity</entry>
</rule>
</emulator>
<emulator name="CITRA-CANARY">
<!-- Nintendo 3DS emulator Citra Canary -->
<rule type="androidpackage">
<entry>org.citra.citra_emu.canary/org.citra.citra_emu.activities.EmulationActivity</entry>
</rule>
</emulator>
<emulator name="CITRA-MMJ">
<!-- Nintendo 3DS emulator Citra MMJ -->
<rule type="androidpackage">
<entry>org.citra.emu/.ui.EmulationActivity</entry>
<entry>com.antutu.ABenchMark/org.citra.emu.ui.EmulationActivity</entry>
</rule>
</emulator>
<emulator name="COLEM">
<!-- Coleco ColecoVision and Adam emulator ColEm -->
<rule type="androidpackage">
<entry>com.fms.colem.deluxe/com.fms.emulib.TVActivity</entry>
<entry>com.fms.colem/com.fms.emulib.TVActivity</entry>
</rule>
</emulator>
<emulator name="DOLPHIN">
<!-- Nintendo GameCube and Wii emulator Dolphin -->
<rule type="androidpackage">
<entry>org.dolphinemu.dolphinemu/.ui.main.TvMainActivity</entry>
</rule>
</emulator>
<emulator name="DOLPHIN-MMJR">
<!-- Dolphin MMJR, fork of Nintendo GameCube and Wii emulator Dolphin -->
<rule type="androidpackage">
<entry>org.mm.jr/org.dolphinemu.dolphinemu.ui.main.MainActivity</entry>
</rule>
</emulator>
<emulator name="DOLPHIN-MMJR2">
<!-- Dolphin MMJR2, fork of Nintendo GameCube and Wii emulator Dolphin -->
<rule type="androidpackage">
<entry>org.dolphinemu.mmjr/org.dolphinemu.dolphinemu.ui.main.MainActivity</entry>
</rule>
</emulator>
<emulator name="DROIDARCADIA">
<!-- Emerson Arcadia 2001 emulator DroidArcadia -->
<rule type="androidpackage">
<entry>com.amigan.droidarcadia/.MainActivity</entry>
</rule>
</emulator>
<emulator name="DRASTIC">
<!-- Nintendo DS emulator DraStic -->
<rule type="androidpackage">
<entry>com.dsemu.drastic/.DraSticActivity</entry>
</rule>
</emulator>
<emulator name="DUCKSTATION">
<!-- Sony PlayStation 1 emulator DuckStation -->
<rule type="androidpackage">
<entry>com.github.stenzek.duckstation/.EmulationActivity</entry>
</rule>
</emulator>
<emulator name="EKA2L1">
<!-- Symbian and Nokia N-Gage emulator EKA2L1 -->
<rule type="androidpackage">
<entry>com.github.eka2l1/.emu.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="EPSXE">
<!-- Sony PlayStation 1 emulator ePSXe -->
<rule type="androidpackage">
<entry>com.epsxe.ePSXe/.ePSXe</entry>
</rule>
</emulator>
<emulator name="FLYCAST">
<!-- Sega Dreamcast emulator Flycast -->
<rule type="androidpackage">
<entry>com.flycast.emulator/com.flycast.emulator.MainActivity</entry>
<entry>com.flycast.emulator/com.reicast.emulator.MainActivity</entry>
</rule>
</emulator>
<emulator name="FMSX">
<!-- MSX home computer emulator fMSX -->
<rule type="androidpackage">
<entry>com.fms.fmsx.deluxe/com.fms.emulib.TVActivity</entry>
<entry>com.fms.fmsx/com.fms.emulib.TVActivity</entry>
</rule>
</emulator>
<emulator name="FPSE">
<!-- Sony PlayStation 1 emulator FPse -->
<rule type="androidpackage">
<entry>com.emulator.fpse/.Main</entry>
</rule>
</emulator>
<emulator name="FPSE-NG">
<!-- Sony PlayStation 1 emulator FPseNG -->
<rule type="androidpackage">
<entry>com.emulator.fpse64/.Main</entry>
</rule>
</emulator>
<emulator name="GAMEHUB-LITE">
<!-- Steam client GameHub Lite -->
<rule type="androidpackage">
<entry>emuready.gamehub.lite/com.xj.landscape.launcher.ui.gamedetail.GameDetailActivity</entry>
<entry>gamehub.lite/com.xj.landscape.launcher.ui.gamedetail.GameDetailActivity</entry>
<entry>com.antutu.ABenchMark/com.xj.landscape.launcher.ui.gamedetail.GameDetailActivity</entry>
<entry>com.tencent.ig/com.xj.landscape.launcher.ui.gamedetail.GameDetailActivity</entry>
<entry>com.ludashi.aibench/com.xj.landscape.launcher.ui.gamedetail.GameDetailActivity</entry>
</rule>
</emulator>
<emulator name="GAMENATIVE">
<!-- Steam client GameNative -->
<rule type="androidpackage">
<entry>app.gamenative/.MainActivity</entry>
</rule>
</emulator>
<emulator name="GBA-EMU">
<!-- Nintendo Game Boy Advance emulator GBA.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.GbaEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="GBC-EMU">
<!-- Nintendo Game Boy and Game Boy Color emulator GBC.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.GbcEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="INES">
<!-- Nintendo NES and Famicom emulator iNES -->
<rule type="androidpackage">
<entry>com.fms.ines.free/com.fms.emulib.TVActivity</entry>
</rule>
</emulator>
<emulator name="INFINITY">
<!-- PICO-8 Fantasy Console game engine Infinity -->
<rule type="androidpackage">
<entry>me.dt2dev.infinity/.SchemeActivity</entry>
</rule>
</emulator>
<emulator name="IRATAJAGUAR">
<!-- Atari Jaguar emulator IrataJaguar -->
<rule type="androidpackage">
<entry>ru.vastness.altmer.iratajaguar/.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="J2ME-LOADER">
<!-- Java 2 Micro Edition emulator J2ME Loader -->
<rule type="androidpackage">
<entry>ru.playsoftware.j2meloader/.MainActivity</entry>
</rule>
</emulator>
<emulator name="JL-MOD">
<!-- Java 2 Micro Edition emulator JL-Mod -->
<rule type="androidpackage">
<entry>ru.woesss.j2meloader/ru.playsoftware.j2meloader.MainActivity</entry>
</rule>
</emulator>
<emulator name="KENJI-NX">
<!-- Nintendo Switch emulator Kenji-NX -->
<rule type="androidpackage">
<entry>org.kenjinx.android/.MainActivity</entry>
</rule>
</emulator>
<emulator name="LIME3DS">
<!-- Nintendo 3DS emulator Lime3DS -->
<rule type="androidpackage">
<entry>io.github.lime3ds.android/.activities.EmulationActivity</entry>
</rule>
</emulator>
<emulator name="LINKBOY">
<!-- Nintendo Game Boy, Game Boy Color and Game Boy Advance emulator Linkboy -->
<rule type="androidpackage">
<entry>com.pixelrespawn.linkboy/.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="LYNX-EMU">
<!-- Atari Lynx emulator Lynx.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.LynxEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="M64PLUS-FZ">
<!-- Nintendo 64 emulator M64Plus FZ -->
<rule type="androidpackage">
<entry>org.mupen64plusae.v3.fzurita.amazon/paulscode.android.mupen64plusae.SplashActivity</entry>
<entry>org.mupen64plusae.v3.fzurita.pro/paulscode.android.mupen64plusae.SplashActivity</entry>
<entry>org.mupen64plusae.v3.fzurita/paulscode.android.mupen64plusae.SplashActivity</entry>
</rule>
</emulator>
<emulator name="MAME4DROID-CURRENT">
<!-- Arcade emulator MAME4droid Current -->
<rule type="androidpackage">
<entry>com.seleuco.mame4d2024/com.seleuco.mame4droid.MAME4droid</entry>
</rule>
</emulator>
<emulator name="MAME4DROID">
<!-- Arcade emulator MAME4droid -->
<rule type="androidpackage">
<entry>com.seleuco.mame4droid/.MAME4droid</entry>
</rule>
</emulator>
<emulator name="MAME4DROID-2024">
<!-- Arcade emulator MAME4droid 2024 -->
<rule type="androidpackage">
<entry>com.seleuco.mame4d2024/com.seleuco.mame4droid.MAME4droid</entry>
</rule>
</emulator>
<emulator name="MANDARINE">
<!-- Nintendo 3DS emulator Mandarine -->
<rule type="androidpackage">
<entry>io.github.mandarine3ds.mandarine/.activities.EmulationActivity</entry>
</rule>
</emulator>
<emulator name="MASTERGEAR">
<!-- Sega Game Gear, Master System and SG-1000 emulator MasterGear -->
<rule type="androidpackage">
<entry>com.fms.mg/com.fms.emulib.TVActivity</entry>
</rule>
</emulator>
<emulator name="MD-EMU">
<!-- Sega Mega Drive/Genesis emulator MD.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.MdEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="MELONDS">
<!-- Nintendo DS and DSi emulator melonDS -->
<rule type="androidpackage">
<entry>me.magnum.melonds/.ui.emulator.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="MELONDS-NIGHTLY">
<!-- Nintendo DS and DSi emulator melonDS (Nightly build) -->
<rule type="androidpackage">
<entry>me.magnum.melonds.nightly/me.magnum.melonds.ui.emulator.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="MSX-EMU">
<!-- MSX home computer and ColecoVision emulator MSX.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.MsxEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="MUPEN64PLUS-AE">
<!-- Nintendo 64 emulator Mupen64Plus AE -->
<rule type="androidpackage">
<entry>org.mupen64plusae.v3.alpha/paulscode.android.mupen64plusae.SplashActivity</entry>
</rule>
</emulator>
<emulator name="MY-BOY">
<!-- Nintendo Game Boy Advance emulator My Boy! -->
<rule type="androidpackage">
<entry>com.fastemulator.gba/.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="MY-OLDBOY">
<!-- Nintendo Game Boy and Game Boy Color emulator My OldBoy! -->
<rule type="androidpackage">
<entry>com.fastemulator.gbc/.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="NEO-EMU">
<!-- SNK Neo Geo emulator NEO.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.NeoEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="NES-EMU">
<!-- Nintendo NES and Famicom emulator NES.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.NesEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="NESOID">
<!-- Nintendo NES and Famicom emulator Nesoid -->
<rule type="androidpackage">
<entry>com.androidemu.nes/.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="NGP-EMU">
<!-- SNK Neo Geo Pocket emulator NGP.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.NgpEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="NOODS">
<!-- Nintendo DS and Game Boy Advance emulator NooDS -->
<rule type="androidpackage">
<entry>com.hydra.noods/.FileBrowser</entry>
</rule>
</emulator>
<emulator name="OPENBOR">
<!-- OpenBOR game engine -->
<rule type="androidpackage">
<entry>org.openbor.engine/.GameActivity</entry>
</rule>
</emulator>
<emulator name="PANDA3DS">
<!-- Nintendo 3DS emulator Panda3DS -->
<rule type="androidpackage">
<entry>com.panda3ds.pandroid/.app.MainActivity</entry>
</rule>
</emulator>
<emulator name="PCE-EMU">
<!-- NEC PC Engine/TurboGrafx-16 emulator PCE.emu -->
<rule type="androidpackage">
<entry>com.PceEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="PIZZA-BOY-GBA">
<!-- Nintendo Game Boy Advance emulator Pizza Boy GBA -->
<rule type="androidpackage">
<entry>it.dbtecno.pizzaboygbapro/it.dbtecno.pizzaboygbapro.MainActivity</entry>
<entry>it.dbtecno.pizzaboygba/it.dbtecno.pizzaboygba.MainActivity</entry>
</rule>
</emulator>
<emulator name="PIZZA-BOY-GBC">
<!-- Nintendo Game Boy and Game Boy Color emulator Pizza Boy GBC -->
<rule type="androidpackage">
<entry>it.dbtecno.pizzaboypro/it.dbtecno.pizzaboypro.MainActivity</entry>
<entry>it.dbtecno.pizzaboy/it.dbtecno.pizzaboy.MainActivity</entry>
</rule>
</emulator>
<emulator name="PIZZA-BOY-SC">
<!-- Sega multi-system emulator Pizza Boy SC -->
<rule type="androidpackage">
<entry>it.dbtecno.pizzaboyscpro/.MainActivity</entry>
<entry>it.dbtecno.pizzaboyscbasic/.MainActivity</entry>
</rule>
</emulator>
<emulator name="PLAY!">
<!-- Sony PlayStation 2 emulator Play! -->
<rule type="androidpackage">
<entry>com.virtualapplications.play/.MainActivity</entry>
</rule>
</emulator>
<emulator name="PPSSPP">
<!-- Sony PlayStation Portable emulator PPSSPP -->
<rule type="androidpackage">
<entry>org.ppsspp.ppssppgold/org.ppsspp.ppsspp.PpssppActivity</entry>
<entry>org.ppsspp.ppsspp/.PpssppActivity</entry>
</rule>
</emulator>
<emulator name="REAL3DOPLAYER">
<!-- 3DO Interactive Multiplayer emulator Real3DOPlayer -->
<rule type="androidpackage">
<entry>ru.vastness.altmer.real3doplayer/.EmulatorActivity</entry>
</rule>
</emulator>
<emulator name="REDREAM">
<!-- Sega Dreamcast emulator Redream -->
<rule type="androidpackage">
<entry>io.recompiled.redream/.MainActivity</entry>
</rule>
</emulator>
<emulator name="RUFFLE">
<!-- Adobe Flash player Ruffle -->
<rule type="androidpackage">
<entry>rs.ruffle/.PlayerActivity</entry>
</rule>
</emulator>
<emulator name="SATURN-EMU">
<!-- Sega Saturn emulator Saturn.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.SaturnEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="SCUMMVM">
<!-- ScummVM game engine -->
<rule type="androidpackage">
<entry>org.scummvm.scummvm.debug/org.scummvm.scummvm.SplashActivity</entry>
<entry>org.scummvm.scummvm/org.scummvm.scummvm.SplashActivity</entry>
</rule>
</emulator>
<emulator name="SKYEMU">
<!-- Nintendo Game Boy, Game Boy Color, Game Boy Advance and DS emulator SkyEmu -->
<rule type="androidpackage">
<entry>com.sky.SkyEmu/.EnhancedNativeActivity</entry>
</rule>
</emulator>
<emulator name="SKYLINE">
<!-- Nintendo Switch emulator Skyline -->
<rule type="androidpackage">
<entry>skyline.emu/emu.skyline.EmulationActivity</entry>
</rule>
</emulator>
<emulator name="SNES9X-EXPLUS">
<!-- Super Nintendo emulator Snes9x EX+ -->
<rule type="androidpackage">
<entry>com.explusalpha.Snes9xPlus/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="SPECCY">
<!-- Sinclair ZX Spectrum emulator Speccy -->
<rule type="androidpackage">
<entry>com.fms.speccy.deluxe/com.fms.emulib.TVActivity</entry>
<entry>com.fms.speccy/com.fms.emulib.TVActivity</entry>
</rule>
</emulator>
<emulator name="SWAN-EMU">
<!-- Bandai WonderSwan emulator Swan.emu -->
<rule type="androidpackage">
<entry>com.explusalpha.SwanEmu/com.imagine.BaseActivity</entry>
</rule>
</emulator>
<emulator name="SWF-PLAYER">
<!-- Adobe Flash player SWF Player -->
<rule type="androidpackage">
<entry>com.issess.flashplayerpro/com.issess.flashplayer.player.FlashPlayerActivity</entry>
<entry>com.issess.flashplayer/.player.FlashPlayerActivity</entry>
</rule>
</emulator>
<emulator name="VIRTUAL-VIRTUAL-BOY">
<!-- Nintendo Virtual Boy emulator Virtual Virtual Boy -->
<rule type="androidpackage">
<entry>com.simongellis.vvb/.MainActivity</entry>
</rule>
</emulator>
<emulator name="VISUAL-PINBALL">
<!-- Pinball simulator Visual Pinball -->
<rule type="androidpackage">
<entry>org.vpinball.vpinball_bgfx/org.vpinball.app.VPinballActivity</entry>
<entry>org.vpinball.app/.VpxLauncherActivity</entry>
</rule>
</emulator>
<emulator name="VITA3K">
<!-- Sony PlayStation Vita emulator Vita3K -->
<rule type="androidpackage">
<entry>org.vita3k.emulator/.Emulator</entry>
<entry>org.vita3k.emulator.ikhoeyZX/org.vita3k.emulator.Emulator</entry>
</rule>
</emulator>
<emulator name="WINLATOR-CMOD">
<!-- Microsoft Windows emulator Winlator Cmod -->
<rule type="androidpackage">
<entry>com.winlator.cmod/.XServerDisplayActivity</entry>
</rule>
</emulator>
<emulator name="WINLATOR-GLIBC">
<!-- Microsoft Windows emulator Winlator Cmod Glibc -->
<rule type="androidpackage">
<entry>com.winlator/.XServerDisplayActivity</entry>
</rule>
</emulator>
<emulator name="WINLATOR-PROOT">
<!-- Microsoft Windows emulator Winlator Cmod PRoot -->
<rule type="androidpackage">
<entry>com.cmodded.winlator/com.winlator.XServerDisplayActivity</entry>
</rule>
</emulator>
<emulator name="YABASANSHIRO-2">
<!-- Sega Saturn emulator Yaba Sanshiro 2 -->
<rule type="androidpackage">
<entry>org.devmiyax.yabasanshioro2.pro/org.uoyabause.android.Yabause</entry>
<entry>org.devmiyax.yabasanshioro2/org.uoyabause.android.Yabause</entry>
</rule>
</emulator>
</ruleList>

View file

@ -0,0 +1,29 @@
<?xml version="1.0"?>
<!-- This is the ES-DE import rules configuration file for Android -->
<ruleList>
<system name="androidapps">
<rule name="Android Package" type="androidpackage">
<extension>.app</extension>
</rule>
</system>
<system name="androidgames">
<rule name="Android Package" type="androidpackage">
<extension>.app</extension>
</rule>
</system>
<system name="emulators">
<rule name="Android Package" type="androidpackage">
<extension>.app</extension>
</rule>
</system>
<system name="epic">
<rule name="Android Package" type="androidpackage">
<extension>.app</extension>
</rule>
</system>
<system name="n64">
<rule name="Android Package" type="androidpackage">
<extension>.app</extension>
</rule>
</system>
</ruleList>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,138 @@
<?xml version="1.0"?>
<!-- This is the ES-DE find rules configuration file for Haiku -->
<ruleList>
<emulator name="OS-SHELL">
<!-- Operating system shell -->
<rule type="systempath">
<entry>bash</entry>
<entry>sh</entry>
</rule>
</emulator>
<emulator name="RETROARCH">
<rule type="systempath">
<entry>retroarch</entry>
</rule>
<rule type="staticpath">
<entry>/boot/system/apps/retroarch</entry>
</rule>
</emulator>
<core name="RETROARCH">
<rule type="corepath">
<entry>/boot/system/add-ons/libretro</entry>
</rule>
</core>
<emulator name="ADVANCEMAME">
<!-- Arcade emulator AdvanceMAME -->
<rule type="systempath">
<entry>advmame</entry>
</rule>
<rule type="staticpath">
<entry>/bin/advmame</entry>
</rule>
</emulator>
<emulator name="DOSBOX-X">
<!-- DOS emulator DOSBox-X -->
<rule type="systempath">
<entry>DOSBox-X</entry>
</rule>
<rule type="staticpath">
<entry>/boot/system/apps/DOSBox-X</entry>
</rule>
</emulator>
<emulator name="FINALBURN-NEO">
<!-- Arcade emulator FinalBurn Neo -->
<rule type="systempath">
<entry>fbneo</entry>
</rule>
<rule type="staticpath">
<entry>/boot/system/apps/fbneo</entry>
</rule>
</emulator>
<emulator name="FS-UAE">
<!-- Commodore Amiga emulator FS-UAE -->
<rule type="systempath">
<entry>fs-uae-launcher</entry>
</rule>
<rule type="staticpath">
<entry>/bin/fs-uae-launcher</entry>
</rule>
</emulator>
<emulator name="FUSE">
<!-- Sinclair ZX Spectrum emulator Fuse -->
<rule type="staticpath">
<entry>/boot/system/apps/Fuse/Fuse</entry>
</rule>
</emulator>
<emulator name="MAME">
<!-- Arcade emulator MAME -->
<rule type="systempath">
<entry>mame</entry>
</rule>
<rule type="staticpath">
<entry>/bin/mame</entry>
</rule>
</emulator>
<emulator name="MEDNAFEN">
<!-- Multi-system emulator Mednafen -->
<rule type="systempath">
<entry>mednafen</entry>
</rule>
<rule type="staticpath">
<entry>/bin/mednafen</entry>
</rule>
</emulator>
<emulator name="MELONDS">
<!-- Nintendo DS and DSi emulator melonDS -->
<rule type="systempath">
<entry>melonDS</entry>
</rule>
<rule type="staticpath">
<entry>/boot/system/apps/melonDS</entry>
</rule>
</emulator>
<emulator name="MGBA">
<!-- Nintendo Game Boy Advance emulator mGBA -->
<rule type="systempath">
<entry>mgba</entry>
</rule>
<rule type="staticpath">
<entry>/bin/mgba</entry>
</rule>
</emulator>
<emulator name="MUPEN64PLUS">
<!-- Nintendo 64 emulator Mupen64Plus -->
<rule type="staticpath">
<entry>/boot/system/apps/Mupen64Plus/bin/Mupen64Plus</entry>
</rule>
</emulator>
<emulator name="PPSSPP">
<!-- Sony PlayStation Portable emulator PPSSPP -->
<rule type="staticpath">
<entry>/boot/system/apps/PPSSPP/PPSSPP</entry>
</rule>
</emulator>
<emulator name="SCUMMVM">
<!-- ScummVM game engine -->
<rule type="systempath">
<entry>ScummVM</entry>
</rule>
<rule type="staticpath">
<entry>/boot/system/apps/ScummVM</entry>
</rule>
</emulator>
<emulator name="SOLARUS">
<!-- Solarus game engine -->
<rule type="systempath">
<entry>solarus-run</entry>
</rule>
<rule type="staticpath">
<entry>/bin/solarus-run</entry>
</rule>
</emulator>
<emulator name="ZESARUX">
<!-- Sinclair ZX Spectrum emulator ZEsarUX -->
<rule type="staticpath">
<entry>/boot/system/apps/ZEsarUX/ZEsarUX</entry>
</rule>
</emulator>
</ruleList>

View file

@ -0,0 +1,4 @@
<?xml version="1.0"?>
<!-- This is the ES-DE import rules configuration file for Haiku -->
<ruleList>
</ruleList>

1952
vendors/es-de/systems/haiku/es_systems.xml vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
<?xml version="1.0"?>
<!-- This is the ES-DE import rules configuration file for Linux -->
<ruleList>
<system name="desktop">
<rule name="Desktop Shortcut" type="desktopshortcut">
<directory>/usr/share/applications</directory>
<directory>/usr/local/share/applications</directory>
<directory>~/.local/share/applications</directory>
<directory>/var/lib/flatpak/exports/share/applications</directory>
<directory>/var/lib/snapd/desktop/applications</directory>
<directory>~/Desktop</directory>
</rule>
</system>
<system name="emulators">
<rule name="Desktop Shortcut" type="desktopshortcut">
<directory>/usr/share/applications</directory>
<directory>/usr/local/share/applications</directory>
<directory>~/.local/share/applications</directory>
<directory>/var/lib/flatpak/exports/share/applications</directory>
<directory>/var/lib/snapd/desktop/applications</directory>
</rule>
</system>
<system name="epic">
<rule name="Desktop Shortcut" type="desktopshortcut">
<directory execFilter="heroic://launch">~/.local/share/applications</directory>
<directory execFilter="heroic://launch">~/Desktop</directory>
</rule>
</system>
<system name="lutris">
<rule name="Desktop Shortcut" type="desktopshortcut">
<directory execFilter="lutris:rungameid">~/.local/share/applications</directory>
<directory execFilter="lutris:rungameid">~/Desktop</directory>
</rule>
</system>
<system name="ps3">
<rule name="Desktop Shortcut" type="desktopshortcut">
<directory execFilter="RPCS3_GAMEID">~/.local/share/applications/RPCS3</directory>
<directory execFilter="RPCS3_GAMEID">~/Desktop</directory>
</rule>
</system>
<system name="steam">
<rule name="Desktop Shortcut" type="desktopshortcut">
<directory execFilter="steam://rungameid">~/.local/share/applications</directory>
<directory execFilter="steam://rungameid">~/Desktop</directory>
</rule>
</system>
</ruleList>

Some files were not shown because too many files have changed in this diff Show more