From 7286541822251e001f2a49c1afbb03520c8d9c4b Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sat, 14 Mar 2026 02:15:57 +0200 Subject: [PATCH] feat: implemented a basic store and emulatorjs --- .forgejo/workflows/build.yml | 4 + .github/workflows/build.yml | 1 + .gitignore | 3 +- .vscode/launch.json | 2 +- .vscode/tasks.json | 5 + bun.lock | 327 ++++++++++++- drizzle/0001_outstanding_silk_fever.sql | 27 ++ drizzle/meta/0001_snapshot.json | 451 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 7 +- scripts/dev.ts | 37 +- scripts/generate-es-de-mapping.ts | 109 +++-- src/bun/api/app.ts | 25 +- src/bun/api/auth.ts | 128 +++-- src/bun/api/cache.ts | 34 ++ src/bun/api/emulatorjs/emulatorjs.ts | 46 ++ src/bun/api/games/games.ts | 219 ++++++--- src/bun/api/games/platforms.ts | 68 ++- .../api/games/services/launchGameService.ts | 47 +- src/bun/api/games/services/statusService.ts | 53 +- src/bun/api/games/services/utils.ts | 133 +++++- src/bun/api/jobs/install-job.ts | 254 +++++++--- src/bun/api/jobs/jobs.ts | 80 ++++ src/bun/api/jobs/login-job.ts | 18 +- src/bun/api/jobs/twitch-login-job.ts | 110 +++++ src/bun/api/jobs/update-store.ts | 76 +++ src/bun/api/rpc.ts | 10 +- src/bun/api/schema/app.ts | 2 +- src/bun/api/schema/cache.ts | 10 + src/bun/api/schema/emulators.ts | 6 +- src/bun/api/settings.ts | 150 ------ src/bun/api/settings/services.ts | 193 ++++++++ src/bun/api/settings/settings.ts | 94 ++++ src/bun/api/store/services/gamesService.ts | 59 +++ src/bun/api/store/store.ts | 201 ++++++++ src/bun/api/system.ts | 7 +- src/bun/api/task-queue.ts | 27 +- src/bun/browser.ts | 40 +- src/bun/index.ts | 26 +- src/bun/server.ts | 33 +- src/bun/types/types.d.ts | 28 +- src/bun/utils.ts | 1 - src/bun/utils/browser-params.ts | 20 +- src/bun/webview/base.ts | 3 +- src/bun/webview/linux.ts | 7 +- src/bun/webview/win32.ts | 7 +- .../components/AnimatedBackground.tsx | 110 +++-- src/mainview/components/AutoFocus.tsx | 9 +- .../{GameCard.tsx => CardElement.tsx} | 30 +- src/mainview/components/CardList.tsx | 11 +- src/mainview/components/Clock.tsx | 14 +- src/mainview/components/CollectionList.tsx | 16 +- src/mainview/components/CollectionsDetail.tsx | 18 +- src/mainview/components/ContextDialog.tsx | 27 +- src/mainview/components/Error.tsx | 33 ++ src/mainview/components/FilePicker.tsx | 36 +- src/mainview/components/Filters.tsx | 44 +- src/mainview/components/FocusDots.tsx | 21 + src/mainview/components/FrontEndGameCard.tsx | 46 ++ src/mainview/components/GameList.tsx | 15 +- src/mainview/components/Header.tsx | 31 +- src/mainview/components/LoadingCardList.tsx | 2 +- src/mainview/components/NotFound.tsx | 31 ++ src/mainview/components/PlatformsList.tsx | 39 +- src/mainview/components/RoundButton.tsx | 39 +- src/mainview/components/Screenshots.tsx | 49 ++ src/mainview/components/ShortcutPrompt.tsx | 2 +- src/mainview/components/Shortcuts.tsx | 2 +- src/mainview/components/options/Button.tsx | 21 +- .../components/options/LocalOption.tsx | 4 +- .../components/options/OptionDropdown.tsx | 15 +- .../components/options/OptionInput.tsx | 22 +- .../components/options/OptionSpace.tsx | 19 +- .../components/options/PathSettingsOption.tsx | 2 +- .../components/options/SettingsAppForm.tsx | 2 +- .../components/options/SettingsOption.tsx | 2 +- .../components/store/EmulatorsSection.tsx | 76 +++ .../components/store/GamesSection.tsx | 49 ++ .../store/MissingEmulatorsSection.tsx | 98 ++++ .../components/store/StatsSection.tsx | 52 ++ .../components/store/StoreEmulatorCard.tsx | 84 ++++ src/mainview/emulatorjs/emulator.ts | 63 +++ src/mainview/emulatorjs/index.html | 20 + src/mainview/emulatorjs/style.css | 22 + src/mainview/emulatorjs/types.d.ts | 62 +++ src/mainview/gen/routeTree.gen.ts | 134 ++++++ src/mainview/gen/static-icon-assets.gen.ts | 2 +- src/mainview/index.css | 150 +++++- src/mainview/index.tsx | 14 +- src/mainview/routes/__root.tsx | 10 +- src/mainview/routes/collection.$id.tsx | 14 +- src/mainview/routes/embedded.$source.$id.tsx | 185 +++++++ src/mainview/routes/game/$source.$id.tsx | 274 ++++++----- src/mainview/routes/index.tsx | 90 ++-- src/mainview/routes/launcher.$source.$id.tsx | 3 +- src/mainview/routes/platform.$source.$id.tsx | 21 +- src/mainview/routes/settings/about.tsx | 107 +++-- src/mainview/routes/settings/accounts.tsx | 144 ++++-- src/mainview/routes/settings/emulators.tsx | 20 +- src/mainview/routes/settings/interface.tsx | 1 + src/mainview/routes/settings/route.tsx | 39 +- .../routes/store/details.emulator.$id.tsx | 198 ++++++++ src/mainview/routes/store/tab/emulators.tsx | 80 ++++ src/mainview/routes/store/tab/games.tsx | 136 ++++++ src/mainview/routes/store/tab/index.tsx | 185 +++++++ src/mainview/routes/store/tab/route.tsx | 156 ++++++ src/mainview/scripts/brandIcons.tsx | 4 + src/mainview/scripts/clientApi.ts | 27 +- src/mainview/scripts/contexts.ts | 34 ++ src/mainview/scripts/gamepads.ts | 20 +- src/mainview/scripts/queries.ts | 49 +- src/mainview/scripts/shortcuts.ts | 8 + src/mainview/scripts/spatialNavigation.ts | 77 ++- src/mainview/scripts/types.ts | 11 + src/mainview/scripts/utils.ts | 210 +++++++- src/mainview/types.d.ts | 8 +- src/shared/constants.ts | 82 +++- src/shared/utils.ts | 25 + tailwind.config.js | 14 +- tsconfig.json | 7 +- vite.config.ts | 20 +- 121 files changed, 5900 insertions(+), 1092 deletions(-) create mode 100644 drizzle/0001_outstanding_silk_fever.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/bun/api/cache.ts create mode 100644 src/bun/api/emulatorjs/emulatorjs.ts create mode 100644 src/bun/api/jobs/jobs.ts create mode 100644 src/bun/api/jobs/twitch-login-job.ts create mode 100644 src/bun/api/jobs/update-store.ts create mode 100644 src/bun/api/schema/cache.ts delete mode 100644 src/bun/api/settings.ts create mode 100644 src/bun/api/settings/services.ts create mode 100644 src/bun/api/settings/settings.ts create mode 100644 src/bun/api/store/services/gamesService.ts create mode 100644 src/bun/api/store/store.ts rename src/mainview/components/{GameCard.tsx => CardElement.tsx} (64%) create mode 100644 src/mainview/components/Error.tsx create mode 100644 src/mainview/components/FocusDots.tsx create mode 100644 src/mainview/components/FrontEndGameCard.tsx create mode 100644 src/mainview/components/NotFound.tsx create mode 100644 src/mainview/components/Screenshots.tsx create mode 100644 src/mainview/components/store/EmulatorsSection.tsx create mode 100644 src/mainview/components/store/GamesSection.tsx create mode 100644 src/mainview/components/store/MissingEmulatorsSection.tsx create mode 100644 src/mainview/components/store/StatsSection.tsx create mode 100644 src/mainview/components/store/StoreEmulatorCard.tsx create mode 100644 src/mainview/emulatorjs/emulator.ts create mode 100644 src/mainview/emulatorjs/index.html create mode 100644 src/mainview/emulatorjs/style.css create mode 100644 src/mainview/emulatorjs/types.d.ts create mode 100644 src/mainview/routes/embedded.$source.$id.tsx create mode 100644 src/mainview/routes/store/details.emulator.$id.tsx create mode 100644 src/mainview/routes/store/tab/emulators.tsx create mode 100644 src/mainview/routes/store/tab/games.tsx create mode 100644 src/mainview/routes/store/tab/index.tsx create mode 100644 src/mainview/routes/store/tab/route.tsx create mode 100644 src/mainview/scripts/brandIcons.tsx create mode 100644 src/mainview/scripts/contexts.ts create mode 100644 src/mainview/scripts/types.ts create mode 100644 src/shared/utils.ts diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index ac6c175..987aaf5 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -4,6 +4,8 @@ on: tags: - "v*.*.*" workflow_dispatch: +env: + TWITCH_CLIENT_ID: ${{ env.TWITCH_CLIENT_ID }} jobs: build: @@ -54,6 +56,8 @@ jobs: - name: Build Canary run: bun run package:Linux + env: + TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} - name: Upload Artifact uses: actions/upload-artifact@v3 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 718b745..84ec3e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,7 @@ jobs: run: bun run package:${{ runner.os }} env: BUILD_DIR: ./build/${{ runner.os }} + TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} - name: Install 7zip (minimal) if: matrix.os == 'ubuntu-latest' diff --git a/.gitignore b/.gitignore index adaaa9e..5d2754a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ artifacts trace downloads .flatpak-builder -gameflow-deck.code-workspace \ No newline at end of file +gameflow-deck.code-workspace +.env.local \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 59a689f..d3f1d32 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "internalConsoleOptions": "neverOpen", "request": "attach", "name": "Attach Bun", - "url": "ws://127.0.0.1:9229/fixed-session", + "url": "ws://127.0.0.1:9228/fixed-session", "localRoot": "${workspaceFolder}", "stopOnEntry": false, }, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e65bd08..bea05da 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -38,6 +38,11 @@ "label": "Start Dev (Hot Reload)", "type": "shell", "command": "bun run dev:hmr", + "options": { + "env": { + "FORCE_BROWSER": "false" + } + }, "isBackground": true, "problemMatcher": [], "presentation": { diff --git a/bun.lock b/bun.lock index d382346..9bf51a2 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@elysiajs/static": "^1.4.7", + "@jimp/wasm-webp": "^1.6.0", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -24,6 +25,7 @@ "systeminformation": "^5.31.1", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", + "ts-igdb-client": "^0.4.2", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", "zod": "^4.3.6", @@ -31,6 +33,7 @@ "devDependencies": { "@ap0nia/eden": "^1.0.0-next.22", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", + "@emulatorjs/emulatorjs": "^4.2.3", "@hey-api/openapi-ts": "^0.91.0", "@noriginmedia/norigin-spatial-navigation": "^2.3.0", "@tailwindcss/vite": "^4.1.18", @@ -140,6 +143,106 @@ "@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="], + "@emulatorjs/core-81": ["@emulatorjs/core-81@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-oPQEqjpR3z7Yedte4u3sOXDZ4NXAykNcbENjYcB+x3QshF8I+3MQCo8kINOT2lsqqgx91WR4kmEaYQqU39YsDA=="], + + "@emulatorjs/core-a5200": ["@emulatorjs/core-a5200@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-/9yS0/MKHp/wO9iuxWfWTGUwiVNKykEOb7fEN5UM9BfIVQ1SAqep4Ji+TigmYW4weH/mASvYzON9ett3dmD6oQ=="], + + "@emulatorjs/core-beetle_vb": ["@emulatorjs/core-beetle_vb@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-/e6qkA/spYw27qDmzxMiMW5ic36N5W1AVU3Qki8BTZGWKXDseZtNuV/zlPts9rwtTuH8k4+fsQLF+dHp905+/Q=="], + + "@emulatorjs/core-cap32": ["@emulatorjs/core-cap32@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-RkEpGVsOo3S0ZEdDzhfZ5qjeYWYEeK6rGjyvJhYVUnmpPGSQFXvPu0POtIKFyTk9XQrlicllep8ZHzEC7Nngsg=="], + + "@emulatorjs/core-crocods": ["@emulatorjs/core-crocods@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-p536fgB5ROd2CjOeDxGxB8YVKrZ1sGS9X0lYBahCmk6unkz4FL8bU20/TQk4XdKMIsSkhiyonvBg8JZZbs5mWg=="], + + "@emulatorjs/core-desmume": ["@emulatorjs/core-desmume@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-3WgGPBWzjZJWPDKVOaK/Jmg34as0nMY9ClfmrGEiMFUfa3tocpQvL9unJ8Oh4ofWEDTf+45bak/PZ7wp2xwekA=="], + + "@emulatorjs/core-desmume2015": ["@emulatorjs/core-desmume2015@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-GpSuu7/q4yIEbBGrZUlJV3CHoH/b+cNs9Xn0PnC2WTkIuQgEcD5FxUFLup+v208tEKB3bPxXeaLX72i13bkspQ=="], + + "@emulatorjs/core-dosbox_pure": ["@emulatorjs/core-dosbox_pure@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-48CT0ztvnh/M+NRLtHS+pSysdnvH+p+6tgMLJU3+jvfPXdX1dlksiq8PvHPwtpuEF3d9mt2yECCBY9Vq6nkgdw=="], + + "@emulatorjs/core-fbalpha2012_cps1": ["@emulatorjs/core-fbalpha2012_cps1@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-GeAEX/mJcWskcMnEZ1OjjefftL2vznmE+Lms19XyRRSwfJ23/wc8p1mt7P82QCR/aB0925gksjJqOX2WBvKV6A=="], + + "@emulatorjs/core-fbalpha2012_cps2": ["@emulatorjs/core-fbalpha2012_cps2@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-RqmLd0J/Gh8dkyjnpGN7YGIRS1fJjtCKqBs8r54Uz35KmHxw4Qs/ghCc836DRXP/IrIYX871jMxVvsOIOdCB/g=="], + + "@emulatorjs/core-fbneo": ["@emulatorjs/core-fbneo@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-54Yx8Kcq8DH42caTakws3pMUZc6UkBNqtZgg7Q04lNmWWc/W0wPGN0gHxG90xmlOFGHHtxp9QnicBb1NXMjGWg=="], + + "@emulatorjs/core-fceumm": ["@emulatorjs/core-fceumm@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-XX9Vv2N/hzp0TstNMCTSppEs+sg+1lpJpPdSDuRqIO/cwdt7dUcF+WjNX1yQJLRbP5+XwcNHZ6K4BKy8CJpndQ=="], + + "@emulatorjs/core-fuse": ["@emulatorjs/core-fuse@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-LIu2Zc9ALYWoLN5iSanA0LJWsDx9w/OeIu6oueVEpKpej064vf2OKIYYlQz9v6CwwmhsnmY0bURbBPS70gkgCQ=="], + + "@emulatorjs/core-gambatte": ["@emulatorjs/core-gambatte@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-Oq3AJL9SKnMsNAmtAP2eN934TQts3NHeaAe0z1BO9Lx/L3xDZKLpxL+XwgcSXIrl4Sx1oXxYEUwD7JjPEQ0DGg=="], + + "@emulatorjs/core-gearcoleco": ["@emulatorjs/core-gearcoleco@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-todwg9FhUzIBe1xkut+HOKmXvwIHgLSNKwERJm2yfJMY7gx/S1MHITHWWUHL3qxSmApacEucr9nZfpuTqVcjpA=="], + + "@emulatorjs/core-genesis_plus_gx": ["@emulatorjs/core-genesis_plus_gx@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-D1e2XA2CPRAjfr0JurpXJgR9dePxl/xHtaE8v1T9BqD9A3DLfobIFWsWU9jCFS4NfUxCvcLdTYdY/We3QzvcTw=="], + + "@emulatorjs/core-handy": ["@emulatorjs/core-handy@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-miv2nSSVIIHFcGeEdeO7BpYKsljL1j+0an4BO7xw44s9iUp2PZnHiY1mHWUIOzf4o22VuiXd/TvHOKUGaEYMNw=="], + + "@emulatorjs/core-mame2003": ["@emulatorjs/core-mame2003@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-ZfcNooiHvkY45ZDgBkWlIFUY2RRQ1U+aesTvmsWUdDJBWMkyIdK0HUQLWUY7kKdz52sjL97cBqG0dL8xM+MAig=="], + + "@emulatorjs/core-mame2003_plus": ["@emulatorjs/core-mame2003_plus@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-z37RxbT4R6dJe4r+/+GGN0gDW7d3K+DACJPowqJDSYO8TQK9RQBWPl0nsgp1aiLYkRL7yspbJn2tu2inBMWZow=="], + + "@emulatorjs/core-mednafen_ngp": ["@emulatorjs/core-mednafen_ngp@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-q/BhrhIk1+yGQ8zXPHaRXMm7Bqgu+7RkRTwbnUSlH3E/ybj8npA3SOP8tvA8TK2ceZU2F5pWdDAwp3dzR3iikg=="], + + "@emulatorjs/core-mednafen_pce": ["@emulatorjs/core-mednafen_pce@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-PwnQAKKxWT8m8dUscH4VX8jcClvgxgR/NkE6FxNE8qiRDyBH3Jze7+tF9+UdWLoJRBwssR5knd7o/Es9xIlU6A=="], + + "@emulatorjs/core-mednafen_pcfx": ["@emulatorjs/core-mednafen_pcfx@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-o7AfEktQ7xjH8Dj6Np9J4EyGfXgHCcsKqc32vCL6Wts1luCsSApMR+ZcGX5n0u231GtZMU3UJaesNKmSocFXLw=="], + + "@emulatorjs/core-mednafen_psx_hw": ["@emulatorjs/core-mednafen_psx_hw@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-SzNrmaqMqBknf/0hTvPg3pIACQeLb6PVSfaJKjYI4i6u0mpAhgsIW4PJiLOHn5RfLtTpy+eIWQJvC1DQGApfeQ=="], + + "@emulatorjs/core-mednafen_wswan": ["@emulatorjs/core-mednafen_wswan@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-ePj0OoSTErXS171PCUsiZl3OrfPJVwupvRYFiMSzu6K2UaQjK3ESThWf8Rh1niJt1F1OQyjQENAeElSm0IcojQ=="], + + "@emulatorjs/core-melonds": ["@emulatorjs/core-melonds@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-IjsEBNLPPbqU2GSeWEoEkOgAonK3JEyzvoYR3ucgOXu6InzDNdfuA5kP/mMh3d6DwCUbOwfnqGZuZXMTyNE2wA=="], + + "@emulatorjs/core-mgba": ["@emulatorjs/core-mgba@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-daiHzZQKEr+P9fra7j5YoEAXiyYUEtBhFQ8EAV/SeCtrkvqtayU7GQ9LYgoSgzkKSwsbNSskApqGuA9EGARYPA=="], + + "@emulatorjs/core-mupen64plus_next": ["@emulatorjs/core-mupen64plus_next@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-HnnEXbOEpYxD7f2wUJQLxWUpEB0bT2kC56u5s7PLyj/xNI5ckQKka5+ay1QhU2fOv2BuPRwjjhlzmVJmk5e3hw=="], + + "@emulatorjs/core-nestopia": ["@emulatorjs/core-nestopia@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-Flz0+Sebpn5sgjJvghhQydZ5OM+Jf4jwa+E6tG1TuZGQ4+5sYeJFB3FA5zHAIXZuFBS91cWnIXRbpOUennf8Fw=="], + + "@emulatorjs/core-opera": ["@emulatorjs/core-opera@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-cJSZXUAtHF2DLznDEFfo5Owuc9YQh5pg1qCb1TDhtkDZ4Q4za1dTIM0WZYbARd7IN83OX1UB2wd03BSDmCQAFQ=="], + + "@emulatorjs/core-parallel_n64": ["@emulatorjs/core-parallel_n64@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-leRDMzJ36HdLyJ7gpvocaT2gA8SHiPALhEVlR8tHOpiV2vph9hPpNCGmiJL2CbP2eI5DW6A1PY1zFuIXbJnEJw=="], + + "@emulatorjs/core-pcsx_rearmed": ["@emulatorjs/core-pcsx_rearmed@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-mMbl/NszCryFI15X5QniOqLNL7YGZ6tLP24gr5y1q2ekFOIbtc39BcVTMDEwRndNGovh2nPoCcMjB+W3fgQCog=="], + + "@emulatorjs/core-picodrive": ["@emulatorjs/core-picodrive@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-FUxeHeJIIKi4XG+UdOoCARDjH7hV11XLb6LFUO644F7Ri08veGqMQkOJAoCP8uXGxa/JdgS7SXqc0a40+vLv2w=="], + + "@emulatorjs/core-ppsspp": ["@emulatorjs/core-ppsspp@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-jSCvK+74PYFwpqbEWzAkuDalK1TQXYogVXUxs24wn0SJcMhykQuziNggn4QQgM7+4wy60Jkh0Xb00PM9fLlWvA=="], + + "@emulatorjs/core-prboom": ["@emulatorjs/core-prboom@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-raqb5bZE1xrhnZkwY+NoiGyd9MG9pOlXsl2x0+2499NrEqow1946BB8xxzz1HHkbAkZ9fGxu+Xif2cbm9toVlw=="], + + "@emulatorjs/core-prosystem": ["@emulatorjs/core-prosystem@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-Dd+gkjtXzzO4vNRKh1AtwUL8iKveQgz3Oy+ENx7uUvpUYAoQc/UT5aDfKP5vwgJJOg6ErwDH1ShC0WW100YvZQ=="], + + "@emulatorjs/core-puae": ["@emulatorjs/core-puae@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-at/pvoBtpVsMlUyG3LCFOX6iGP4wQzbSaiATrJe8ydiuJVTH/2Eta6veMX6VUsEKd6ClU8SnTMOjqXn70qIqPg=="], + + "@emulatorjs/core-same_cdi": ["@emulatorjs/core-same_cdi@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-Rh0TgLmpr8MnTyJtIsUYjj8CtAO3hitkZ/iDuvgnCDHSuVZrUo0oA1lnWgbzIPg16ThahB+X1t8kxIOmZapiiw=="], + + "@emulatorjs/core-smsplus": ["@emulatorjs/core-smsplus@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-93G2pY9HJVl5pImoIQbDhtswVkX9aIgrrihdGTFHqurhisYHl74XCpzmCoMzdB5UMx09nFW12S+n+bLTIuiLrA=="], + + "@emulatorjs/core-snes9x": ["@emulatorjs/core-snes9x@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-JTe9Rv9eOuCkgkG4ILwuCZUqNcVI3m9ju1NHTTjridUlFgJSqL4DUqF/vLKN9olCpsqlJFfcCayAOoikJxefwA=="], + + "@emulatorjs/core-stella2014": ["@emulatorjs/core-stella2014@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-bs5az26pMrM6jswhZ5YxBXLpIwclwfdnWVbaEpip7yjJFQHactL7KYH7hVLts0JWDnfEK2lNxh4zlTfI6HAHmQ=="], + + "@emulatorjs/core-vice_x128": ["@emulatorjs/core-vice_x128@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-3wmM8O764qdrWHzpHIcihYEYFH6n/BhzB+6Cng8epa298KObF4uklW3jPG/HJyDjWWJsdTlSgo52h6NQC3j/rw=="], + + "@emulatorjs/core-vice_x64": ["@emulatorjs/core-vice_x64@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-y9ea8mPNS9rWBIBYwBuG/rzDApndW9JOL2Bs3qrBRhUeRPFEkQlGPaXKgOCyaV2Iuhy3Zrb3SmtoG0+hn67vOw=="], + + "@emulatorjs/core-vice_x64sc": ["@emulatorjs/core-vice_x64sc@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-gNnVRa/aXmzMXp1+7WXdlW7nIx21O2lS2j4eFt5dun8U6XnSKsqSpsyguJiotlKxzEGtzuE0BZRdSnh/YJQdFA=="], + + "@emulatorjs/core-vice_xpet": ["@emulatorjs/core-vice_xpet@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-jCGjMuU7CXAlxbGHYgVSGLpBFgCaERrnqYi2krDwX8jkubZjpkgE3gdO6wqQrMm9de6icnyk7fkwsRFTMfjwug=="], + + "@emulatorjs/core-vice_xplus4": ["@emulatorjs/core-vice_xplus4@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-9ZeW5adQpqHbp28Z8dPkM7ouPiSX+qGe3mfyXBj6FhBdNrUPrBvbl+A3tLJVYiPX03d9ZLYcxeVn3i60a0WsgQ=="], + + "@emulatorjs/core-vice_xvic": ["@emulatorjs/core-vice_xvic@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-7YFTY1Wa4cjNTsZl+oHecq+OAHskVPEHd29MEKbYQ9jruMmcEXlchuLjjjiEbUs0YLsOr8Gbg3RPBf/wxVi+fA=="], + + "@emulatorjs/core-virtualjaguar": ["@emulatorjs/core-virtualjaguar@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-aZ5oWVrLrXwyVByK11jgcrcdqKM4MfhDucucuBk4duO2blHB8TS8f1XYUDbz0/N44Wl5+sphc5U5IaiXgvdFQw=="], + + "@emulatorjs/core-yabause": ["@emulatorjs/core-yabause@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-12JwbgwoS1l4+KbsQ0jcIU71jVhn5XgXN80/1Fc8aZE4i4sEQatYKU3dZSMBlIJZaWgRN76PEjQymASMJA9/4w=="], + + "@emulatorjs/cores": ["@emulatorjs/cores@4.2.3", "", { "dependencies": { "@emulatorjs/core-81": "latest", "@emulatorjs/core-a5200": "latest", "@emulatorjs/core-beetle_vb": "latest", "@emulatorjs/core-cap32": "latest", "@emulatorjs/core-crocods": "latest", "@emulatorjs/core-desmume": "latest", "@emulatorjs/core-desmume2015": "latest", "@emulatorjs/core-dosbox_pure": "latest", "@emulatorjs/core-fbalpha2012_cps1": "latest", "@emulatorjs/core-fbalpha2012_cps2": "latest", "@emulatorjs/core-fbneo": "latest", "@emulatorjs/core-fceumm": "latest", "@emulatorjs/core-fuse": "latest", "@emulatorjs/core-gambatte": "latest", "@emulatorjs/core-gearcoleco": "latest", "@emulatorjs/core-genesis_plus_gx": "latest", "@emulatorjs/core-handy": "latest", "@emulatorjs/core-mame2003": "latest", "@emulatorjs/core-mame2003_plus": "latest", "@emulatorjs/core-mednafen_ngp": "latest", "@emulatorjs/core-mednafen_pce": "latest", "@emulatorjs/core-mednafen_pcfx": "latest", "@emulatorjs/core-mednafen_psx_hw": "latest", "@emulatorjs/core-mednafen_wswan": "latest", "@emulatorjs/core-melonds": "latest", "@emulatorjs/core-mgba": "latest", "@emulatorjs/core-mupen64plus_next": "latest", "@emulatorjs/core-nestopia": "latest", "@emulatorjs/core-opera": "latest", "@emulatorjs/core-parallel_n64": "latest", "@emulatorjs/core-pcsx_rearmed": "latest", "@emulatorjs/core-picodrive": "latest", "@emulatorjs/core-ppsspp": "latest", "@emulatorjs/core-prboom": "latest", "@emulatorjs/core-prosystem": "latest", "@emulatorjs/core-puae": "latest", "@emulatorjs/core-same_cdi": "latest", "@emulatorjs/core-smsplus": "latest", "@emulatorjs/core-snes9x": "latest", "@emulatorjs/core-stella2014": "latest", "@emulatorjs/core-vice_x128": "latest", "@emulatorjs/core-vice_x64": "latest", "@emulatorjs/core-vice_x64sc": "latest", "@emulatorjs/core-vice_xpet": "latest", "@emulatorjs/core-vice_xplus4": "latest", "@emulatorjs/core-vice_xvic": "latest", "@emulatorjs/core-virtualjaguar": "latest", "@emulatorjs/core-yabause": "latest", "@emulatorjs/emulatorjs": "latest" } }, "sha512-1JxUjVMvEAM7ijUErkzgRSvWjibpjavaUtLlbqWicac4m6NQjKgi+Oc3Po4dcVH+ZPkS1Jt4LHb1WGpGmSIjwA=="], + + "@emulatorjs/emulatorjs": ["@emulatorjs/emulatorjs@4.2.3", "", { "dependencies": { "@node-minify/clean-css": "^9.0.1", "@node-minify/core": "^9.0.2", "@node-minify/terser": "^9.0.1", "http-server": "^14.1.1", "nipplejs": "^0.10.2", "node-7z": "^3.0.0", "node-fetch": "^3.3.2", "socket.io": "^4.8.1" }, "optionalDependencies": { "@emulatorjs/cores": "latest" } }, "sha512-7z3qaA4LwyurhuGvdMUDF9xJpEbxC3SNy9+E9tSaOsRo8FCS2QXam/0k/lc9kqHWRFIlLKWahNjPAStyL0rFnw=="], + "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], "@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=="], @@ -214,6 +317,8 @@ "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], @@ -270,6 +375,8 @@ "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + "@jimp/wasm-webp": ["@jimp/wasm-webp@1.6.0", "", { "dependencies": { "@jsquash/webp": "^1.4.0", "zod": "^3.23.8" } }, "sha512-P0zUpK6n2XIAn8bt0F6rhSn1+FgteBTrL+TBb6Oqw8v5qEDJoNYkd6LlfZYN8YwtRBTBdZ8GFnWsg2Sar+qOkA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -284,6 +391,16 @@ "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@jsquash/webp": ["@jsquash/webp@1.5.0", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw=="], + + "@node-minify/clean-css": ["@node-minify/clean-css@9.0.1", "", { "dependencies": { "@node-minify/utils": "9.0.1", "clean-css": "5.3.3" } }, "sha512-GHTMmjGloRvNzqdG7foI0iZeS2QmuYCQvdASJP9sCKjkpH45bygODpXPYKnlzUEpQgYvPK9Q3GxqYnVY9SdoqA=="], + + "@node-minify/core": ["@node-minify/core@9.0.2", "", { "dependencies": { "@node-minify/utils": "9.0.1", "glob": "10.3.3", "mkdirp": "3.0.1" } }, "sha512-FNhv29Wom6wKrrFKaeAfmZqz7TX5A1E6P+bpd0VIc+DYWMLUIhAViS8riaZg3A1oD0s06s+5BG2Fg7RqMKiKHw=="], + + "@node-minify/terser": ["@node-minify/terser@9.0.1", "", { "dependencies": { "@node-minify/utils": "9.0.1", "terser": "5.36.0" } }, "sha512-WF78Ex+/xNZZDYvzwB7+sLUYQbJzyyS36ZjMhVqhORejHOsoOcTjQ9TdOgKZoY7wlfxjnLxeMKkO8R/R1KL9aQ=="], + + "@node-minify/utils": ["@node-minify/utils@9.0.1", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-aC1+mhKTP3IMa2VcuGl3ui92LO/7CPQWldNGzu3BVGKiMNJ70AKJW/R6huuYCSuQyHDGM9oFwiVClsZnFxn67g=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -322,6 +439,8 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="], @@ -376,6 +495,8 @@ "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -468,6 +589,8 @@ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="], @@ -494,6 +617,8 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "add-stream": ["add-stream@1.0.0", "", {}, "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ=="], @@ -526,10 +651,16 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "atomically": ["atomically@2.1.0", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q=="], "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], @@ -538,8 +669,12 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ=="], + "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], + "binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -566,6 +701,10 @@ "c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], "camelcase-keys": ["camelcase-keys@6.2.2", "", { "dependencies": { "camelcase": "^5.3.1", "map-obj": "^4.0.0", "quick-lru": "^4.0.1" } }, "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg=="], @@ -586,6 +725,8 @@ "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -598,6 +739,8 @@ "colorjs.io": ["colorjs.io@0.5.2", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], @@ -656,6 +799,10 @@ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "corser": ["corser@2.0.1", "", {}, "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ=="], + "cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -674,6 +821,8 @@ "dargs": ["dargs@7.0.0", "", {}, "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "dateformat": ["dateformat@3.0.3", "", {}, "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q=="], "debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="], @@ -692,6 +841,8 @@ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], @@ -722,6 +873,12 @@ "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=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "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=="], @@ -732,6 +889,10 @@ "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + "engine.io": ["engine.io@6.6.5", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3" } }, "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -740,6 +901,14 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "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=="], @@ -752,6 +921,8 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="], @@ -772,6 +943,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="], "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], @@ -780,6 +953,14 @@ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -792,8 +973,12 @@ "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-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-pkg-repo": ["get-pkg-repo@4.2.1", "", { "dependencies": { "@hutson/parse-repository-url": "^3.0.0", "hosted-git-info": "^4.0.0", "through2": "^2.0.0", "yargs": "^16.2.0" }, "bin": { "get-pkg-repo": "src/cli.js" } }, "sha512-2+QbHjFRfGB74v/pYWjd5OhU3TDIC2Gv/YKUTk/tCvAz0pkn/Mz6P3uByuBimLOcPvN2jYdScl3xGFSrx0jEcA=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], @@ -808,28 +993,44 @@ "gitconfiglocal": ["gitconfiglocal@1.0.0", "", { "dependencies": { "ini": "^1.3.2" } }, "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ=="], + "glob": ["glob@10.3.3", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.0.3", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": { "glob": "dist/cjs/src/bin.js" } }, "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw=="], + "glob-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=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], + "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], "hard-rejection": ["hard-rejection@2.1.0", "", {}, "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + "html-encoding-sniffer": ["html-encoding-sniffer@3.0.0", "", { "dependencies": { "whatwg-encoding": "^2.0.0" } }, "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="], + + "http-server": ["http-server@14.1.1", "", { "dependencies": { "basic-auth": "^2.0.1", "chalk": "^4.1.2", "corser": "^2.0.1", "he": "^1.2.0", "html-encoding-sniffer": "^3.0.0", "http-proxy": "^1.18.1", "mime": "^1.6.0", "minimist": "^1.2.6", "opener": "^1.5.1", "portfinder": "^1.0.28", "secure-compare": "3.0.1", "union": "~0.5.0", "url-join": "^4.0.1" }, "bin": { "http-server": "bin/http-server" } }, "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A=="], + "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=="], @@ -878,6 +1079,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jackspeak": ["jackspeak@2.3.6", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ=="], + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -944,8 +1147,18 @@ "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.defaultsdeep": ["lodash.defaultsdeep@4.6.1", "", {}, "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA=="], + + "lodash.defaultto": ["lodash.defaultto@4.14.0", "", {}, "sha512-G6tizqH6rg4P5j32Wy4Z3ZIip7OfG8YWWlPFzUFGcYStH1Ld0l1tWs6NevEQNEDnO1M3NZYjuHuraaFSN5WqeQ=="], + + "lodash.flattendeep": ["lodash.flattendeep@4.4.0", "", {}, "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ=="], + + "lodash.isempty": ["lodash.isempty@4.4.0", "", {}, "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg=="], + "lodash.ismatch": ["lodash.ismatch@4.4.0", "", {}, "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g=="], + "lodash.negate": ["lodash.negate@3.0.2", "", {}, "sha512-JGJYYVslKYC0tRMm/7igfdHulCjoXjoganRNWM8AgS+RXfOvFnPkOveDhPI65F9aAypCX9QEEQoBqWf7Q6uAeA=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -956,6 +1169,8 @@ "map-obj": ["map-obj@4.3.0", "", {}, "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], @@ -966,7 +1181,11 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -978,6 +1197,8 @@ "minimist-options": ["minimist-options@4.1.0", "", { "dependencies": { "arrify": "^1.0.1", "is-plain-obj": "^1.1.0", "kind-of": "^6.0.3" } }, "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "modify-values": ["modify-values@1.0.1", "", {}, "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw=="], @@ -986,14 +1207,24 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "nipplejs": ["nipplejs@0.10.2", "", {}, "sha512-XGxFY8C2DOtobf1fK+MXINTzkkXJLjZDDpfQhOUZf4TSytbc9s4bmA0lB9eKKM8iDivdr9NQkO7DpIQfsST+9g=="], + + "node-7z": ["node-7z@3.0.0", "", { "dependencies": { "debug": "^4.3.2", "lodash.defaultsdeep": "^4.6.1", "lodash.defaultto": "^4.14.0", "lodash.flattendeep": "^4.4.0", "lodash.isempty": "^4.4.0", "lodash.negate": "^3.0.2", "normalize-path": "^3.0.0" } }, "sha512-KIznWSxIkOYO/vOgKQfJEaXd7rgoFYKZbaurainCEdMhYc7V7mRHX+qdf2HgbpQFcdJL/Q6/XOPrDLoBeTfuZA=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], "node-disk-info": ["node-disk-info@1.3.0", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-NEx858vJZ0AoBtmD/ChBIHLjFTF28xCsDIgmFl4jtGKsvlUx9DU/OrMDjvj3qp/E4hzLN0HvTg7eJx5XFQvbeg=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + "node-downloader-helper": ["node-downloader-helper@2.1.10", "", { "bin": { "ndh": "bin/ndh" } }, "sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg=="], + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-html-parser": ["node-html-parser@7.0.2", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ=="], @@ -1014,6 +1245,8 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], @@ -1022,6 +1255,8 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "opener": ["opener@1.5.2", "", { "bin": { "opener": "bin/opener-bin.js" } }, "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -1050,6 +1285,8 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "path-type": ["path-type@3.0.0", "", { "dependencies": { "pify": "^3.0.0" } }, "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1070,6 +1307,8 @@ "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "portfinder": ["portfinder@1.0.38", "", { "dependencies": { "async": "^3.2.6", "debug": "^4.3.6" } }, "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], @@ -1090,10 +1329,14 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="], "qr.js": ["qr.js@0.0.0", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="], + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="], @@ -1132,6 +1375,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], @@ -1146,7 +1391,7 @@ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -1194,6 +1439,8 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "secure-compare": ["secure-compare@3.0.1", "", {}, "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "seroval": ["seroval@1.4.2", "", {}, "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ=="], @@ -1206,8 +1453,24 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + "socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="], + + "socket.io-adapter": ["socket.io-adapter@2.5.6", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" } }, "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ=="], + + "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1230,12 +1493,16 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "stringify-package": ["stringify-package@1.0.1", "", {}, "sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], @@ -1304,6 +1571,10 @@ "trim-newlines": ["trim-newlines@3.0.1", "", {}, "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw=="], + "ts-apicalypse": ["ts-apicalypse@0.4.2", "", { "dependencies": { "axios": "^1.4.0" } }, "sha512-A02KFDFZHYTft0fTkxr5AERLb//XK/qjvGxrX8uNCwZAvFpLI/4TrOVxbq6kV2caZXWAn8eZZfRzVn1xd/cSMw=="], + + "ts-igdb-client": ["ts-igdb-client@0.4.2", "", { "dependencies": { "axios": "^1.4.0", "ts-apicalypse": "^0.4.2" } }, "sha512-VGQbIyy75GbHW0WGGHBXsdJzuj8wOW/jiWURmQWltpkFjCTMSqqx9gTPSUUcJ0W2kdlWcyMU4TDDHo4N3Fij7A=="], + "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=="], @@ -1324,6 +1595,8 @@ "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "union": ["union@0.5.0", "", { "dependencies": { "qs": "^6.4.0" } }, "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], @@ -1332,6 +1605,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "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=="], @@ -1344,6 +1619,8 @@ "varint": ["varint@6.0.0", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "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=="], @@ -1352,6 +1629,10 @@ "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=="], + "wasm-feature-detect": ["wasm-feature-detect@1.8.0", "", {}, "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "webview-bun": ["webview-bun@2.4.0", "", {}, "sha512-0+ugnQlcUHmuW+iLeb+Lzb8rGUJh7WEdXvNsuvaVEXT3EagK380XdD7heVJu0Ek/mNxMY3G2JM142YRQ1hDUGQ=="], @@ -1368,6 +1649,10 @@ "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], @@ -1396,8 +1681,16 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@jimp/core/file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1430,6 +1723,12 @@ "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@jimp/wasm-webp/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@node-minify/core/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + + "@node-minify/terser/terser": ["terser@5.36.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w=="], + "@tailwindcss/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=="], @@ -1456,6 +1755,8 @@ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "compare-func/dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "conventional-changelog-writer/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1470,16 +1771,22 @@ "elysia/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "get-pkg-repo/through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="], "get-pkg-repo/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], "git-semver-tags/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "handlebars/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "html-encoding-sniffer/whatwg-encoding": ["whatwg-encoding@2.0.0", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg=="], + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], @@ -1498,6 +1805,8 @@ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "path-type/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], @@ -1520,6 +1829,8 @@ "standard-version/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -1574,10 +1885,18 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@jimp/core/file-type/strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], "@jimp/core/file-type/token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "@node-minify/terser/terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], @@ -1590,6 +1909,8 @@ "get-pkg-repo/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "meow/read-pkg-up/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -1722,8 +2043,6 @@ "dotgitignore/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - "get-pkg-repo/through2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - "get-pkg-repo/through2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "meow/read-pkg-up/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], diff --git a/drizzle/0001_outstanding_silk_fever.sql b/drizzle/0001_outstanding_silk_fever.sql new file mode 100644 index 0000000..21aec17 --- /dev/null +++ b/drizzle/0001_outstanding_silk_fever.sql @@ -0,0 +1,27 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_games` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `source_id` text, + `source` text, + `igdb_id` integer, + `name` text, + `ra_id` integer, + `path_fs` text, + `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 +INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary" FROM `games`;--> statement-breakpoint +DROP TABLE `games`;--> statement-breakpoint +ALTER TABLE `__new_games` RENAME TO `games`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `games_igdb_id_unique` ON `games` (`igdb_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `games_ra_id_unique` ON `games` (`ra_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `games_slug_unique` ON `games` (`slug`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..c922871 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,451 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6dc00b41-64d7-4cb3-ad90-f9e8e35af643", + "prevId": "673fe5dc-58a5-495b-8fb1-104e7945e90b", + "tables": { + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections_games": { + "name": "collections_games", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "collections_games_collection_id_collections_id_fk": { + "name": "collections_games_collection_id_collections_id_fk", + "tableFrom": "collections_games", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "collections_games_game_id_games_id_fk": { + "name": "collections_games_game_id_games_id_fk", + "tableFrom": "collections_games", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "games": { + "name": "games", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ra_id": { + "name": "ra_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path_fs": { + "name": "path_fs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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_igdb_id_unique": { + "name": "games_igdb_id_unique", + "columns": [ + "igdb_id" + ], + "isUnique": true + }, + "games_ra_id_unique": { + "name": "games_ra_id_unique", + "columns": [ + "ra_id" + ], + "isUnique": true + }, + "games_slug_unique": { + "name": "games_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "games_platform_id_platforms_id_fk": { + "name": "games_platform_id_platforms_id_fk", + "tableFrom": "games", + "tableTo": "platforms", + "columnsFrom": [ + "platform_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "platforms": { + "name": "platforms", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_slug": { + "name": "igdb_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moby_id": { + "name": "moby_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "es_slug": { + "name": "es_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ra_id": { + "name": "ra_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover": { + "name": "cover", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "platforms_igdb_id_unique": { + "name": "platforms_igdb_id_unique", + "columns": [ + "igdb_id" + ], + "isUnique": true + }, + "platforms_igdb_slug_unique": { + "name": "platforms_igdb_slug_unique", + "columns": [ + "igdb_slug" + ], + "isUnique": true + }, + "platforms_moby_id_unique": { + "name": "platforms_moby_id_unique", + "columns": [ + "moby_id" + ], + "isUnique": true + }, + "platforms_es_slug_unique": { + "name": "platforms_es_slug_unique", + "columns": [ + "es_slug" + ], + "isUnique": true + }, + "platforms_ra_id_unique": { + "name": "platforms_ra_id_unique", + "columns": [ + "ra_id" + ], + "isUnique": true + }, + "platforms_slug_unique": { + "name": "platforms_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "screenshots": { + "name": "screenshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "screenshots_game_id_games_id_fk": { + "name": "screenshots_game_id_games_id_fk", + "tableFrom": "screenshots", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2402c14..c181729 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1771508990238, "tag": "0000_pretty_harry_osborn", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1772998956867, + "tag": "0001_outstanding_silk_fever", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index ed7c71d..f225b1d 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "packageManager": "bun@1.3.9", "type": "module", "scripts": { - "dev": "NODE_ENV=development bun run build:vite && WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS='--remote-debugging-port=9222' bun run ./scripts/dev.ts", - "dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run dev'", + "dev": "NODE_ENV=development bun run build:vite && bun run ./scripts/dev.ts", + "dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'", "build:vite": "vite build", "build:prod:vite": "NODE_ENV=production bun run build:vite", "build:dev:vite": "NODE_ENV=development bun run build:vite", @@ -44,6 +44,7 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@elysiajs/static": "^1.4.7", + "@jimp/wasm-webp": "^1.6.0", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -59,6 +60,7 @@ "systeminformation": "^5.31.1", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", + "ts-igdb-client": "^0.4.2", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", "zod": "^4.3.6" @@ -66,6 +68,7 @@ "devDependencies": { "@ap0nia/eden": "^1.0.0-next.22", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", + "@emulatorjs/emulatorjs": "^4.2.3", "@hey-api/openapi-ts": "^0.91.0", "@noriginmedia/norigin-spatial-navigation": "^2.3.0", "@tailwindcss/vite": "^4.1.18", diff --git a/scripts/dev.ts b/scripts/dev.ts index c693d75..96ac9f2 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -1,19 +1,28 @@ // watcher.ts - run this instead of --watch import EventEmitter from "events"; -import { watch } from "fs"; import browser from '../src/bun/browser'; +import { tmpdir } from "os"; +import path from "path"; const events = new EventEmitter(); +const abortController = new AbortController(); + +process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222"; +process.env.NODE_ENV = "development"; + +let retries = 0; function spawnServer () { - return Bun.spawn(["bun", "run", "--inspect=127.0.0.1:9229/fixed-session", '--watch', "./src/bun/index.ts"], { + return Bun.spawn(["bun", "run", '--watch', "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], { env: { ...Bun.env, HEADLESS: "true", }, stdout: "inherit", stderr: "inherit", - stdin: "inherit", + stdin: "pipe", + signal: abortController.signal, + killSignal: 'SIGUSR1', ipc (message, subprocess, handle) { if (message.type === 'exitapp') @@ -23,7 +32,15 @@ function spawnServer () }, onExit (subprocess, exitCode, signalCode) { - process.exit(); + if (exitCode === 1 && retries <= 3) + { + server = spawnServer(); + retries++; + } else + { + process.exit(); + } + } }); } @@ -32,12 +49,18 @@ function spawnBrowser () { try { - return browser(events, !!Bun.env.FORCE_BROWSER); + + return browser(events, Bun.env.FORCE_BROWSER === "true", { configPath: path.join(tmpdir(), 'gameflow') }); } catch (error) { console.error(error); }; } -const server = spawnServer(); -spawnBrowser()?.then(e => server.send({ type: 'exitapp' })); \ No newline at end of file +let server = spawnServer(); +spawnBrowser()?.then(async e => +{ + console.log("Sending exit Signal to server"); + await server.stdin.write('shutdown\n'); + await server.stdin.flush(); +}); \ No newline at end of file diff --git a/scripts/generate-es-de-mapping.ts b/scripts/generate-es-de-mapping.ts index 559f6d0..3c0aa10 100644 --- a/scripts/generate-es-de-mapping.ts +++ b/scripts/generate-es-de-mapping.ts @@ -6,6 +6,8 @@ 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"; +import path from 'node:path'; +import { ensureDir } from 'fs-extra'; /** get all latest supported romm platforms */ const rommPlatforms = await getSupportedPlatformsEndpointApiPlatformsSupportedGet({ baseUrl: "https://demo.romm.app" }); @@ -41,10 +43,10 @@ await Promise.all(platforms.map(async ([platform, arch]) => 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 systemsXml = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_systems.xml`).arrayBuffer(); + const $s = cheerio.load(Buffer.from(systemsXml)); + const rulesXml = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_find_rules.xml`).arrayBuffer(); + const $r = cheerio.load(Buffer.from(rulesXml)); const sqlitePath = `./vendors/es-de/emulators.${platform}.${arch}.sqlite`; const sqlite = new Database(sqlitePath, { create: true, readwrite: true }); @@ -52,7 +54,7 @@ await Promise.all(platforms.map(async ([platform, arch]) => 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 emulators = $r('ruleList emulator').toArray().map(s => { const $emulator = $r(s); const $systempath = $emulator.find('rule[type=systempath] entry'); @@ -71,13 +73,27 @@ await Promise.all(platforms.map(async ([platform, arch]) => winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()), }; return emulator; - })); + }); + + await db.insert(schema.emulators).values(emulators); /** Save the systems like ps2 or psp */ - await Promise.all($s(`systemList system`).toArray().map(async s => + const systems = 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 commands = $s(s).find("command").toArray().map(c => + { + const command: typeof schema.commands.$inferInsert = { + label: $s(c).attr('label'), + command: $s(c).text(), + system: name + }; + + return command; + }); + const rommMapping = rommPlatforms.data?.find(p => p.slug === (customMappings as any)[name] || p.slug === name || @@ -87,54 +103,63 @@ await Promise.all(platforms.map(async ([platform, arch]) => p.display_name === fullname ); - const system: typeof schema.systems.$inferInsert = { + const mappings: { + source: string; + sourceId: number | null; + sourceSlug: string | null; + system: string; + }[] = []; + + 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'] + ]; + + mappings.push(...sources.map(([source, sourceId, sourceSlug]) => ({ + source, + sourceId: sourceId ? rommMapping[sourceId] as number : null, + sourceSlug: sourceSlug ? rommMapping[sourceSlug] as string : null, + system: name + })) + .filter(m => m.sourceId !== null || m.sourceSlug !== null)); + } + + const system = { name, fullname, path: $s(s).find("path").text(), - extension: $s(s).find("extension").text().replaceAll('.', '').split(' ') + extension: $s(s).find("extension").text().replaceAll('.', '').split(' '), + commands, + mappings }; + return system; + })); + await Promise.all(systems.map(async system => + { /** Store mappings to all other sources for easy reference */ - db.transaction(async (tx) => + await db.transaction(async (tx) => { await tx.insert(schema.systems).values(system); - if (rommMapping) + if (system.mappings.length > 0) { - 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)); + .values(system.mappings); } }); - 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; - })); + await db.insert(schema.commands).values(system.commands); })); })); diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index ef1d20a..ac4e780 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -10,10 +10,10 @@ import Conf from "conf"; import projectPackage from '~/package.json'; import { Notification, 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 * as schema from "@schema/app"; +import cacheSchema from "@schema/cache"; +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"; @@ -21,6 +21,7 @@ import { ErrorLike } from "bun"; import { appPath, getErrorMessage } from "../utils"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { ensureDir } from "fs-extra"; +import UpdateStoreJob from "./jobs/update-store"; export const config = new Conf({ projectName: projectPackage.name, @@ -50,7 +51,10 @@ const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath); export const jar = new CookieJar(fileCookieStore); let sqlite: Database; +export const cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite'); +let cacheSqlite: Database; export let db: DrizzleSqliteDODatabase; +export let cache: DrizzleSqliteDODatabase; await reloadDatabase(); const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true }); export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); @@ -73,6 +77,7 @@ events.addListener('activegameexit', ({ error }) => } }); config.onDidChange('downloadPath', () => reloadDatabase()); +taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); export async function cleanup () { @@ -86,13 +91,25 @@ export async function reloadDatabase () { await ensureDir(config.get('downloadPath')); sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true }); + await ensureDir(path.join(os.tmpdir(), 'gameflow')); + console.log("Loaded Cache from: ", cachePath); + cacheSqlite = new Database(cachePath, { create: true, readwrite: true }); db = drizzle(sqlite, { schema }); + cache = drizzle(cacheSqlite, { schema: cacheSchema }); migrate(db!, { migrationsFolder: appPath("./drizzle") }); + cache.run(` + CREATE TABLE IF NOT EXISTS item_cache ( + key TEXT PRIMARY KEY, + data TEXT NOT NULL, + expire_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); } interface AppEventMap { - activegameexit: [{ source: string, id: number, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }]; + activegameexit: [{ source: string, id: string, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }]; exitapp: []; notification: [Notification]; } \ No newline at end of file diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index 494b67c..501275d 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -1,45 +1,117 @@ import Elysia, { sse, status } from "elysia"; -import { config, jar, taskQueue } from "./app"; +import { config, events, jar, taskQueue } from "./app"; import z from "zod"; import { client } from "@clients/romm/client.gen"; import { loginApiLoginPost, logoutApiLogoutPost } from "@clients/romm"; import secrets from '../api/secrets'; import { LoginJob } from "./jobs/login-job"; +import TwitchLoginJob from "./jobs/twitch-login-job"; export default new Elysia() - .post('/login/remote/start', async () => + .post('/login/twitch', async ({ body: { openInBrowser } }) => + { + if (taskQueue.hasActiveOfType(TwitchLoginJob)) + { + return status("Conflict", `Twitch Authentication already in progress`); + } + + if (!process.env.TWITCH_CLIENT_ID) + { + return status("Not Found", "Twitch Client ID not set"); + } + + return taskQueue.enqueue(TwitchLoginJob.id, new TwitchLoginJob(process.env.TWITCH_CLIENT_ID, openInBrowser ?? false)); + }, + { body: z.object({ openInBrowser: z.boolean().optional() }) }) + .post('/logout/twitch', async () => + { + if (!process.env.TWITCH_CLIENT_ID) + { + return status("Not Found", "Twitch Client ID not set"); + } + + const res = await fetch('https://id.twitch.tv/oauth2/revoke', { + method: "POST", headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams({ + client_id: process.env.TWITCH_CLIENT_ID + }) + }); + + await secrets.delete({ service: 'gamflow_twitch', name: 'access_token' }); + await secrets.delete({ service: 'gamflow_twitch', name: 'refresh_token' }); + await secrets.delete({ service: 'gamflow_twitch', name: 'expires_in' }); + + return status(res.status, res.statusText); + }) + .get('/login/twitch', async () => + { + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + if (!access_token) + { + return status('Not Found', "Not Logged In"); + } + + const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } }); + if (res.ok) + { + return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; + } + + if (!process.env.TWITCH_CLIENT_ID) + { + return status("Not Found", "Twitch Client ID not set"); + } + + const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" }); + if (!refresh_token) + { + return status("Not Found", "Refresh Token Not Found"); + } + + // refresh token + const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', { + method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ + client_id: process.env.TWITCH_CLIENT_ID, + access_token, + grant_type: "refresh_token", + refresh_token + }) + }); + + if (refreshResponse.ok) + { + const data: { + access_token: string, + refresh_token: string, + token_type: string; + expires_in: number; + } = await refreshResponse.json(); + + await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token }); + await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token }); + await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() }); + + events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' }); + + const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } }); + if (res.ok) + { + return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; + } + } + + return status(400, res.statusText); + }) + .post('/login/romm', async () => { if (taskQueue.hasActiveOfType(LoginJob)) { return status("Conflict", "Login Already Active"); } - const job = new LoginJob(); - taskQueue.enqueue("login", job); - return status("OK"); - }) - .get('/login/remote/status', async function* () - { - const job = taskQueue.findJob("login"); - if (job) - { - const loginJob = job.job as LoginJob; - yield sse({ data: { endsAt: loginJob.endsAt, url: loginJob.url } }); - await taskQueue.waitForJob('login'); - yield sse({ data: {} }); - } - - yield sse({ data: {} }); - }) - .post('/login/remote/cancel', async () => - { - const job = taskQueue.findJob("login"); - if (job) - { - job.abort("cancel"); - await taskQueue.waitForJob('login'); - } - return {}; + return taskQueue.enqueue(LoginJob.id, new LoginJob()); }) .post('/login', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) }) .get('/login', async () => diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts new file mode 100644 index 0000000..c8c4a8f --- /dev/null +++ b/src/bun/api/cache.ts @@ -0,0 +1,34 @@ +import { eq } from "drizzle-orm"; +import { cache } from "./app"; +import cacheSchema from "@schema/cache"; + +export const CACHE_KEYS = { + ROM_PLATFORMS: 'rom-platforms', + STORE_GAME: (path: string) => `store-game-${path}`, + STORE_GAME_MANIFEST: 'store-game-manifest' +} as const; + +export async function getOrCached (key: string, getter: () => Promise, options?: { expireMs?: number; }): Promise +{ + const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) }); + const updated_at = new Date(); + + if (cached && cached.expire_at > updated_at) + { + return cached.data as T; + } + + const data = await getter(); + + const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000); + + await cache.insert(cacheSchema.item_cache) + .values({ key, data, updated_at, expire_at }) + .onConflictDoUpdate({ + target: cacheSchema.item_cache.key, + set: { data, updated_at, expire_at } + }) + .run(); + + return data; +} \ No newline at end of file diff --git a/src/bun/api/emulatorjs/emulatorjs.ts b/src/bun/api/emulatorjs/emulatorjs.ts new file mode 100644 index 0000000..c8018de --- /dev/null +++ b/src/bun/api/emulatorjs/emulatorjs.ts @@ -0,0 +1,46 @@ +// ES-DE to emulator JS mapping +// TODO: use the retroarch cores based on ES-DE +export const cores: Record = { + "atari5200": "atari5200", + "virtualboy": "vb", + "nds": "nds", + "arcade": "arcade", + "nes": "nes", + "gb": "gb", + "gbc": "gb", + "colecovision": "coleco", + "mastersystem": "segaMS", + "megadrive": "segaMD", + "gamegear": "segaGG", + "segacd": "segaCD", + "sega32x": "sega32x", + "genesis": "sega", + "mark3": "sega", + "megacd": "sega", + "megacdjp": "sega", + "megadrivejp": "sega", + "sg-1000": "sega", + "atarilynx": "lynx", + "mame": "mame", + "ngp": "ngp", + "supergrafx": "pce", + "pcfx": "pcfx", + "psx": "psx", + "wonderswan": "ws", + "gba": "gba", + "n64": "n64", + "3do": "3do", + "psp": "psp", + "atari7800": "atari7800", + "snes": "snes", + "atari2600": "atari2600", + "atarijaguar": "jaguar", + "saturn": "segaSaturn", + "amiga": "amiga", + "c64": "c64", + "c128": "c128", + "pet": "pet", + "plus4": "plus4", + "vic20": "vic20", + "dos": "dos" +}; \ No newline at end of file diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 6196cc2..d9fe0ce 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,23 +1,32 @@ import Elysia, { status } from "elysia"; -import { config, db, taskQueue } from "../app"; +import { activeGame, config, db, events, taskQueue } from "../app"; import { and, eq, getTableColumns, sql } from "drizzle-orm"; import z from "zod"; -import * as schema from "../schema/app"; +import * as schema from "@schema/app"; import fs from "node:fs/promises"; import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } 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 { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameMatch } from "./services/utils"; import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; import { launchCommand } from "./services/launchGameService"; import { getErrorMessage } from "@/bun/utils"; -import { Jimp } from 'jimp'; +import { defaultFormats, defaultPlugins } from 'jimp'; +import { createJimp } from "@jimp/core"; +import webp from "@jimp/wasm-webp"; +import { extractStoreGameSourceId, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService"; -async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height }: { blur?: number, width?: number, height?: number; }) +// A custom jimp that supports webp +const Jimp = createJimp({ + formats: [...defaultFormats, webp], + plugins: defaultPlugins, +}); + +async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; }) { - if (blur) + if (blur && !noBlur) { const jimp = await Jimp.read(img); if (width) @@ -48,6 +57,8 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, export default new Elysia() .get('/game/local/:id/cover', async ({ params: { id }, query, set }) => { + set.headers["cross-origin-resource-policy"] = 'cross-origin'; + const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) }); if (!coverBlob || !coverBlob.cover) { @@ -71,7 +82,7 @@ export default new Elysia() return processImage(`${rommAdress}/${path}`, query); } return status('Not Found'); - }, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) }) + }, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional(), noBlur: z.coerce.boolean().optional() }) }) .get('/image', async ({ query }) => { return processImage(query.url, query); @@ -106,18 +117,24 @@ export default new Elysia() }, { params: z.object({ id: z.number() }), response: z.object({ installed: z.boolean() }) - }).get('/games', async ({ query: { platform_source, platform_slug, platform_id, collection_id } }) => + }) + .get('/games', async ({ query, set }) => { const where: any[] = []; - if (platform_slug) + if (query.platform_slug) { - where.push(eq(schema.platforms.slug, platform_slug)); + where.push(eq(schema.platforms.slug, query.platform_slug)); + } + + if (query.source) + { + where.push(eq(schema.games.source, query.source)); } const games: FrontEndGameType[] = []; - let localGamesSet: Set | undefined; + let localGamesSet: Set | undefined; - if (!collection_id) + if (!query.collection_id) { const localGames = await db.select({ ...getTableColumns(schema.games), @@ -128,45 +145,87 @@ export default new Elysia() .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) .groupBy(schema.games.id) - + .offset(query.offset ?? 0) + .limit(query.limit ?? 50) .where(and(...where)); - localGamesSet = new Set(localGames.filter(g => !!g.source_id).map(g => g.source_id!)); + localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)); games.push(...localGames.map(g => { - const game: FrontEndGameType = { - platform_display_name: g.platform?.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`, - paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [], - path_fs: g.path_fs, - last_played: g.last_played, - slug: g.slug, - name: g.name, - platform_id: g.platform_id - }; - return game; + return convertLocalToFrontend(g); })); } - if ((!platform_source || platform_source === 'romm') || !!collection_id) + if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { - 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 => + const rommGames = await getRomsApiRomsGet({ + query: { + platform_ids: query.platform_id ? [query.platform_id] : undefined, + collection_id: query.collection_id, + limit: query.limit, + offset: query.offset + }, throwOnError: true + }); + games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g => { return convertRomToFrontend(g); })); } + if (query.source === 'store') + { + const gamesManifest = await getStoreGameManifest(); + set.headers['x-max-items'] = gamesManifest.filter(g => g.type === 'blob').length; + + const storeGames = await Promise.all(gamesManifest + .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), gamesManifest.length)) + .map(async (e) => + { + const system = path.dirname(e.path); + const id = path.basename(e.path, path.extname(e.path)); + + const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) }); + + if (localGame) + { + return undefined; + } + + const storeGame = await getStoreGameFromPath(e.path); + + return convertStoreToFrontend(system, id, storeGame); + })); + games.push(...storeGames.filter(g => g !== undefined)); + } return { games }; }, { query: GameListFilterSchema, }) + .get('/rom/:source/:id', async ({ params: { id, source } }) => + { + const localGame = await db.query.games.findFirst({ + where: getLocalGameMatch(id, source), + columns: { path_fs: true } + }); + + if (!localGame?.path_fs) + { + return status("Not Found"); + } + + const downloadPath = config.get('downloadPath'); + const path_fs = path.join(downloadPath, localGame.path_fs); + const stats = await fs.stat(path_fs); + if (stats.isDirectory()) + { + return status("Not Found", "Rom is a folder"); + } + + return Bun.file(path_fs); + }, { + params: z.object({ source: z.string(), id: z.string() }) + }) .get('/game/:source/:id', async ({ params: { source, id } }) => { async function getLocalGameDetailed (match: any) @@ -175,7 +234,7 @@ export default new Elysia() where: match, with: { screenshots: { columns: { id: true } }, - platform: { columns: { name: true } } + platform: { columns: { name: true, slug: true } } } }); if (localGame) @@ -185,7 +244,7 @@ export default new Elysia() const game: FrontEndGameTypeDetailed = { path_cover: `/api/romm/game/local/${localGame.id}/cover`, updated_at: localGame.created_at, - id: { id: localGame.id, source: 'local' }, + id: { id: String(localGame.id), source: 'local' }, path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`, fs_size_bytes: fileSize ?? null, paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`), @@ -199,7 +258,8 @@ export default new Elysia() last_played: localGame.last_played, slug: localGame.slug, name: localGame.name, - platform_id: localGame.platform_id + platform_id: localGame.platform_id, + platform_slug: localGame.platform.slug }; return game; } @@ -209,7 +269,7 @@ export default new Elysia() if (source === 'local') { - const localGame = await getLocalGameDetailed(eq(schema.games.id, id)); + const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id))); if (localGame) return localGame; return status('Not Found'); } @@ -218,18 +278,30 @@ export default new Elysia() const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); if (localGame) return localGame; - const rom = await getRomApiRomsIdGet({ path: { id } }); - if (rom.data) + if (source === 'romm') { - const romGame = convertRomToFrontendDetailed(rom.data); - return romGame; + const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } }); + if (rom.data) + { + const romGame = convertRomToFrontendDetailed(rom.data); + return romGame; + } + + return status("Not Found", rom.response); + } + else if (source === 'store') + { + const gameId = extractStoreGameSourceId(id); + const storeGame = await getStoreGame(gameId.system, gameId.id); + if (!storeGame) return status("Not Found"); + return convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame); } - return status("Not Found", rom.response); + return status("Not Found"); } }, { - params: z.object({ source: z.string(), id: z.coerce.number() }) + params: z.object({ source: z.string(), id: z.string() }) }) .get('/status/:source/:id', async ({ params: { source, id }, set }) => { @@ -239,7 +311,7 @@ export default new Elysia() return buildStatusResponse(source, id); }, { response: z.any(), - params: z.object({ id: z.coerce.number(), source: z.string() }), + params: z.object({ id: z.string(), source: z.string() }), query: z.object({ isLocal: z.boolean().optional() }) }) .delete('/game/:source/:id', async ({ params: { source, id } }) => @@ -253,36 +325,51 @@ export default new Elysia() return status(deleted.length > 0 ? 'OK' : 'Not Modified'); }, { - params: z.object({ id: z.coerce.number(), source: z.string() }), + params: z.object({ id: z.string(), source: z.string() }), }) .post('/game/:source/:id/install', async ({ params: { id, source } }) => { if (!taskQueue.hasActive()) { - taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id)); - return status(200); + if (source === 'romm' || source === 'store') + { + taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id)); + return status(200); + } + + return status('Not Implemented'); } else { return status('Not Implemented'); } }, { - params: z.object({ id: z.coerce.number(), source: z.string() }), + params: z.object({ id: z.string(), source: z.string() }), response: z.any() }) - .post('/game/:source/:id/play', async ({ params: { id, source }, set }) => + .post('/game/:source/:id/play', async ({ params: { id, source }, query, set }) => { - const validCommand = await getValidLaunchCommandsForGame(source, id); - if (validCommand) + const validCommands = await getValidLaunchCommandsForGame(source, id); + if (validCommands) { - if (validCommand instanceof Error) + if (validCommands instanceof Error) { - return errorToResponse(validCommand, set); + return errorToResponse(validCommands, set); } else { try { - await launchCommand(validCommand.command.command, source, id, validCommand.gameId); + const validCommand = query.command_id ? validCommands.commands.find(c => c.id === query.command_id) : validCommands.commands[0]; + if (validCommand) + { + // launch command waits for the game to exit, we don't want that. + launchCommand(validCommand.command, source, id, validCommands.gameId); + return { type: 'application', command: null }; + } else + { + return status("Not Found"); + } + } catch (error) { console.error(error); @@ -291,5 +378,27 @@ export default new Elysia() } } }, { - params: z.object({ id: z.coerce.number(), source: z.string() }), + params: z.object({ id: z.string(), source: z.string() }), + query: z.object({ command_id: z.number().or(z.string()).optional() }), + response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() }) + }) + .post("/stop", async ({ }) => + { + if (activeGame) + { + events.emit('activegameexit', { + source: 'local', id: String(activeGame.gameId), + exitCode: null, + signalCode: null + }); + } + }) + .get('/emulatorjs/data/cores/*', async ({ params }) => + { + const res = await fetch(`https://cdn.emulatorjs.org/latest/data/cores/${params['*']}`); + return res; + }) + .get('/emulatorjs/data/*', async ({ params }) => + { + return status("Not Found"); }); \ No newline at end of file diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index 731b026..d8a1e9c 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -1,17 +1,18 @@ import Elysia, { status } from "elysia"; import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm"; import z from "zod"; -import { count, eq, getTableColumns, notInArray } from "drizzle-orm"; +import { count, eq, getTableColumns } from "drizzle-orm"; import { db } from "../app"; import { FrontEndPlatformType } from "@shared/constants"; -import * as schema from "../schema/app"; +import * as schema from "@schema/app"; +import { CACHE_KEYS, getOrCached } from "../cache"; export default new Elysia() .get('/platforms', async () => { const platforms: FrontEndPlatformType[] = []; let rommPlatformsSet: Set | undefined; - const { data: rommPlatforms } = await getPlatformsApiPlatformsGet(); + const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e)); const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) }) .from(schema.platforms) @@ -32,7 +33,7 @@ export default new Elysia() path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`, game_count: p.rom_count, updated_at: new Date(p.updated_at), - id: { source: 'romm', id: p.id }, + id: { source: 'romm', id: String(p.id) }, hasLocal: localPlatformSet.has(p.slug), paths_screenshots: game.data?.items[0]?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`) ?? [] }; @@ -46,7 +47,13 @@ export default new Elysia() platforms.push(...await Promise.all(localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(async p => { - const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id), with: { screenshots: true }, columns: {} }); + const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id) }); + let screenshots: { id: number; }[] = []; + if (game) + { + screenshots = await db.query.screenshots.findMany({ where: eq(schema.screenshots.game_id, game.id), columns: { id: true } }); + } + const platform: FrontEndPlatformType = { slug: p.slug, name: p.name, @@ -54,9 +61,9 @@ export default new Elysia() 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 }, + id: { source: 'local', id: String(p.id) }, hasLocal: true, - paths_screenshots: game?.screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? [] + paths_screenshots: screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? [] }; @@ -66,13 +73,52 @@ export default new Elysia() return { platforms }; }).get('/platforms/:source/:id', async ({ params: { source, id } }) => { - const rommPlatform = await getPlatformApiPlatformsIdGet({ path: { id } }); - if (rommPlatform.data) + if (source === 'romm') { - return rommPlatform.data; + const { data: rommPlatform, response } = await getPlatformApiPlatformsIdGet({ path: { id } }); + if (rommPlatform) + { + const platform: FrontEndPlatformType = { + slug: rommPlatform.slug, + name: rommPlatform.display_name, + family_name: rommPlatform.family_name, + path_cover: `/api/romm/image/romm/assets/platforms/${rommPlatform.slug}.svg`, + game_count: rommPlatform.rom_count, + updated_at: new Date(rommPlatform.updated_at), + id: { source: 'romm', id: String(rommPlatform.id) }, + paths_screenshots: [], + hasLocal: false + }; + + return platform; + } + + return status("Not Found", response); + } + else if (source === 'local') + { + const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, id) }); + if (localPlatform) + { + const platform: FrontEndPlatformType = { + slug: localPlatform.slug, + name: localPlatform.name, + family_name: localPlatform.family_name, + path_cover: `/api/romm/platform/local/${localPlatform.id}/cover`, + game_count: 0, + updated_at: localPlatform.created_at, + id: { source: 'local', id: String(localPlatform.id) }, + hasLocal: true, + paths_screenshots: [] + }; + + return platform; + } + + return status("Not Found"); } - return status("Not Found", rommPlatform.response); + return status("Not Implemented"); }, { 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({ diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index a056b32..bdd0fd2 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -2,26 +2,19 @@ import path from 'node:path'; import { which } from 'bun'; import fs from 'node:fs/promises'; import { existsSync, readFileSync } from 'node:fs'; -import * as schema from '../../schema/emulators'; -import * as appSchema from "../../schema/app"; +import * as schema from '@schema/emulators'; +import * as appSchema from "@schema/app"; import { eq } from 'drizzle-orm'; import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app'; import os from 'node:os'; import { $ } from 'bun'; import { spawn } from 'node:child_process'; import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm'; +import { CommandEntry } from '@/shared/constants'; export const varRegex = /%([^%]+)%/g; -interface CommandEntry -{ - label?: string; - command: string; - valid: boolean; - emulator?: string; -} - -export async function launchCommand (validCommand: string, source: string, sourceId: number, id: number) +export async function launchCommand (validCommand: string, source: string, sourceId: string, id: number) { if (activeGame && activeGame.process?.killed === false) { @@ -69,13 +62,12 @@ export async function launchCommand (validCommand: string, source: string, sourc if (source === 'romm') { - updateRommProps(sourceId); + updateRommProps(Number(sourceId)); } else if (localGame?.source === 'romm' && localGame.source_id) { - updateRommProps(localGame.source_id); + updateRommProps(Number(localGame.source_id)); } - }); /* Old spawn lanching, cases issues, needs to be ran as shell @@ -117,7 +109,10 @@ export async function getValidLaunchCommands (data: { }): Promise { - const system = await emulatorsDb.query.systems.findFirst({ with: { commands: true }, where: eq(schema.systems.name, data.systemSlug) }); + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(schema.systems.name, data.systemSlug) + }); if (!system) { @@ -165,7 +160,7 @@ export async function getValidLaunchCommands (data: { } } - const formattedCommands = await Promise.all(system.commands.map(async command => + const formattedCommands = await Promise.all(system.commands.map(async (command, index) => { const label = command.label; let cmd = command.command; @@ -213,14 +208,14 @@ export async function getValidLaunchCommands (data: { if (value.startsWith("%EMULATOR_")) { const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - let exec = await findExec(emulatorName); + let exec = await findExecByName(emulatorName); if (data.customEmulatorConfig.has(emulatorName)) { - exec = data.customEmulatorConfig.get(emulatorName); + exec = { path: data.customEmulatorConfig.get(emulatorName)!, type: 'custom' }; } emulator = emulatorName; - return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]]; + return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]]; } const key = value[0].substring(1, value.length - 1); @@ -237,6 +232,7 @@ export async function getValidLaunchCommands (data: { const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim(); return { + id: index, label: label ?? undefined, command: formattedCommand, valid: !invalid, emulator @@ -246,13 +242,18 @@ export async function getValidLaunchCommands (data: { return formattedCommands.filter(c => !!c); } -export async function findExec (emulatorName: string) +export async function findExecByName (emulatorName: string) { const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) }); if (!emulator) { throw new Error(`Could not find emulator ${emulatorName}`); } + return findExec(emulator); +} + +export async function findExec (emulator: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) +{ if (os.platform() === 'win32') { const regValues = emulator.winregistrypath; @@ -263,7 +264,7 @@ export async function findExec (emulatorName: string) const registryValue = await readRegistryValue(node); if (registryValue) { - return registryValue; + return { path: registryValue, type: 'registry' }; } } @@ -276,7 +277,7 @@ export async function findExec (emulatorName: string) const systemPath = await resolveSystemPath(systempaths); if (systemPath) { - return systemPath; + return { path: systemPath, type: 'system' }; } } @@ -286,7 +287,7 @@ export async function findExec (emulatorName: string) const staticPath = await resolveStaticPath(staticPaths); if (staticPath) { - return staticPath; + return { path: staticPath, type: 'static' }; } } } diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index f46cc6b..e022fb8 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,13 +1,16 @@ -import { GameInstallProgress, GameStatusType, } from "@shared/constants"; +import { GameInstallProgress, GameStatusType, RPC_URL, } from "@shared/constants"; import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app"; import { getValidLaunchCommands } from "./launchGameService"; -import * as schema from '../../schema/app'; +import * as schema from '@schema/app'; import { eq } from "drizzle-orm"; import { getErrorMessage } from "@/bun/utils"; import { getLocalGameMatch } from "./utils"; import { getRomApiRomsIdGet } from "@/clients/romm"; import fs from 'node:fs/promises'; import { ErrorLike } from "elysia/universal"; +import { getStoreGameFromId } from "../../store/services/gamesService"; +import { cores } from "../../emulatorjs/emulatorjs"; +import { host } from "@/bun/utils/host"; class CommandSearchError extends Error { @@ -18,7 +21,7 @@ class CommandSearchError extends Error } } -export async function getLocalGame (source: string, id: number) +export async function getLocalGame (source: string, id: string) { const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug }) .from(schema.games) @@ -33,7 +36,7 @@ export async function getLocalGame (source: string, id: number) return undefined; } -export async function getValidLaunchCommandsForGame (source: string, id: number) +export async function getValidLaunchCommandsForGame (source: string, id: string) { const localGame = await getLocalGame(source, id); if (localGame) @@ -42,18 +45,28 @@ export async function getValidLaunchCommandsForGame (source: string, id: number) { if (localGame.path_fs) { + try { const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs }); + + if (cores[localGame.platform_slug]) + { + const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`; + commands.push({ + id: 'emulatorjs', + label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: 'emulatorjs' + }); + } + const validCommand = commands.find(c => c.valid); if (validCommand) { - return { command: validCommand, gameId: localGame.id, source: source, sourceId: id }; - + return { commands: commands.filter(c => c.valid), 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(',')}`); + return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`); } } catch (error) { @@ -76,7 +89,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: number) return undefined; } -export default async function buildStatusResponse (source: string, id: number) +export default async function buildStatusResponse (source: string, id: string) { let cleanup: (() => void) | undefined; let closed = false; @@ -87,6 +100,7 @@ export default async function buildStatusResponse (source: string, id: number) function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping') { + if (closed) return; const evntString = event ? `event: ${event}\n` : ''; controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`)); } @@ -136,13 +150,14 @@ export default async function buildStatusResponse (source: string, id: number) } else { - enqueue({ status: 'installed', details: validCommand.command.label }); + enqueue({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands }); } - } else if (source === 'romm') + } + else if (source === 'romm') { // TODO: Add Caching - const remoteGame = await getRomApiRomsIdGet({ path: { id } }); + const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(id) } }); const stats = await fs.statfs(config.get('downloadPath')); if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail) { @@ -152,6 +167,20 @@ export default async function buildStatusResponse (source: string, id: number) enqueue({ status: 'install', details: 'Install' }); } + } else if (source === 'store') + { + const storeGame = await getStoreGameFromId(id); + const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); + const size = Number(fileResponse.headers.get('content-length')); + const stats = await fs.statfs(config.get('downloadPath')); + + if (size > stats.bsize * stats.bavail) + { + enqueue({ status: 'error', error: "Not Enough Free Space" }); + } else + { + enqueue({ status: 'install', details: 'Install' }); + } } } } @@ -190,7 +219,7 @@ export default async function buildStatusResponse (source: string, id: number) { enqueue({ status: 'error', - error: error + error: getErrorMessage(error) }, 'error'); } })); diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index 573e6b7..66e4944 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -1,11 +1,12 @@ import getFolderSize from "get-folder-size"; import fs from "node:fs/promises"; import path from "node:path"; -import { config } from "../../app"; +import { config, db, emulatorsDb } from "../../app"; import { and, eq } from "drizzle-orm"; -import * as schema from "../../schema/app"; -import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants"; +import * as schema from "@schema/app"; +import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants"; import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm"; +import * as emulatorSchema from "@schema/emulators"; export async function calculateSize (installPath: string | null) { @@ -19,15 +20,15 @@ export async function checkInstalled (installPath: string | null) return fs.exists(path.join(config.get('downloadPath'), installPath)); } -export function getLocalGameMatch (id: number, source: string) +export function getLocalGameMatch (id: string, source: string) { - return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, id); + return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id)); } export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType { const game: FrontEndGameType = { - id: { id: rom.id, source: 'romm' }, + id: { id: String(rom.id), source: 'romm' }, path_cover: `/api/romm/image/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), @@ -40,11 +41,131 @@ export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType source: null, source_id: null, paths_screenshots: rom.merged_screenshots.map(s => `/api/romm/image/romm/${s}`), + platform_slug: rom.platform_slug }; return game; } +export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { + platform?: typeof schema.platforms.$inferSelect | null; + screenshotIds?: number[]; +}) +{ + const game: FrontEndGameType = { + platform_display_name: g.platform?.name ?? "Local", + id: { id: String(g.id), source: 'local' }, + updated_at: g.created_at, + path_cover: `/api/romm/game/local/${g.id}/cover`, + source_id: g.source_id, + source: g.source, + path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, + paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [], + path_fs: g.path_fs, + last_played: g.last_played, + slug: g.slug, + name: g.name, + platform_id: g.platform_id, + platform_slug: g.platform?.slug ?? null + }; + + return game; +} + +export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & { + platform?: typeof schema.platforms.$inferSelect | null; + screenshotIds?: number[]; +}) +{ + const game: FrontEndGameTypeDetailed = { + platform_display_name: g.platform?.name ?? "Local", + id: { id: String(g.id), source: 'local' }, + updated_at: g.created_at, + path_cover: `/api/romm/game/local/${g.id}/cover`, + source_id: g.source_id, + source: g.source, + path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, + paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [], + path_fs: g.path_fs, + last_played: g.last_played, + slug: g.slug, + name: g.name, + platform_id: g.platform_id, + platform_slug: g.platform?.slug ?? null, + summary: g.summary, + fs_size_bytes: 0, + missing: false, + local: true + }; + + return game; +} + +export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise +{ + let size: number | null = null; + try + { + const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); + size = Number(fileResponse.headers.get('content-length')); + } catch (error) + { + console.error(error); + } + const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm')) + }); + + const platformDef = await emulatorsDb.query.systems.findFirst({ + where: eq(emulatorSchema.systems.name, system), + columns: { fullname: true } + }); + + const gameId = `${system}@${id}`; + + const game: FrontEndGameType = { + platform_display_name: platformDef?.fullname ?? system, + path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`, + id: { source: 'store', id: gameId }, + source: null, + source_id: null, + path_fs: null, + path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`, + last_played: null, + updated_at: new Date(), + slug: null, + name: storeGame.title, + platform_id: null, + platform_slug: system, + paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [] + }; + + return game; +} + +export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise +{ + let size: number | null = null; + try + { + const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); + size = Number(fileResponse.headers.get('content-length')); + } catch (error) + { + console.error(error); + } + + const detailed: FrontEndGameTypeDetailed = { + ...await convertStoreToFrontend(system, id, storeGame), + summary: storeGame.description, + fs_size_bytes: size, + missing: false, + local: false, + }; + + return detailed; +} + export function convertRomToFrontendDetailed (rom: DetailedRomSchema) { const detailed: FrontEndGameTypeDetailed = { diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 91a185b..89b1912 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -2,13 +2,16 @@ import { IJob, JobContext } from "../task-queue"; import { mkdir } from 'node:fs/promises'; import { and, eq, or } from 'drizzle-orm'; import fs from 'node:fs/promises'; -import * as schema from "../schema/app"; -import * as emulatorSchema from "../schema/emulators"; +import * as schema from "@schema/app"; +import * as emulatorSchema from "@schema/emulators"; import path from 'node:path'; -import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm"; +import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm"; import { config, db, emulatorsDb, jar } from "../app"; import unzip from 'unzip-stream'; import { Readable, Transform } from "node:stream"; +import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; +import * as igdb from 'ts-igdb-client'; +import secrets from "../secrets"; interface JobConfig { @@ -18,15 +21,15 @@ interface JobConfig export class InstallJob implements IJob { - public id: number; + public gameId: string; public source: string; - public sourceId: number; - + public sourceId: string; public config?: JobConfig; + static id = "install-job" as const; - constructor(id: number, source: string, sourceId: number, config?: JobConfig) + constructor(id: string, source: string, sourceId: string, config?: JobConfig) { - this.id = id; + this.gameId = id; this.config = config; this.sourceId = sourceId; this.source = source; @@ -41,6 +44,65 @@ export class InstallJob implements IJob { const downloadPath = config.get('downloadPath'); + let downloadUrl: URL; + let cookie: string = ''; + let screenshotUrls: string[]; + let coverUrl: string; + let rommPlatform: PlatformSchema | undefined; + let slug: string | null; + let path_fs: string | undefined; + let summary: string | null; + let name: string | null; + let last_played: Date | null; + let igdb_id: number | null; + let ra_id: number | null; + let source_id: string; + let system_slug: string; + let extract_path: string; + + switch (this.source) + { + case 'romm': + + const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data; + rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data; + + const rommAddress = config.get('rommAddress'); + coverUrl = `${rommAddress}${rom.path_cover_large}`; + screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`); + last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null; + igdb_id = rom.igdb_id; + ra_id = rom.ra_id; + summary = rom.summary; + name = rom.name; + path_fs = path.join(rom.fs_path, rom.fs_name); + source_id = String(rom.id); + slug = rom.slug; + system_slug = rommPlatform.slug; + extract_path = ''; + + downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`); + downloadUrl.searchParams.set('rom_ids', String(this.gameId)); + cookie = await jar.getCookieString(config.get('rommAddress') ?? ''); + break; + case 'store': + const game = await getStoreGameFromId(this.gameId); + const gameId = extractStoreGameSourceId(this.gameId); + coverUrl = game.pictures.titlescreens[0]; + screenshotUrls = game.pictures.screenshots; + downloadUrl = new URL(game.file); + slug = this.gameId; + source_id = this.gameId; + name = game.title; + summary = game.description; + system_slug = gameId.system; + extract_path = 'roms', gameId.system; + + break; + default: + throw new Error("Unsupported source"); + } + if (this.config?.dryDownload !== true) { /* @@ -92,11 +154,10 @@ export class InstallJob implements IJob await fs.rm(zipFilePath);*/ cx.setProgress(0, 'download'); - const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`); - downloadUrl.searchParams.set('rom_ids', String(this.id)); + const res = await fetch(downloadUrl, { headers: { - cookie: await jar.getCookieString(config.get('rommAddress') ?? '') + cookie: cookie }, }); @@ -119,62 +180,99 @@ export class InstallJob implements IJob await new Promise((resolve, reject) => { - Readable.fromWeb(res.body as any).pipe(progressStream).pipe(unzip.Extract({ path: downloadPath })).on('close', resolve).on('error', reject); + const extract = unzip.Extract({ path: path.join(downloadPath, extract_path), }); + (extract as any).unzipStream.on('entry', (entry: any) => + { + if (!path_fs) + path_fs = path.join(extract_path, entry.path); + }); + Readable.fromWeb(res.body as any).pipe(progressStream) + .pipe(extract) + .on('close', resolve) + .on('error', reject); }); } - 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 }); + await mkdir(path.join(downloadPath, extract_path), { 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}`); + + const coverResponse = await fetch(coverUrl); + const cover = Buffer.from(await coverResponse.arrayBuffer()); 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 platformSearch = [eq(schema.platforms.slug, system_slug)]; + const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, system_slug)]; - const esPlatform = await emulatorsDb - .select({ slug: emulatorSchema.systemMappings.system, romm_slug: emulatorSchema.systemMappings.sourceSlug }) - .from(emulatorSchema.systemMappings) - .where(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug))); + if (rommPlatform) + { + if (rommPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, rommPlatform.igdb_id)); + if (rommPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, rommPlatform.igdb_slug)); + if (rommPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, rommPlatform.ra_id)); + if (rommPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, rommPlatform.moby_id)); - const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) }); + esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm')); + esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform.slug)); + } + + const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ + with: { system: true }, + where: and(...esPlatformSearch) + }); + + if (esPlatform) + platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name)); + + let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) }); let platformId: number; if (!existingPlatform) { - // 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; + // TODO: use something else than the romm demo as CDN + const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${system_slug}.svg`); + + if (!esPlatform && !rommPlatform) + { + // go to unknown platform + existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") }); + + if (existingPlatform) + { + platformId = existingPlatform.id; + } else + { + const [{ id }] = await tx.insert(schema.platforms).values({ + slug: 'unknown', + name: "Unknown" + }).returning({ id: schema.platforms.id }); + platformId = id; + } + } else + { + // Create new local platform + const platform: typeof schema.platforms.$inferInsert = { + slug: rommPlatform?.slug ?? esPlatform?.system.name ?? '', + igdb_id: rommPlatform?.igdb_id, + igdb_slug: rommPlatform?.igdb_slug, + ra_id: rommPlatform?.ra_id, + cover: Buffer.from(await platformCover.arrayBuffer()), + cover_type: platformCover.headers.get('content-type'), + name: rommPlatform?.name ?? esPlatform?.system.fullname ?? '', + family_name: rommPlatform?.family_name, + es_slug: esPlatform?.system.name ?? undefined + }; + + // TODO: add ES slug once I have better way to query ES + const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id }); + platformId = id; + } + } else { platformId = existingPlatform.id; @@ -182,32 +280,52 @@ export class InstallJob implements IJob // 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, + source_id, + source: this.source, + slug, + path_fs, + last_played: last_played, 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()), + igdb_id: igdb_id, + ra_id: ra_id, + summary: summary, + name, + cover, 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; - }))); + if (screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID) + { + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + if (access_token) + { + const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); + + const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', igdb_id)).execute(); + + screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!)); + } + } + + // pre-fetch screenshots + const screenshots = await Promise.all(screenshotUrls.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof schema.screenshots.$inferInsert = { + game_id: id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + }); } diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts new file mode 100644 index 0000000..b4680a6 --- /dev/null +++ b/src/bun/api/jobs/jobs.ts @@ -0,0 +1,80 @@ +import Elysia from "elysia"; +import z, { } from "zod"; +import { taskQueue } from "../app"; +import { LoginJob } from "./login-job"; +import TwitchLoginJob from "./twitch-login-job"; +import UpdateStoreJob from "./update-store"; + +function registerJob (job: T, path: Path, dataSchema: TS) +{ + return new Elysia().ws(path, { + body: z.discriminatedUnion('type', [ + z.object({ type: z.literal('cancel') }) + ]), + response: z.discriminatedUnion('type', [ + z.object({ + type: z.literal(['data', 'started', 'progress']), + status: z.string(), + progress: z.number(), + data: dataSchema + }), + z.object({ type: z.literal(['completed', 'ended']) }), + z.object({ type: z.literal('error'), error: z.unknown() }) + ]), + open (ws) + { + const job = taskQueue.findJob(path); + if (job) + { + ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() }); + } + + (ws.data as any).cleanup = [ + taskQueue.on('started', ({ id, job }) => + { + if (id === path) + { + ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() }); + } + }), + taskQueue.on('progress', ({ id, job }) => + { + if (id === path) + { + ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() }); + } + }), + taskQueue.on('completed', ({ id }) => + { + if (id === path) + { + ws.send({ type: 'completed' }); + } + }), + taskQueue.on('error', ({ id, error }) => + { + if (id === path) + { + ws.send({ type: 'error', error: error }); + } + }) + ]; + }, + close (ws) + { + (ws.data as any).cleanup.forEach((d: Function) => d()); + }, + message (ws, message) + { + if (message.type === 'cancel') + { + taskQueue.findJob(path)?.abort('cancel'); + } + }, + }); +} + +export const jobs = new Elysia({ prefix: '/api/jobs' }) + .use(registerJob(LoginJob, LoginJob.id, LoginJob.dataSchema)) + .use(registerJob(TwitchLoginJob, TwitchLoginJob.id, TwitchLoginJob.dataSchema)) + .use(registerJob(UpdateStoreJob, UpdateStoreJob.id, undefined)); diff --git a/src/bun/api/jobs/login-job.ts b/src/bun/api/jobs/login-job.ts index c0f4849..eec8a91 100644 --- a/src/bun/api/jobs/login-job.ts +++ b/src/bun/api/jobs/login-job.ts @@ -4,20 +4,27 @@ import { LOGIN_PORT, SERVER_URL } from "@/shared/constants"; import { host, localIp } from "@/bun/utils/host"; import cors from "@elysiajs/cors"; import { tryLoginAndSave } from "../auth"; -import z from "zod"; import { config } from "../app"; +import z from "zod"; +import { delay } from "@/shared/utils"; export class LoginJob implements IJob { endsAt: Date; + startedAt: Date; url: string; + static id = "login-job" as const; + static dataSchema = z.object({ endsAt: z.date(), startedAt: z.date(), url: z.url() }); constructor() { - this.endsAt = new Date(); + this.endsAt = new Date(new Date().getTime() + 300000); + this.startedAt = new Date(); this.url = `http://${localIp}:${LOGIN_PORT}/`; } + exposeData = (): z.infer => ({ endsAt: this.endsAt, startedAt: this.startedAt, url: this.url }); + async start (context: JobContext): Promise { const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } }) @@ -44,12 +51,7 @@ export class LoginJob implements IJob try { loginServer.listen({}); - await new Promise((resolve, reject) => - { - this.endsAt = new Date(new Date().getTime() + 300000); - context.abortSignal.addEventListener('abort', () => reject()); - setTimeout(() => { reject('timeout'); }, 300000); // auto close after 5 minutes - }); + await delay(this.endsAt, context.abortSignal); } catch { } finally diff --git a/src/bun/api/jobs/twitch-login-job.ts b/src/bun/api/jobs/twitch-login-job.ts new file mode 100644 index 0000000..3d2a0c0 --- /dev/null +++ b/src/bun/api/jobs/twitch-login-job.ts @@ -0,0 +1,110 @@ +import { IJob, JobContext } from "../task-queue"; +import secrets from "../secrets"; +import open from "open"; +import z from "zod"; +import { delay } from "@/shared/utils"; + + +interface TwitchDevice +{ + device_code: string, + expires_in: number, + expires_at: Date, + started_at: Date, + interval: number, + user_code: string, + verification_uri: string; +} + +export default class TwitchLoginJob implements IJob +{ + twitchScopes = "analytics:read:extensions analytics:read:games user:read:email"; + device?: TwitchDevice; + clientId: string; + openInBrowser: boolean; + static id = 'twitch-login-job' as const; + static dataSchema = z.object({ expires_at: z.date(), started_at: z.date(), url: z.url(), user_code: z.string() }).or(z.undefined()); + + constructor(clientId: string, openInBrowser: boolean) + { + this.clientId = clientId; + this.openInBrowser = openInBrowser; + } + + exposeData = (): z.infer => this.device ? ({ + expires_at: this.device.expires_at, + started_at: this.device.started_at, + url: this.device.verification_uri, + user_code: this.device.user_code + }) : undefined; + + async start (context: JobContext): Promise + { + context.setProgress(0, "Retrieving Device"); + let res = await fetch("https://id.twitch.tv/oauth2/device", { + method: "POST", + body: new URLSearchParams({ + client_id: this.clientId, + scopes: this.twitchScopes + }), + signal: context.abortSignal + }); + + const device: TwitchDevice = await res.json(); + const expiredTimeout = setTimeout(() => context.abort('expired'), device.expires_in * 1000); + device.expires_at = new Date(new Date().getTime() + device.expires_in * 1000); + device.started_at = new Date(); + this.device = device; + + try + { + if (this.openInBrowser) + open(device.verification_uri); + this.device = device; + context.setProgress(50, "Waiting For Authentication"); + + while (true) + { + if (context.abortSignal.aborted) break; + await delay(device.interval * 1000, context.abortSignal); + + res = await fetch("https://id.twitch.tv/oauth2/token", { + method: "POST", + body: new URLSearchParams({ + client_id: this.clientId, + scopes: this.twitchScopes, + device_code: this.device.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code" + }), + signal: context.abortSignal + }); + + if (res.status === 200) + { + const data: { + access_token: string, + expires_in: number, + refresh_token: string, + scope: string[], + token_type: string; + } = await res.json(); + + secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token }); + secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token }); + secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() }); + break; + } + else if (res.status !== 400) + { + console.error(res.statusText); + break; + } + } + + } finally + { + clearTimeout(expiredTimeout); + } + } + +} \ No newline at end of file diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/update-store.ts new file mode 100644 index 0000000..ce97c27 --- /dev/null +++ b/src/bun/api/jobs/update-store.ts @@ -0,0 +1,76 @@ +import { ensureDir } from "fs-extra"; +import { IJob, JobContext } from "../task-queue"; +import { getStoreFolder } from "../store/store"; + +export default class UpdateStoreJob implements IJob +{ + static id = "update-store" as const; + static origin = "https://github.com/simeonradivoev/gameflow-store.git"; + static branch = "master"; + + async gitCommand (commands: string[], dir: string) + { + const proc = Bun.spawn(['git', ...commands], { + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + + const [output] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + + return output.trim(); + } + + async isGitRepo (dir: string) + { + return (await this.gitCommand(["rev-parse", "--is-inside-work-tree"], dir)) === 'true'; + } + + async getOrigin (dir: string) + { + const origin = await this.gitCommand(["remote", "get-url", "origin"], dir); + return origin; + } + + async hasChanges (dir: string) + { + return (await this.gitCommand(["status", "--porcelain"], dir)).length > 0; + } + + async start (context: JobContext) + { + const storeFolder = getStoreFolder(); + await ensureDir(storeFolder); + context.setProgress(10); + if (await this.isGitRepo(storeFolder)) + { + const existingOrigin = await this.getOrigin(storeFolder); + if (existingOrigin !== UpdateStoreJob.origin) + { + throw new Error(`Git Repo in downloads is not valid. It has origin of ${existingOrigin}. Repo must be of ${UpdateStoreJob.origin}`); + } + + // check for uncommitted changes + const status = await this.gitCommand([" status", "--porcelain"], storeFolder); + if (status.length > 0) + { + console.log("Cleaning local changes..."); + await this.gitCommand(["reset", "--hard"], storeFolder); + await this.gitCommand(["clean", "-fd"], storeFolder); + } + + // fetch & reset to remote + await this.gitCommand(["fetch", "origin"], storeFolder); + await this.gitCommand(["reset", "--hard", `origin/${UpdateStoreJob.branch}`], storeFolder); + console.log("Shop Repo updated"); + } else + { + context.setProgress(50); + await this.gitCommand(["clone", "--depth", "1", "--branch", UpdateStoreJob.branch, UpdateStoreJob.origin, '.'], storeFolder); + context.setProgress(100); + } + } +} \ No newline at end of file diff --git a/src/bun/api/rpc.ts b/src/bun/api/rpc.ts index a1544f2..6ad55d3 100644 --- a/src/bun/api/rpc.ts +++ b/src/bun/api/rpc.ts @@ -1,17 +1,21 @@ import { cors } from "@elysiajs/cors"; import Elysia from "elysia"; -import { RPC_PORT } from "../../shared/constants"; +import { RPC_PORT } from "@shared/constants"; import clients from "./clients"; -import { settings } from "./settings"; +import { settings } from "./settings/settings"; import { system } from "./system"; +import { store } from "./store/store"; import { host } from "../utils/host"; +import { jobs } from "./jobs/jobs"; const api = new Elysia({ serve: {} }) - .use([cors(), clients, settings, system]); + .use([cors(), clients, settings, system, store, jobs]); export type RommAPIType = typeof clients; export type SettingsAPIType = typeof settings; export type SystemAPIType = typeof system; +export type StoreAPIType = typeof store; +export type JobsAPIType = typeof jobs; export function RunAPIServer () { diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index cec2361..fafa32a 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -3,7 +3,7 @@ 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_id: text('source_id'), source: text("source"), igdb_id: integer("igdb_id").unique(), name: text("name"), diff --git a/src/bun/api/schema/cache.ts b/src/bun/api/schema/cache.ts new file mode 100644 index 0000000..ff6bcca --- /dev/null +++ b/src/bun/api/schema/cache.ts @@ -0,0 +1,10 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export default { + item_cache: sqliteTable('item_cache', { + key: text('key').primaryKey(), + data: text('data', { mode: 'json' }).notNull(), + expire_at: integer("expire_at", { mode: 'timestamp' }).notNull(), + updated_at: integer("updated_at", { mode: 'timestamp' }).notNull(), + }) +}; \ No newline at end of file diff --git a/src/bun/api/schema/emulators.ts b/src/bun/api/schema/emulators.ts index 67262c4..0af0ff0 100644 --- a/src/bun/api/schema/emulators.ts +++ b/src/bun/api/schema/emulators.ts @@ -29,6 +29,10 @@ export const systemMappings = sqliteTable('systemMappings', { system: text().notNull().references(() => systems.name) }); +export const systemMappingsRelations = relations(systemMappings, ({ one }) => ({ + system: one(systems, { fields: [systemMappings.system], references: [systems.name] }) +})); + export const commands = sqliteTable('commands', { system: text().references(() => systems.name, { onDelete: 'cascade', onUpdate: 'cascade' }), label: text(), @@ -36,7 +40,7 @@ export const commands = sqliteTable('commands', { }); export const commandsRelations = relations(commands, ({ one }) => ({ - author: one(systems, { + system: one(systems, { fields: [commands.system], references: [systems.name], }), diff --git a/src/bun/api/settings.ts b/src/bun/api/settings.ts deleted file mode 100644 index 9d67421..0000000 --- a/src/bun/api/settings.ts +++ /dev/null @@ -1,150 +0,0 @@ -import z from "zod"; -import { LOGIN_PORT, SettingsSchema } from "@shared/constants"; -import Elysia, { status } from "elysia"; -import { config, customEmulators, db, emulatorsDb, taskQueue } 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'; -import { existsSync } from "node:fs"; -import { InstallJob } from "./jobs/install-job"; -import { move } from "fs-extra"; - -export const settings = new Elysia({ prefix: '/api/settings' }) - .get('/emulators/automatic', async () => - { - const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id }) - .from(appSchema.games) - .leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id)) - .groupBy(appSchema.platforms.es_slug); - - 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 = {}; - 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()) - }) - .put('/path/download', async ({ body: { manualPath, drive } }) => - { - if (taskQueue.hasActiveOfType(InstallJob)) - { - return status("Forbidden", "Installation in progress"); - } - - const oldDownloadPath = config.get('downloadPath'); - if (!existsSync(oldDownloadPath)) - { - return status("Not Found", "Old download path doesn't exist"); - } - - async function isDirEmpty (dirname: string) - { - const files = await fs.readdir(dirname); - return files.length === 0; - } - - const path = manualPath ?? drive; - - if (!path) - { - return; - } - - if (existsSync(path) && !isDirEmpty(path)) - { - return status("Conflict", "New location already exists and is not empty"); - } - - await move(oldDownloadPath, path); - config.set('downloadPath', manualPath); - return manualPath; - }, { - body: z.object({ - manualPath: z.string().optional(), - drive: z.string().optional() - }) - }) - .get("/:id", async ({ params: { id } }) => - { - const value = config.get(id); - return { value: value }; - }, { - params: z.object({ id: z.keyof(SettingsSchema) }), - }).post('/:id', - async ({ params: { id }, body: { value }, }) => - { - config.set(id, value); - }, { - params: z.object({ id: z.keyof(SettingsSchema) }), - body: z.object({ value: z.any() }), - }); - diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts new file mode 100644 index 0000000..cce32de --- /dev/null +++ b/src/bun/api/settings/services.ts @@ -0,0 +1,193 @@ + +import * as appSchema from '@schema/app'; +import { findExec, findExecByName } from "../games/services/launchGameService"; +import * as emulatorSchema from "@schema/emulators"; +import { eq, inArray } from 'drizzle-orm'; +import { customEmulators, db, emulatorsDb } from '../app'; +import fs from 'node:fs/promises'; +import { cores } from '../emulatorjs/emulatorjs'; + +/** + * Get emulators based on local games. Only the ones we probably need. + * */ +export async function getRelevantEmulators () +{ + const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id, platform_name: appSchema.platforms.name }) + .from(appSchema.games) + .leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id)) + .groupBy(appSchema.platforms.es_slug); + + const platformLookup = new Map(localGames.filter(g => g.es_slug).map(g => [g.es_slug!, g])); + const platformViability = new Map(localGames.filter(g => g.es_slug).map(g => [g.es_slug!, false])); + + // check emulator js + for (const platform of platformLookup) + { + if (cores[platform[0]]) + platformViability.set(platform[0], true); + } + + // all commands based on the local games + 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)); + + + // get all emulators in said commands + const emulators = commands + .flatMap(command => + { + const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/); + if (!matches) + { + return undefined; + } + + return matches?.map(m => ({ emulator: m, system: command.system_slug })); + } + ).filter(c => !!c); + + // Group them by name + const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator); + const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) => + { + let execPath: { path: string; type: string, } | undefined; + if (customEmulators.has(emulator)) + { + execPath = { path: customEmulators.get(emulator), type: 'custom' }; + } else + { + execPath = await findExecByName(emulator); + } + + let platform: number | null | undefined = null; + const validSystemSlug = system_slug.find(s => s.system); + if (validSystemSlug?.system) + { + platform = platformLookup.get(validSystemSlug.system)?.platform_id; + } + + // check if automatic or custom path found existing binary. + // This might not be the actual emulator but I don't care. + const exists = !!execPath && await fs.exists(execPath.path); + const systems = Array.from(new Set(system_slug.filter(s => s.system).map(s => s.system!))); + if (exists) + { + systems.forEach(s => platformViability.set(s, true)); + } + + return { + emulator: emulator, + path: execPath, + exists: exists, + isCritical: false, + path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null, + systems: systems.map(s => platformLookup.get(s)).filter(s => !!s) + }; + })); + + finalEmulators.push({ + emulator: 'emulatorjs', + exists: true, + path: { path: 'localhost', type: 'js' }, + path_cover: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`, + isCritical: false, + systems: [] + }); + + return finalEmulators.map(e => + { + e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!)); + return e; + }); +} + +/** + * Only emulators we strictly need based on local games. Emulator JS is included as bundled. + * If there is even single emulator for a system don't include emulators for that system. + */ +/*export async function getMissingEmulators () +{ + const localGames = await db.query.games.findMany({ + columns: { + platform_id: true, + slug: true + }, + with: { + platform: { + columns: { + name: true, + es_slug: true + } + }, + } + }); + + const platformLookup = new Map(localGames.map(g => [g.platform.es_slug, g])); + const platformViability = new Map(localGames.map(g => [g.platform.es_slug, false])); + + // all commands based on the local games + const commands = await emulatorsDb.query.commands.findMany({ + columns: { command: true }, + where: inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.platform.es_slug).map(s => s.platform.es_slug!)))), + with: { system: { columns: { name: true } } } + }); + + // get all emulators in said commands + const emulators = commands + .flatMap(command => + { + const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/); + if (!matches) + { + return undefined; + } + + return matches?.map(m => ({ emulator: m, system: command.system?.name })); + } + ).filter(c => !!c); + + const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator); + const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) => + { + let execPath: { path: string; type: string, } | undefined; + if (customEmulators.has(emulator)) + { + execPath = { path: customEmulators.get(emulator), type: 'custom' }; + } else + { + execPath = await findExecByName(emulator); + } + + let platform: number | null | undefined = null; + if (system_slug.length <= 1) + { + platform = platformLookup.get(system_slug[0].system)?.platform_id; + } + + // check if automatic or custom path found existing binary. + // This might not be the actual emulator but I don't care. + const exists = !!execPath && await fs.exists(execPath.path); + const systems = Array.from(new Set(system_slug.map(s => s.system))); + if (exists) + { + systems.forEach(s => platformViability.set(s, true)); + } + + return { + emulator: emulator, + path: execPath, + exists: exists, + isCritical: false, + path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null, + systems: systems.map(s => platformLookup.get(s)).filter(s => !!s) + }; + })); + + return finalEmulators.map(e => + { + e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!)); + return e; + }); +}*/ \ No newline at end of file diff --git a/src/bun/api/settings/settings.ts b/src/bun/api/settings/settings.ts new file mode 100644 index 0000000..dda53b9 --- /dev/null +++ b/src/bun/api/settings/settings.ts @@ -0,0 +1,94 @@ +import z from "zod"; +import { SettingsSchema } from "@shared/constants"; +import Elysia, { status } from "elysia"; +import { config, customEmulators, taskQueue } from "../app"; +import fs from 'node:fs/promises'; +import { existsSync } from "node:fs"; +import { InstallJob } from "../jobs/install-job"; +import { move } from "fs-extra"; +import { getRelevantEmulators } from "./services"; + +export const settings = new Elysia({ prefix: '/api/settings' }) + .get('/emulators/automatic', async () => + { + return getRelevantEmulators(); + }) + .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()) + }) + .put('/path/download', async ({ body: { manualPath, drive } }) => + { + if (taskQueue.hasActiveOfType(InstallJob)) + { + return status("Forbidden", "Installation in progress"); + } + + const oldDownloadPath = config.get('downloadPath'); + if (!existsSync(oldDownloadPath)) + { + return status("Not Found", "Old download path doesn't exist"); + } + + async function isDirEmpty (dirname: string) + { + const files = await fs.readdir(dirname); + return files.length === 0; + } + + const path = manualPath ?? drive; + + if (!path) + { + return; + } + + if (existsSync(path) && !isDirEmpty(path)) + { + return status("Conflict", "New location already exists and is not empty"); + } + + await move(oldDownloadPath, path); + config.set('downloadPath', manualPath); + return manualPath; + }, { + body: z.object({ + manualPath: z.string().optional(), + drive: z.string().optional() + }) + }) + .get("/:id", async ({ params: { id } }) => + { + const value = config.get(id); + return { value: value }; + }, { + params: z.object({ id: z.keyof(SettingsSchema) }), + }).post('/:id', + async ({ params: { id }, body: { value }, }) => + { + config.set(id, value); + }, { + params: z.object({ id: z.keyof(SettingsSchema) }), + body: z.object({ value: z.any() }), + }); + diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts new file mode 100644 index 0000000..4221e5a --- /dev/null +++ b/src/bun/api/store/services/gamesService.ts @@ -0,0 +1,59 @@ +import { GithubManifestSchema, StoreGameSchema } from "@/shared/constants"; +import { CACHE_KEYS, getOrCached } from "../../cache"; + +export async function getStoreGameManifest () +{ + return getOrCached(CACHE_KEYS.STORE_GAME_MANIFEST, async () => + { + const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json()).then(data => GithubManifestSchema.parseAsync(data)); + + return store.tree.filter((e: any) => + { + if (e.type === 'blob' && e.path !== "featured.json") + { + return true; + } + return false; + }); + }); +} + +export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) +{ + const offset = filter?.offset ?? 0; + const limit = Math.min(50, filter?.limit ?? 10); + + const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) => + { + return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, ""))))); + })); + + return games; +} + +export function extractStoreGameSourceId (id: string) +{ + const gameId = id.split('@'); + if (gameId.length !== 2) + throw new Error("Store ID should include platform and name with @ separator"); + return { system: gameId[0], id: gameId[1] }; +} + +export function getStoreGameFromId (id: string) +{ + const data = extractStoreGameSourceId(id); + return getStoreGame(data.system, data.id); +} + +export async function getStoreGame (system: string, id: string) +{ + return getStoreGameFromPath(`${system}/${encodeURIComponent(id)}.json`); +} + +export async function getStoreGameFromPath (path: string) +{ + const game = await getOrCached(CACHE_KEYS.STORE_GAME(path), () => fetch(`https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/${path}`) + .then(e => e.json()) + .then(g => StoreGameSchema.parseAsync(g))); + return game; +} \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts new file mode 100644 index 0000000..3720c96 --- /dev/null +++ b/src/bun/api/store/store.ts @@ -0,0 +1,201 @@ + +import Elysia from "elysia"; +import { config, customEmulators, db } from "../app"; +import path from "node:path"; +import fs from 'node:fs/promises'; +import { EmulatorPackageSchema, EmulatorPackageType, FrontEndEmulator, FrontEndEmulatorDetailed, StoreGameSchema } from "@/shared/constants"; +import { findExec } from "../games/services/launchGameService"; +import { emulatorsDb } from '../app'; +import { and, eq } from "drizzle-orm"; +import * as emulatorSchema from '@schema/emulators'; +import * as appSchema from '@schema/app'; +import z from "zod"; +import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; +import { getPlatformsApiPlatformsGet } from "@/clients/romm"; +import { CACHE_KEYS, getOrCached } from "../cache"; + +export function getStoreFolder () +{ + const downlodDir = config.get('downloadPath'); + return path.join(downlodDir, "store"); +} + +async function getAllStoreEmulatorPackages () +{ + const downlodDir = config.get('downloadPath'); + const emulatorsBucket = path.join(downlodDir, "store", "buckets", "emulators"); + const emulators = await fs.readdir(emulatorsBucket); + const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8'))); + + const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e => + { + if (e.error) + { + console.error(e.error); + } + return e.data; + }).map(e => e.data!); + + return emulatesParsed; +} + +async function buildSystems (emulator: EmulatorPackageType) +{ + const systems = await Promise.all(emulator.systems.map(async system => + { + const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system)) + }); + + const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } }); + + let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`; + + return { id: system, name: esSystem?.fullname ?? system, icon: icon }; + })); + + return systems; +} + +export const store = new Elysia({ prefix: '/api/store' }) + .get('/emulators', async ({ query }) => + { + const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => + { + console.error(e); + return undefined; + }); + const emulatesParsed = await getAllStoreEmulatorPackages(); + let frontEndEmulators = await Promise.all(emulatesParsed + .filter(e => e.os.includes(process.platform as any)) + .map(async (emulator) => + { + let execPath: { path: string; type: string; } | undefined; + const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) }); + + if (esEmulator) + { + if (customEmulators.has(emulator?.name)) + { + execPath = { path: customEmulators.get(emulator.name), type: 'custom' }; + } else + { + execPath = await findExec(esEmulator); + } + } + + const exists = !!execPath && await fs.exists(execPath.path); + const systems = await buildSystems(emulator); + + const gameCounts = await Promise.all(systems.map(async (s) => + { + const rommMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, s.id)) }); + const romPlatform = rommPlatforms?.find(p => p.slug === (rommMapping?.sourceSlug ?? s.id)); + if (romPlatform) + { + return romPlatform.rom_count; + } + + return 0; + + })); + + const gameCount = gameCounts.reduce((a, c) => a + c); + + return { ...emulator, exists, systems, gameCount } satisfies FrontEndEmulator; + })); + + if (query.missing) + { + frontEndEmulators = frontEndEmulators.filter(e => !e.exists); + } + + if (query.orderBy === 'importance') + { + frontEndEmulators.sort((a, b) => + { + const gameCountDiff = b.gameCount - a.gameCount; + if (gameCountDiff !== 0) return gameCountDiff; + return a.name.localeCompare(b.name); + }); + } + + if (query.limit) + { + frontEndEmulators = frontEndEmulators.splice(0, query.limit); + } + + return frontEndEmulators; + }, + { + query: z.object({ + limit: z.coerce.number().optional(), + missing: z.stringbool().optional().describe("Show Only Non Installed emulators"), + orderBy: z.enum(['name', 'recently_updated', 'importance']).optional() + }) + }) + .get('/games/featured', async () => + { + const response = await fetch('https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/featured.json'); + const games = await z.object({ featured: z.array(StoreGameSchema) }).parseAsync(await response.json()); + return Promise.all(games.featured.map(async g => + { + const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(`${g.system}@${g.title}`, 'store') }); + if (localGame) return convertLocalToFrontendDetailed(localGame); + return convertStoreToFrontendDetailed(g.system, g.title, g); + })); + }) + .get('/stats', async () => + { + const emulatesParsed = await getAllStoreEmulatorPackages(); + const storeEmulatorCount = emulatesParsed.filter(e => e.os.includes(process.platform as any)).length; + const gameCount = await db.$count(appSchema.games); + return { + storeEmulatorCount, + gameCount + }; + }) + .get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) => + { + const downlodDir = config.get('downloadPath'); + return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name)); + }, + { params: z.object({ id: z.string(), name: z.string() }) }) + .get('/details/emulator/:id', async ({ params: { id } }) => + { + const downlodDir = config.get('downloadPath'); + const emulatorPath = path.join(downlodDir, "store", "buckets", "emulators", `${id}.json`); + const emulatorScreenshotsPath = path.join(downlodDir, "store", "media", "screenshots", id); + const emulatorPackage = await EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8'))); + + const systems = await buildSystems(emulatorPackage); + let execPath: { path: string; type: string; } | undefined; + const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorPackage.name) }); + + if (esEmulator) + { + if (customEmulators.has(emulatorPackage?.name)) + { + execPath = { path: customEmulators.get(emulatorPackage.name), type: 'custom' }; + } else + { + execPath = await findExec(esEmulator); + } + } + + + const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; + const exists = !!execPath && await fs.exists(execPath.path); + const emulator: FrontEndEmulatorDetailed = { + ...emulatorPackage, + systems, + exists, + status: { + source: execPath?.type, + location: execPath?.path + }, + screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`) + }; + + return emulator; + }, { params: z.object({ id: z.string() }) }); \ No newline at end of file diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 46ab566..31741a4 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -2,7 +2,7 @@ import Elysia from "elysia"; import open from 'open'; import z from "zod"; import os from 'node:os'; -import { config, events } from "./app"; +import { cachePath, config, events } from "./app"; import { isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; @@ -11,6 +11,7 @@ import { DirSchema, DownloadsDrive } from "@/shared/constants"; import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; import si from 'systeminformation'; +import { getStoreFolder } from "./store/store"; export const system = new Elysia({ prefix: '/api/system' }) .post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) => @@ -48,7 +49,9 @@ export const system = new Elysia({ prefix: '/api/system' }) hostname: os.hostname(), steamDeck: process.env.SteamDeck, machine: os.machine(), - source + source, + cacheSize: (await fs.stat(cachePath)).size, + storeSize: (await getFolderSize(getStoreFolder())).size }; }) .get('/notifications', ({ set }) => diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 88002df..002f326 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -32,6 +32,7 @@ export class TaskQueue setTimeout(this.processQueue); }); return promise; + } return Promise.resolve(); } @@ -91,8 +92,10 @@ export class TaskQueue export interface EventsList { + started: [e: BaseEvent]; progress: [e: ProgressEvent]; abort: [e: AbortEvent]; + /** Called when the job successfully completes */ completed: [e: CompletedEvent]; error: [e: ErrorEvent]; ended: [e: BaseEvent]; @@ -101,7 +104,7 @@ export interface EventsList interface BaseEvent { id: string; - job: IJob; + job: IPublicJob; } interface ErrorEvent extends BaseEvent @@ -128,6 +131,7 @@ interface CompletedEvent extends BaseEvent export interface IJob { start (context: JobContext): Promise; + exposeData?(): any; } export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted'; @@ -137,7 +141,7 @@ export interface IPublicJob progress: number; state?: string; status: JobStatus; - job: any; + job: IJob; abort: (reason?: any) => void; } @@ -152,7 +156,7 @@ export class JobContext implements IPublicJob private error?: any; private events: EventEmitter; private abortController: AbortController; - private m_job: IJob; + private readonly m_job: IJob; constructor(id: string, events: EventEmitter, job: IJob) { @@ -162,7 +166,7 @@ export class JobContext implements IPublicJob 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.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this } satisfies AbortEvent); }); this.events = events; } @@ -171,19 +175,24 @@ export class JobContext implements IPublicJob { try { + this.events.emit('started', { id: this.m_id, job: this }); await this.m_job.start(this); this.completed = true; - this.events.emit('completed', { id: this.m_id, job: this.m_job }); + this.events.emit('completed', { id: this.m_id, job: this }); } catch (error) { - console.error(error); - this.events.emit('error', { id: this.m_id, job: this.m_job, error }); + if (error !== 'cancel') + { + console.error(error); + } + + this.events.emit('error', { id: this.m_id, job: this, error }); this.error = error; } finally { this.running = false; - this.events.emit('ended', { id: this.m_id, job: this.m_job }); + this.events.emit('ended', { id: this.m_id, job: this }); } } @@ -211,7 +220,7 @@ export class JobContext implements IPublicJob 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 }); + this.events.emit('progress', { id: this.m_id, progress, state: state ?? this.m_state, job: this }); } public abort (reason?: any) diff --git a/src/bun/browser.ts b/src/bun/browser.ts index 01744f7..14d3f27 100644 --- a/src/bun/browser.ts +++ b/src/bun/browser.ts @@ -1,32 +1,42 @@ import { killBrowser, spawnBrowser } from './utils/browser-spawner'; -import { BuildParams } from './utils/browser-params'; +import { BrowserParams, BuildParams } from './utils/browser-params'; import os from 'node:os'; import { EventEmitter } from 'node:stream'; -import { config } from './api/app'; -import { dirname } from 'node:path'; -export default async function init (events: EventEmitter, forceBrowser: boolean) +export default async function init (events: EventEmitter, forceBrowser: boolean, params: BrowserParams) { if (forceBrowser) { - await runBrowser(events); + await runBrowser(events, params); } else { try { - await runWebview(events); + await runWebview(events, params); } catch (error) { - await runBrowser(events); + await runBrowser(events, params); } } } -async function runWebview (events: EventEmitter) +async function runWebview (events: EventEmitter, params: BrowserParams) { - const webviewWorker = new Worker(new URL(`./webview/${os.platform()}`, import.meta.url).href, { + const webviewPath = process.env.IS_BINARY ? `./webview/${os.platform()}` : new URL(`./webview/${os.platform()}`, import.meta.url).href; + console.log("Launching Webview Worker at: ", webviewPath); + const config: Record = {}; + if (params.windowSize) + { + config.WINDOW_WIDTH = String(params.windowSize?.width); + config.WINDOW_HEIGHT = String(params.windowSize?.height); + } + const webviewWorker = new Worker(webviewPath, { smol: true, - ref: false + ref: false, + env: { + ...config, + ...process.env as any + } }); return new Promise((resolve, reject) => @@ -39,8 +49,9 @@ async function runWebview (events: EventEmitter) webviewWorker.addEventListener('message', (e) => { - if (e.data === 'destroyed') + if (e.data.data === 'destroyed') { + console.log("Webview Destroyed"); resolve(true); } }); @@ -48,14 +59,15 @@ async function runWebview (events: EventEmitter) events.on('exitapp', () => { resolve(true); + console.log("Terminating Webview Worker"); webviewWorker.terminate(); }); }); } -async function runBrowser (events: EventEmitter) +async function runBrowser (events: EventEmitter, params: BrowserParams) { - const browserParams = await BuildParams({ configPath: dirname(config.path) }); + const browserParams = await BuildParams(params); if (!browserParams) { console.error("Could not find valid browser"); @@ -72,7 +84,7 @@ async function runBrowser (events: EventEmitter) detached: false, execPath: browserParams.browser.path, source: browserParams.browser.source, - configPath: dirname(config.path), + configPath: params.configPath, ipc (message) { console.log(message); diff --git a/src/bun/index.ts b/src/bun/index.ts index 0bd929b..9ea71be 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -1,10 +1,12 @@ import { RunBunServer } from './server'; import { RunAPIServer } from './api/rpc'; -import { cleanup as appCleanup, events } from './api/app'; +import { cleanup as appCleanup, config, events } from './api/app'; import init from './browser'; +import { dirname } from 'pathe'; +import { createInterface } from 'readline'; const api = RunAPIServer(); -let bunServer: { stop: () => void; url: URL; } | undefined; +let bunServer: { stop: () => void; } | undefined; if (!Bun.env.PUBLIC_ACCESS) { @@ -16,21 +18,25 @@ async function cleanup () console.log("Cleaning Up"); await appCleanup(); bunServer?.stop(); - await api.apiServer.stop(); + await api.apiServer.stop(true); await api.cleanup(); + console.log("Finished Cleaning Up"); process.exit(0); } if (Bun.env.HEADLESS) { - // Called by outside force - process.on('message', ({ type }) => + const rl = createInterface({ input: process.stdin }); + + rl.on("line", async (line) => { - if (type === 'exitapp') + if (line.trim() === "shutdown") { - cleanup(); + console.log("Graceful Shutdown"); + await cleanup(); } }); + // Called by user events.on('exitapp', () => { @@ -39,7 +45,11 @@ if (Bun.env.HEADLESS) }); } else { - await init(events, !!Bun.env.FORCE_BROWSER); + await init(events, Bun.env.FORCE_BROWSER === "true", { + configPath: dirname(config.path), + windowPosition: config.get('windowPosition'), + windowSize: config.get('windowSize') + }); await cleanup(); } diff --git a/src/bun/server.ts b/src/bun/server.ts index 3d10da5..58fd64a 100644 --- a/src/bun/server.ts +++ b/src/bun/server.ts @@ -1,19 +1,44 @@ -import { SERVER_PORT } from "../shared/constants"; +import { SERVER_PORT } from "@shared/constants"; import path from 'node:path'; -import appInfo from '../../package.json'; +import appInfo from '~/package.json'; import { host } from "./utils/host"; import { appPath } from "./utils"; +import Elysia, { file } from "elysia"; +import cors from "@elysiajs/cors"; +import staticPlugin from "@elysiajs/static"; export function RunBunServer () { console.log("Launching Server on port ", SERVER_PORT); - return Bun.serve({ + return new Elysia() + .use(cors()) + .get("/", ({ set }) => + { + set.headers['cross-origin-opener-policy'] = 'same-origin'; + set.headers['cross-origin-embedder-policy'] = 'require-corp'; + return file("./dist/index.html"); + }) + .get('/emulatorjs', ({ set }) => + { + set.headers['cross-origin-opener-policy'] = 'same-origin'; + set.headers['cross-origin-embedder-policy'] = 'require-corp'; + set.headers['cross-origin-resource-policy'] = 'cross-origin'; + return file('./dist/emulatorjs/index.html'); + }) + .use(staticPlugin({ + indexHTML: false, + assets: "dist", + prefix: "/", + alwaysStatic: true + })).listen({ port: SERVER_PORT, hostname: host }, console.log); + /*return Bun.serve({ port: SERVER_PORT, hostname: host, routes: { "/": Bun.file(appPath("./dist/index.html")), // Serve a file by lazily loading it into memory "/favicon.ico": Bun.file(appPath("./dist/favicon.ico")), + "/emulatorjs/": Bun.file(appPath("./dist/emulatorjs/index.html")), "/.well-known/appspecific/com.chrome.devtools.json": new Response( JSON.stringify({ name: appInfo.name, @@ -33,5 +58,5 @@ export function RunBunServer () const url = new URL(req.url); return new Response(Bun.file(appPath(`./${path.join('dist', url.pathname)}`))); }, - }); + });*/ } \ No newline at end of file diff --git a/src/bun/types/types.d.ts b/src/bun/types/types.d.ts index 3cd0398..dd95180 100644 --- a/src/bun/types/types.d.ts +++ b/src/bun/types/types.d.ts @@ -7,4 +7,30 @@ export type ActiveGame = { gameId: number; name: string; command: string; -}; \ No newline at end of file +}; + +interface ObjectConstructor +{ + /** + * Groups members of an iterable according to the return value of the passed callback. + * @param items An iterable. + * @param keySelector A callback which will be invoked for each item in items. + */ + groupBy ( + items: Iterable, + keySelector: (item: T, index: number) => K, + ): Partial>; +} + +interface MapConstructor +{ + /** + * Groups members of an iterable according to the return value of the passed callback. + * @param items An iterable. + * @param keySelector A callback which will be invoked for each item in items. + */ + groupBy ( + items: Iterable, + keySelector: (item: T, index: number) => K, + ): Map; +} \ No newline at end of file diff --git a/src/bun/utils.ts b/src/bun/utils.ts index fd712e1..33a58cf 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -1,4 +1,3 @@ - import { $ } from 'bun'; import path from 'node:path'; diff --git a/src/bun/utils/browser-params.ts b/src/bun/utils/browser-params.ts index ea9e483..05efdf9 100644 --- a/src/bun/utils/browser-params.ts +++ b/src/bun/utils/browser-params.ts @@ -1,13 +1,19 @@ -import { SERVER_URL } from "../../shared/constants"; +import { SERVER_URL } from "@shared/constants"; import os from 'node:os'; import path from 'node:path'; import { getBrowserPath } from "./get-browser"; import { isSteamDeckGameMode } from "../utils"; -import { config } from "../api/app"; import { ensureDir } from 'fs-extra'; import { host } from "./host"; -export async function BuildParams (data: { configPath: string; }) +export interface BrowserParams +{ + configPath: string; + windowPosition?: { x: number, y: number; }; + windowSize?: { width?: number, height?: number; }; +} + +export async function BuildParams (data: BrowserParams) { const validBrowser = await getBrowserPath({ browserOrder: Bun.env.BROWSER_PRIORITY ? Bun.env.BROWSER_PRIORITY.split(',') as any : ['chrome', 'chromium'] @@ -52,9 +58,9 @@ export async function BuildParams (data: { configPath: string; }) if (isSteamDeckGameMode()) { args.push('--kiosk'); - } else + } else if (data.windowSize) { - args.push(`--window-size=${config.get('windowSize.width')},${config.get('windowSize.height')}`); + args.push(`--window-size=${data.windowSize.width},${data.windowSize.height}`); } args.push('--password-store=basic'); @@ -71,9 +77,9 @@ export async function BuildParams (data: { configPath: string; }) args.push('--remote-debugging-port=9222'); } - if (config.has('windowPosition')) + if (data.windowPosition) { - args.push(`--window-position=${config.get('windowPosition.x')},${config.get('windowPosition.y')}`); + args.push(`--window-position=${data.windowPosition.x},${data.windowPosition.y}`); } if (isEdge) diff --git a/src/bun/webview/base.ts b/src/bun/webview/base.ts index 91bfcb2..b9fc736 100644 --- a/src/bun/webview/base.ts +++ b/src/bun/webview/base.ts @@ -1,4 +1,4 @@ -import { SERVER_URL } from "@/shared/constants"; +import { SERVER_URL } from "@shared/constants"; import { host } from "../utils/host"; export default function (webview: { navigate: (url: string) => void; run: () => void; destroy: () => void; }) @@ -14,4 +14,5 @@ export default function (webview: { navigate: (url: string) => void; run: () => }; webview.navigate(SERVER_URL(host)); webview.run(); + postMessage({ data: 'destroyed' }); } \ No newline at end of file diff --git a/src/bun/webview/linux.ts b/src/bun/webview/linux.ts index d0a91af..c53ccca 100644 --- a/src/bun/webview/linux.ts +++ b/src/bun/webview/linux.ts @@ -1,4 +1,4 @@ -import { Webview } from 'webview-bun'; +import { Size, SizeHint, Webview } from 'webview-bun'; import webviewWorkerBase from "./base"; if (process.env.FLATPAK_BUILD === "true") @@ -28,6 +28,9 @@ if (process.env.FLATPAK_BUILD === "true") } else { console.log("Launching Webview"); - const webview = new Webview(import.meta.env.NODE_ENV === 'development'); + let size: Size | undefined = undefined; + if (process.env.WINDOW_WIDTH && process.env.WINDOW_HEIGHT) + size = { width: Number(process.env.WINDOW_WIDTH), height: Number(process.env.WINDOW_HEIGHT), hint: SizeHint.NONE }; + const webview = new Webview(process.env.NODE_ENV === 'development', size); webviewWorkerBase(webview); } \ No newline at end of file diff --git a/src/bun/webview/win32.ts b/src/bun/webview/win32.ts index bfb6404..6ef0a20 100644 --- a/src/bun/webview/win32.ts +++ b/src/bun/webview/win32.ts @@ -1,6 +1,9 @@ -import { Webview } from 'webview-bun'; +import { Size, SizeHint, Webview } from 'webview-bun'; import webviewWorkerBase from "./base"; -const webview = new Webview(import.meta.env.NODE_ENV === 'development'); +let size: Size | undefined = undefined; +if (process.env.WINDOW_WIDTH && process.env.WINDOW_HEIGHT) + size = { width: Number(process.env.WINDOW_WIDTH), height: Number(process.env.WINDOW_HEIGHT), hint: SizeHint.NONE }; +const webview = new Webview(process.env.NODE_ENV === 'development', size); webviewWorkerBase(webview); \ No newline at end of file diff --git a/src/mainview/components/AnimatedBackground.tsx b/src/mainview/components/AnimatedBackground.tsx index 482a674..6b936fd 100644 --- a/src/mainview/components/AnimatedBackground.tsx +++ b/src/mainview/components/AnimatedBackground.tsx @@ -1,11 +1,10 @@ import classNames from 'classnames'; -import { createContext, JSX, Ref, useContext, useEffect, useState } from 'react'; +import { CSSProperties, JSX, Ref, useEffect, useRef, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import { useSessionStorage } from 'usehooks-ts'; -import { useLocalSetting } from '../scripts/utils'; - -export const AnimatedBackgroundContext = createContext({} as { setBackground: (url: string) => void; }); +import { mobileCheck, useLocalSetting } from '../scripts/utils'; +import { AnimatedBackgroundContext } from '../scripts/contexts'; export function AnimatedBackground (data: { children?: any; @@ -15,26 +14,43 @@ export function AnimatedBackground (data: { className?: string; animated?: boolean, scrolling?: boolean; + style?: CSSProperties; }) { - const animateBackground = true; + const animateBackground = useLocalSetting('backgroundAnimation'); + const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? + useSessionStorage( + data.backgroundKey, + data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined, + ) + : useState(); - const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? useSessionStorage( - data.backgroundKey!, - data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined, - ) : useState(); + const [lastBackgroundUrl, setLastBackgroundUrl] = useState(undefined); + const backgroundElementRef = useRef(null); useEffect(() => { - setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined); + const lastBg = backgroundUrl; + + if (data.backgroundUrl != backgroundUrl) + { + setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined); + setLastBackgroundUrl(lastBg); + } }, [data.backgroundUrl]); - let finalBackgroundUrl; + let finalBackgroundUrl: URL | undefined; try { finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined; } catch { } + let finalLastBackgroundUrl: URL | undefined; + try + { + finalLastBackgroundUrl = lastBackgroundUrl ? new URL(lastBackgroundUrl) : undefined; + } catch { } + const blur = useLocalSetting('backgroundBlur'); if (blur) { @@ -43,11 +59,41 @@ export function AnimatedBackground (data: { finalBackgroundUrl?.searchParams.set('blur', String(24)); } + if (!finalLastBackgroundUrl?.searchParams.has('blur')) + { + finalLastBackgroundUrl?.searchParams.set('blur', String(24)); + } + finalBackgroundUrl?.searchParams.set('height', String(320)); + finalLastBackgroundUrl?.searchParams.set('height', String(320)); } + useEffect(() => + { + if (finalBackgroundUrl && backgroundElementRef.current) + { + const finalBackgroundImg = new Image(); + finalBackgroundImg.addEventListener('load', e => + { + if (backgroundElementRef.current) + { + backgroundElementRef.current.style.backgroundImage = `url('${finalBackgroundUrl.href}')`; + backgroundElementRef.current.style.opacity = "1"; + backgroundElementRef.current.style.backgroundSize = "100%"; + } + }); + finalBackgroundImg.src = finalBackgroundUrl.href; + } + + + }, [finalBackgroundUrl]); + + const isMobile = mobileCheck(); + function handleSetBackground (url: string) { + + setLastBackgroundUrl(backgroundUrl); setBackgroundUrl(url); } @@ -70,30 +116,40 @@ export function AnimatedBackground (data: { return (
- {!data.scrolling &&
- { +
+ {blur && finalLastBackgroundUrl && } + {finalBackgroundUrl ? e.currentTarget.classList.add(blur ? "animate-bg-zoom-big" : "animate-bg-zoom")} - >} -
+ > : <>
} +
+
} - {data.animated && animateBackground &&
+ {data.animated && animateBackground &&
{backgroundElements}
} {data.children} + {!!data.scrolling && <> +
+
+ }
); diff --git a/src/mainview/components/AutoFocus.tsx b/src/mainview/components/AutoFocus.tsx index 160b801..d29d626 100644 --- a/src/mainview/components/AutoFocus.tsx +++ b/src/mainview/components/AutoFocus.tsx @@ -1,13 +1,18 @@ import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { useEffect } from "react"; -export function AutoFocus (data: { focus: () => void; force?: boolean; delay?: number; }) +export function AutoFocus (data: { + parentKey?: string; + focus: () => void; + force?: boolean; + delay?: number; +}) { useEffect(() => { let delayTimeout: number | undefined; - if (data.force || !getCurrentFocusKey() || !doesFocusableExist(getCurrentFocusKey())) + if (data.force || !getCurrentFocusKey() || getCurrentFocusKey() === data.parentKey || !doesFocusableExist(getCurrentFocusKey())) { if (data.delay) { diff --git a/src/mainview/components/GameCard.tsx b/src/mainview/components/CardElement.tsx similarity index 64% rename from src/mainview/components/GameCard.tsx rename to src/mainview/components/CardElement.tsx index 14b844f..f098c5f 100644 --- a/src/mainview/components/GameCard.tsx +++ b/src/mainview/components/CardElement.tsx @@ -32,11 +32,10 @@ export interface GameCardParams className?: string; onFocus?: GameCardFocusHandler; onBlur?: (id: string) => void; - onAction?: () => void; clickFocuses?: boolean; } -export default function GameCard (data: GameCardParams) +export default function CardElement (data: GameCardParams & InteractParams) { const { ref, focused, focusSelf } = useFocusable({ focusKey: data.focusKey, @@ -57,40 +56,35 @@ export default function GameCard (data: GameCardParams) scrollSnapAlign: "center" }} onFocus={focusSelf} - onDoubleClick={data.onAction} + onDoubleClick={e => data.onAction?.(e.nativeEvent)} onClick={() => { focusSelf(); data.onAction?.(); }} className={twMerge( - `game-card bg-base-300 game-card-height flex flex-col justify-end z-5 ring-primary`, - '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", - classNames({ - "focused animate-wiggle ring-7 bg-base-content text-base-300 drop-shadow-xl drop-shadow-black/30 scale-102 z-10": focused && !isPointer, - "group hover:focused hover:animate-wiggle sm:hover:ring-4 md:hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isMouse, - "h-(--game-card-height)": typeof data.preview === "string" - }), + "relative game-card bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-xl focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none", data.className )} > -
{typeof data.preview === "string" ? ( - + ) : ( typeof data.preview === 'function' ? data.preview({ focused }) : data.preview - )}
+ )} +
-
+
{data.badges?.map((b, i) =>
) }
-
+
{data.title}
diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 684eba7..0605138 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -1,11 +1,10 @@ import { FocusContext, - FocusDetails, useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import { GameMeta } from "../../shared/constants"; -import GameCard, { GameCardFocusHandler, GameCardParams } from "./GameCard"; +import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement"; import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; @@ -47,7 +46,7 @@ export function CardList (data: { useShortcuts(g.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]); return ( - diff --git a/src/mainview/components/Clock.tsx b/src/mainview/components/Clock.tsx index 64fce83..8b71cfd 100644 --- a/src/mainview/components/Clock.tsx +++ b/src/mainview/components/Clock.tsx @@ -1,15 +1,19 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; -export default function Clock() { +export default function Clock () +{ const locale = "en"; const [today, setDate] = useState(new Date()); - useEffect(() => { - const timer = setInterval(() => { + useEffect(() => + { + const timer = setInterval(() => + { setDate(new Date()); }, 60 * 1000); - return () => { + return () => + { clearInterval(timer); }; }, []); diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 51d89d3..67a1716 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -4,13 +4,15 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { CardList, GameMetaExtra } from "./CardList"; import { SaveSource } from "../scripts/spatialNavigation"; -import { GameCardFocusHandler } from "./GameCard"; +import { GameCardFocusHandler } from "./CardElement"; +import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; export default function CollectionList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: GameCardFocusHandler; + onSelect?: (id: string) => void; }) { const navigate = useNavigate(); @@ -20,6 +22,12 @@ export default function CollectionList (data: { staleTime: DefaultRommStaleTime }); + const handleDefaultSelect = (id: string) => + { + SaveSource('game-list', { search: { focus: getCurrentFocusKey() } }); + navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } }); + }; + return ( ], } satisfies GameMetaExtra))} - onSelectGame={(id) => - { - SaveSource('game-list'); - navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } }); - }} + onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect} onGameFocus={(id, node, details) => { data.setBackground( diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index a801d14..fa0d97a 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -1,16 +1,16 @@ import { AnimatedBackground } from './AnimatedBackground'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { HeaderUI } from './Header'; -import { GameList, GameListFilter } from './GameList'; +import { GameList } from './GameList'; import { Search, Settings2 } from 'lucide-react'; import { JSX, Suspense } from 'react'; import Shortcuts from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { Router } from '..'; -import { PopSource } from '../scripts/spatialNavigation'; +import { PopNavigateSource, PopSource } from '../scripts/spatialNavigation'; import { GameListFilterType } from '@/shared/constants'; -import { GameCardFocusHandler } from './GameCard'; +import { GameCardFocusHandler } from './CardElement'; export interface CollectionsDetailParams { @@ -22,16 +22,6 @@ export interface CollectionsDetailParams footer?: JSX.Element; } -function HandleGoBack () -{ - const source = PopSource('game-list'); - if (source) - { - console.log("Found source ", source, " to go back to"); - } - Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } }); -} - export function CollectionsDetail (data: CollectionsDetailParams) { const focusKey = `game-list-${data.id}-${data.filters ? Object.values(data.filters).map(f => String(f)).join(",") : ''}`; @@ -40,7 +30,7 @@ export function CollectionsDetail (data: CollectionsDetailParams) preferredChildFocusKey: `${focusKey}-list`, }); - useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => PopNavigateSource('game-list', '/') }]); const { shortcuts } = useShortcutContext(); const handleScroll: GameCardFocusHandler = (id, node, details) => diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index 12e9f21..ff429c6 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -1,14 +1,10 @@ -import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; -import { createContext, JSX, useContext, useEffect } from "react"; +import { JSX, useContext, useEffect } from "react"; import { twMerge } from "tailwind-merge"; import { X } from "lucide-react"; import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; - -const ContextDialogContext = createContext({} as { - close: () => void, - id: string; -}); +import { ContextDialogContext } from "../scripts/contexts"; export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; }) { @@ -35,12 +31,12 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class trackChildren: typeof data.content !== 'string' }); const colors = { - primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused || hasFocusedChild }), - secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused || hasFocusedChild }), - accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused || hasFocusedChild }), - info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused || hasFocusedChild }), - warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused || hasFocusedChild }), - error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused || hasFocusedChild }) + primary: "active:bg-primary control-pointer:hover:bg-primary focused:bg-primary focused:text-primary-content in-focused:bg-primary in-focused:text-primary-content", + secondary: "active:bg-secondary control-pointer:hover:bg-secondary focused:bg-secondary focused:text-secondary-content in-focused:bg-secondary in-focused:text-secondary-content", + accent: "active:bg-accent control-pointer:hover:bg-accent focused:bg-accent focused:text-accent-content in-focused:bg-accent in-focused:text-accent-content", + info: "active:bg-info control-pointer:hover:bg-info focused:bg-info focused:text-info-content in-focused:bg-info in-focused:text-info-content", + warning: "active:bg-warning control-pointer:hover:bg-warning focused:bg-warning focused:text-warning-content in-focused:bg-warning in-focused:text-warning-content", + error: "active:bg-error control-pointer:hover:bg-error focused:bg-error focused:text-error-content in-focused:bg-error in-focused:text-error-content" }; if (data.shortcuts) { @@ -51,8 +47,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class className={ twMerge("flex cursor-pointer sm:text-sm md:text-base")}> -
{data.icon} @@ -105,7 +100,7 @@ export function ContextDialog (data: { }] : [], [data.open]); return diff --git a/src/mainview/components/Error.tsx b/src/mainview/components/Error.tsx new file mode 100644 index 0000000..fe824db --- /dev/null +++ b/src/mainview/components/Error.tsx @@ -0,0 +1,33 @@ +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { Home, TriangleAlert } from "lucide-react"; +import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; +import { Router } from ".."; +import Shortcuts from "./Shortcuts"; +import { Button } from "./options/Button"; +import { useEffect } from "react"; +import { ErrorComponentProps } from "@tanstack/react-router"; +import { mobileCheck } from "../scripts/utils"; + +export default function Error (data: ErrorComponentProps) +{ + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" }); + const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); + useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]); + const { shortcuts } = useShortcutContext(); + + useEffect(() => { focusSelf(); }, []); + + return
+ +

+ + {data.error.message} +

+

{window.location.href}

+ +
+
+
+
+
; +} \ No newline at end of file diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index 07a0805..2c369a6 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -1,11 +1,11 @@ -import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; -import { ContextList, DialogEntry, OptionElement } from "./ContextDialog"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { ContextList, DialogEntry } from "./ContextDialog"; import { systemApi } from "../scripts/clientApi"; -import { createContext, useContext, useRef, useState } from "react"; +import { useContext, useRef, useState } from "react"; import path from "pathe"; -import { Check, File, Folder, FolderClosed, FolderInput, FolderOutput, FolderPlus, HardDrive, Plus, Save, Undo, Usb, X } from "lucide-react"; +import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { DirType, Drive } from "@/shared/constants"; +import { DirType } from "@/shared/constants"; import classNames from "classnames"; import { twMerge } from "tailwind-merge"; import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; @@ -13,17 +13,8 @@ import SvgIcon from "./SvgIcon"; import { Button } from "./options/Button"; import toast from "react-hot-toast"; import { drivesQuery, filesQuery } from "../scripts/queries"; - -const FilePickerContext = createContext<{ - allowNewFolderCreation: boolean; - isDirectoryPicker: boolean; - setCurrentPath: (path: string) => void; - currentPath: string | undefined, - startingPath: string | undefined; - refetchFiles: () => void; - drives: Drive[], - activeDrive: Drive | undefined; -}>({} as any); +import { FilePickerContext } from "../scripts/contexts"; +import useActiveControl from "../scripts/gamepads"; function List (data: { id: string, @@ -137,7 +128,7 @@ function NewFolderOption (data: { id: string, dirname: string; }) }); return
- +
; } @@ -149,7 +140,7 @@ function OptionButtons (data: { }) { const { ref, focusKey } = useFocusable({ focusKey: `options-${data.id}`, onEnterPress: data.onSelect }); - return
+ return
{data.showConfirm && } @@ -252,6 +243,8 @@ export default function FilePicker (data: { [<>{activeDrive?.label}, ...fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep)] : fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep); + const { isPointer } = useActiveControl(); + return
{p} )} + + {(filesLoading || drivesLoading) &&
  • } - {(filesLoading || drivesLoading) && }
    } - currentPath ? data.onSelect(currentPath) : undefined} - id={data.id} /> + id={data.id} />}
    ; } \ No newline at end of file diff --git a/src/mainview/components/Filters.tsx b/src/mainview/components/Filters.tsx index f391f9a..73796e6 100644 --- a/src/mainview/components/Filters.tsx +++ b/src/mainview/components/Filters.tsx @@ -4,10 +4,7 @@ import useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import SvgIcon from "./SvgIcon"; -import classNames from "classnames"; -import { useSearch } from "@tanstack/react-router"; -import { useEffect } from "react"; -import useActiveControl from "../scripts/gamepads"; +import { twMerge } from "tailwind-merge"; function FilterCat ( data: { @@ -25,31 +22,12 @@ function FilterCat ( onEnterPress: data.onAction }); - const { filter } = useSearch({ from: '/' }); - useEffect(() => - { - if (filter == data.id && data.hasFocusedPeer) - { - focusSelf(); - } - }, [filter]); - - const { isMouse } = useActiveControl(); - return (
  • {data.children ?? data.label}
  • @@ -59,35 +37,37 @@ function FilterCat ( export function FilterUI (data: { id: string; options: Record; - selected: string; setSelected: (id: string) => void; + containerClassName?: string; + className?: string; }) { + const defaultFocus = Object.entries(data.options).filter(o => o[1].selected)[0]?.[0]; const { ref, focusKey, hasFocusedChild } = useFocusable({ - focusKey: `filter-${data.id}`, + focusKey: data.id, saveLastFocusedChild: false, autoRestoreFocus: false, - preferredChildFocusKey: data.selected, + preferredChildFocusKey: `${data.id}-${defaultFocus}`, trackChildren: true }); return (
    -
      +
      • {Object.entries(data.options)?.map(([id, option]) => ( data.setSelected(id)} - active={id === data.selected} + active={option.selected} {...option} /> ))} diff --git a/src/mainview/components/FocusDots.tsx b/src/mainview/components/FocusDots.tsx new file mode 100644 index 0000000..d42d945 --- /dev/null +++ b/src/mainview/components/FocusDots.tsx @@ -0,0 +1,21 @@ +import { setFocus } from "@noriginmedia/norigin-spatial-navigation"; +import classNames from "classnames"; +import { twMerge } from "tailwind-merge"; +import { useGlobalFocus } from "../scripts/spatialNavigation"; + +export default function FocusDots (data: { + elements: string[]; + +}) +{ + const focusedKey = useGlobalFocus(); + + return
        {data.elements.map((em, i) => + { + const focused = em === focusedKey; + return ; + })}
        ; +} \ No newline at end of file diff --git a/src/mainview/components/FrontEndGameCard.tsx b/src/mainview/components/FrontEndGameCard.tsx new file mode 100644 index 0000000..774ea6e --- /dev/null +++ b/src/mainview/components/FrontEndGameCard.tsx @@ -0,0 +1,46 @@ +import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants"; +import CardElement from "./CardElement"; +import { SaveSource } from "../scripts/spatialNavigation"; +import { Router } from ".."; +import { HardDrive } from "lucide-react"; +import { JSX } from "react"; +import { FOCUS_KEYS } from "../scripts/types"; + +export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; } & FocusParams & InteractParams) +{ + function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null) + { + SaveSource('details', { search: { focus: FOCUS_KEYS.GAME_CARD(data.game.id.id) } }); + console.log({ id: String(sourceId ?? id.id), source: source ?? id.source }); + Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } }); + }; + + const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + const subtitle =
        + {!!data.game.path_platform_cover && } +

        {data.game.platform_display_name}

        +
        ; + + const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_cover}`); + previewUrl.searchParams.delete('ts'); + previewUrl.searchParams.set('width', "640"); + + const badges: JSX.Element[] = []; + if (data.game.id.source === 'local') + { + badges.push(); + } + + return data.onAction ? data.onAction(e) : handleDefaultSelect(data.game.id, data.game.source, data.game.source_id)} + preview={previewUrl.href} + title={data.game.name ?? ""} + subtitle={subtitle} + focusKey={FOCUS_KEYS.GAME_CARD(data.game.id.id)} + index={data.index} + id={`game-${data.game.id.source}-${data.game.id.id}`} + />; +} \ No newline at end of file diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index 12c0b3e..f1eb533 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -6,8 +6,7 @@ import { SaveSource } from "../scripts/spatialNavigation"; import { rommApi } from "../scripts/clientApi"; import { HardDrive } from "lucide-react"; import { JSX } from "react"; -import { GameCardFocusHandler } from "./GameCard"; -import { gameQuery } from "../scripts/queries"; +import { GameCardFocusHandler } from "./CardElement"; import { useLocalSetting } from "../scripts/utils"; export interface GameListParams @@ -16,7 +15,7 @@ export interface GameListParams filters?: GameListFilterType, grid?: boolean, setBackground?: (url: string) => void; - onGameSelect?: (id: FrontEndId) => void; + onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void; onFocus?: GameCardFocusHandler; className?: string; } @@ -33,7 +32,7 @@ export function GameList (data: GameListParams) const queryClient = useQueryClient(); const blur = useLocalSetting('backgroundBlur'); - const handleFocus = (id: FrontEndId, source: string | null, sourceId: number | null) => + const handleFocus = (id: FrontEndId, source: string | null, sourceId: string | null) => { const game = games.data?.games.find((g) => g.id === id); if (game) @@ -52,7 +51,7 @@ export function GameList (data: GameListParams) } }; - function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: number | null) + function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null) { SaveSource('details'); navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } }); @@ -73,11 +72,11 @@ export function GameList (data: GameListParams) const badges: JSX.Element[] = []; if (g.id.source === 'local') { - badges.push(); + badges.push(); } const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); previewUrl.searchParams.delete('ts'); - previewUrl.searchParams.set('width', "640"); + previewUrl.searchParams.set('width', "16"); const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); platformUrl.searchParams.set('width', "64"); @@ -93,7 +92,7 @@ export function GameList (data: GameListParams) ), previewUrl: previewUrl.href, badges: badges, - onSelect: () => data.onGameSelect ? data.onGameSelect(g.id) : handleDefaultSelect(g.id, g.source, g.source_id), + onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g.id, g.source, g.source_id), onFocus: () => handleFocus(g.id, g.source, g.source_id) } satisfies GameMetaExtra; }, diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 928fb04..272c968 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -22,10 +22,10 @@ import } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { useQuery } from "@tanstack/react-query"; -import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen"; +import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen"; import { RPC_URL } from "../../shared/constants"; import { JSX, useEffect, useRef } from "react"; -import { SaveSource } from "../scripts/spatialNavigation"; +import { SaveSource, useFocusableDynamic } from "../scripts/spatialNavigation"; import { systemApi } from "../scripts/clientApi"; import { Router } from ".."; @@ -54,14 +54,14 @@ function HeaderAvatar (data: { id={data.id} ref={ref} onClick={data.onSelect} + style={{ viewTransitionName: `header-account-${data.id}` }} className={classNames( - `avatar indicator ring-base-100 ring-offset-base-100 sm:size-8 md:size-14 rounded-full flex items-center justify-center`, + `avatar indicator ring-offset-base-100 sm:size-8 md:size-14 rounded-full flex items-center justify-center`, bgColors[data.type ?? "none"], "text-base-content cursor-pointer transition-all drop-shadow-md", - "hover:ring-primary hover:ring-7", + "hover:ring-primary hover:ring-7 focusable focusable-primary focused:ring-offset-base-100", { "ring-5 hover:ring-offset-5": data.active, - "sm:ring-4 md:ring-7 ring-primary ring-offset-base-100": focused, "ring-offset-5": focused && data.active, }, data.className, @@ -276,7 +276,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; }) { return
        -
        +
        @@ -289,22 +289,29 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement key={b.id} className="header-icon sm:size-10 md:size-16" id={b.id} - icon={b.icon} external={b.external} - action={b.action} - />)} + style={{ viewTransitionName: `header-button-${b.id}` }} + onAction={b.action} + >{b.icon})}
        ; } -export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[]; buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; }) +export function HeaderUI (data: { + buttons?: HeaderButton[]; + accounts?: HeaderAccount[]; + buttonElements?: JSX.Element[] | JSX.Element; + title?: JSX.Element; + preferredChildFocusKey?: string; +}) { - const { ref, focusKey } = useFocusable({ focusKey: "header-elements" }); + const { ref, focusKey } = useFocusable({ focusKey: "header-elements", preferredChildFocusKey: data.preferredChildFocusKey }); return (
        {data.title} diff --git a/src/mainview/components/LoadingCardList.tsx b/src/mainview/components/LoadingCardList.tsx index f381f70..a48b491 100644 --- a/src/mainview/components/LoadingCardList.tsx +++ b/src/mainview/components/LoadingCardList.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { GameCardSkeleton } from './GameCard'; +import { GameCardSkeleton } from './CardElement'; export default function LoadingCardList (data: { placeholderCount: number, grid?: boolean; }) { diff --git a/src/mainview/components/NotFound.tsx b/src/mainview/components/NotFound.tsx new file mode 100644 index 0000000..ec0a638 --- /dev/null +++ b/src/mainview/components/NotFound.tsx @@ -0,0 +1,31 @@ +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { Home, TriangleAlert } from "lucide-react"; +import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts"; +import { Router } from ".."; +import Shortcuts from "./Shortcuts"; +import { Button } from "./options/Button"; +import { useEffect } from "react"; + +export default function NotFound () +{ + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" }); + const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } }); + useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]); + const { shortcuts } = useShortcutContext(); + + useEffect(() => { focusSelf(); }, []); + + return
        + +

        + + Not found +

        +

        {window.location.href}

        + +
        +
        +
        +
        +
        ; +} \ No newline at end of file diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 600efc1..a4503fb 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -1,16 +1,25 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants"; +import { DefaultRommStaleTime, RPC_URL } from "@shared/constants"; import { CardList, GameMetaExtra } from "./CardList"; -import classNames from "classnames"; import { rommApi } from "../scripts/clientApi"; import { SaveSource } from "../scripts/spatialNavigation"; import { JSX, useMemo } from "react"; import { HardDrive } from "lucide-react"; -import { GameCardFocusHandler } from "./GameCard"; +import { GameCardFocusHandler } from "./CardElement"; +import { mobileCheck } from "../scripts/utils"; +import { twMerge } from "tailwind-merge"; -export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: GameCardFocusHandler; grid?: boolean; }) +export function PlatformsList (data: { + id: string, + setBackground: (url: string) => void; + className?: string; + onFocus?: GameCardFocusHandler; + grid?: boolean; + onSelect?: (source: string, id: string) => void; +}) { + const isMobile = mobileCheck(); const navigate = useNavigate(); const { data: platforms } = useSuspenseQuery( { @@ -25,6 +34,12 @@ export function PlatformsList (data: { id: string, setBackground: (url: string) staleTime: DefaultRommStaleTime, }); + const handleDefaultSelect = (source: string, id: string) => + { + SaveSource('game-list'); + navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } }); + }; + const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime()) .map((g, i) => { @@ -44,13 +59,9 @@ export function PlatformsList (data: { id: string, setBackground: (url: string) onFocus: () => data.setBackground( g.paths_screenshots.length > 0 ? `${RPC_URL(__HOST__)}${g.paths_screenshots[new Date().getMinutes() % g.paths_screenshots.length]}` : `${RPC_URL(__HOST__)}/api/romm/image?url=https://picsum.photos/id/${10 + i}/1280/720.webp`, ), - onSelect: () => - { - SaveSource('game-list'); - navigate({ to: `/platform/${g.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } }); - }, + onSelect: () => data.onSelect ? data.onSelect(g.id.source, g.id.id) : handleDefaultSelect(g.id.source, g.id.id), preview: - ({ focused }) =>
        -
        @@ -76,7 +87,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string) type="platform" id={data.id} grid={data.grid} - className={data.className} + className={twMerge('*:aspect-8/10! md:py-12', data.className)} onGameFocus={data.onFocus} games={platformsMapped} onSelectGame={(id) => diff --git a/src/mainview/components/RoundButton.tsx b/src/mainview/components/RoundButton.tsx index 68ef701..3f1c315 100644 --- a/src/mainview/components/RoundButton.tsx +++ b/src/mainview/components/RoundButton.tsx @@ -1,39 +1,20 @@ -import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import classNames from "classnames"; -import { JSX } from "react"; +import { CSSProperties, JSX } from "react"; import { twMerge } from 'tailwind-merge'; +import { Button, ButtonStyle } from "./options/Button"; export function RoundButton (data: { id: string; - icon: JSX.Element; + children?: any; className?: string; external?: boolean; - action?: () => void; -}) + style?: ButtonStyle; +} & InteractParams & FocusParams) { - const { ref, focused } = useFocusable({ - focusKey: data.id, - onEnterPress: data.action, - }); + return ( -
        - {data.icon} -
        + + ); } diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx new file mode 100644 index 0000000..3a760e8 --- /dev/null +++ b/src/mainview/components/Screenshots.tsx @@ -0,0 +1,49 @@ +import { RPC_URL } from "@/shared/constants"; +import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { useRef, useState } from "react"; +import FocusDots from "./FocusDots"; +import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils"; +import { Fullscreen } from "lucide-react"; + +function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; }) +{ + const imageRef = useRef(null); + const { ref, focused, focusSelf } = useFocusable({ + focusKey: `screenshot-${data.index}`, + onEnterPress: () => (ref.current as HTMLElement).requestFullscreen(), + onFocus: (e, p, details) => + { + data.setFocused?.(data.index); + scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' }); + } + }); 4096; + return
        + focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" /> +
        imageRef.current?.requestFullscreen()}>
        +
        ; +} + +export default function Screenshots (data: { screenshots: string[]; } & FocusParams) +{ + const scrollRef = useRef(null); + const { ref, focusKey } = useFocusable({ + focusKey: 'screenshot-list', + onFocus: (e, p, details) => + { + data.onFocus?.(focusKey, ref.current, details); + } + }); + useDragScroll(scrollRef); + + return
        + +
        + {data.screenshots.map((s, i) => )} +
        + `screenshot-${i}`)} /> +
        +
        ; +} \ No newline at end of file diff --git a/src/mainview/components/ShortcutPrompt.tsx b/src/mainview/components/ShortcutPrompt.tsx index 9b29a50..63a4ffd 100644 --- a/src/mainview/components/ShortcutPrompt.tsx +++ b/src/mainview/components/ShortcutPrompt.tsx @@ -16,7 +16,7 @@ export default function ShortcutPrompt (data: { onClick={data.onClick} style={{ viewTransitionName: data.id }} className={twMerge("xs:text-xs sm:p-1 sm:text-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", + "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 active:text-base-300 active:bg-base-content", data.className, classNames({ "hover:bg-base-300 cursor-pointer": !!data.onClick, diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index dbea853..a1253c5 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -48,7 +48,7 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) const { control } = useActiveControl(); const showKeyboard = control === 'keyboard' || control === 'mouse'; return ( -
        +
        {data.shortcuts?.filter(s => !!s.label).map((s, i) => data.onFocus?.(focusKey, ref.current, details), focusable: !data.disabled }); @@ -31,9 +45,10 @@ export function Button (data: { return + }} className={'flex items-center justify-center border h-10 border-base-content/30 px-4 py-2 rounded-full cursor-pointer grow not-in-focused:bg-base-200 focusable focusable-accent hover:border-base-content hover:bg-base-content hover:text-base-300'}>{data.value} {open && ({ diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index f279466..d3de509 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -1,10 +1,9 @@ -import classNames from "classnames"; -import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react"; +import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react"; import { twMerge } from "tailwind-merge"; import { useOptionContext } from "./OptionSpace"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { systemApi } from "../../scripts/clientApi"; -import { Check, CheckIcon, X } from "lucide-react"; +import { CheckIcon, X } from "lucide-react"; export function OptionInput (data: { name: string; @@ -52,11 +51,8 @@ export function OptionInput (data: { }; return ( -