From 56e1fc213720c3c8613187ed91d0fe908ddd184b Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sat, 29 Aug 2026 23:39:21 +0300 Subject: [PATCH] refactor(web-panel): migrate to biome, drop baseUrl, normalize eol --- .gitattributes | 42 +- .gitignore | 4 +- web-panel/.prettierignore | 7 - web-panel/.prettierrc | 11 - web-panel/biome.json | 106 ++ web-panel/bridge/build.mjs | 68 +- web-panel/bridge/dev-server.mjs | 333 +++--- web-panel/bridge/package.json | 2 +- .../bridge/scripts/default/activate-pro.js | 4 +- .../scripts/default/activate-pro/index.js | 117 +- .../scripts/default/installed-apps-sync.js | 4 +- .../default/installed-apps-sync/artwork.js | 414 ++++--- .../default/installed-apps-sync/constants.js | 186 ++- .../installed-apps-sync/game-status.js | 506 ++++---- .../default/installed-apps-sync/index.js | 706 ++++++----- .../installed-apps-sync/installed-data.js | 903 +++++++------- .../default/installed-apps-sync/logger.js | 67 +- .../installed-apps-sync/remote-commands.js | 392 +++--- .../default/installed-apps-sync/runtime.js | 532 +++++---- .../default/installed-apps-sync/services.js | 420 ++++--- .../scripts/default/remote-popup-cleanup.js | 153 ++- .../remote-popup-cleanup/qr-renderer.js | 19 +- web-panel/bridge/scripts/package.json | 3 + web-panel/bridge/src/bridge-state.ts | 57 +- web-panel/bridge/src/index.ts | 4 +- web-panel/bridge/src/logger.ts | 17 +- .../bridge/src/normalizers/command-results.ts | 21 +- .../bridge/src/normalizers/game-status.ts | 6 +- web-panel/bridge/src/normalizers/index.ts | 65 +- .../bridge/src/normalizers/trainer.test.ts | 20 +- web-panel/bridge/src/normalizers/trainer.ts | 2 +- web-panel/bridge/src/protocol-router.test.ts | 68 +- web-panel/bridge/src/protocol-router.ts | 19 +- web-panel/bridge/src/renderer-scripts.test.ts | 64 +- .../bridge/src/runtime.integration.test.ts | 393 +++--- web-panel/bridge/src/runtime.ts | 1 + web-panel/bridge/src/server-files.ts | 26 +- web-panel/bridge/src/server.ts | 244 ++-- web-panel/bridge/src/types.ts | 18 +- web-panel/bridge/src/wand/renderer-scripts.ts | 43 +- web-panel/bridge/src/wand/runtime.ts | 107 +- .../src/wand/trainer-localization.test.ts | 173 +-- .../bridge/src/wand/trainer-localization.ts | 360 +++--- web-panel/bridge/src/websocket-codec.ts | 23 +- web-panel/bridge/tsconfig.json | 26 +- web-panel/components.json | 46 +- web-panel/eslint.config.js | 34 - web-panel/lingui.config.ts | 18 +- web-panel/package.json | 105 +- web-panel/pnpm-lock.yaml | 1051 ++--------------- web-panel/protocol/messages.ts | 5 +- web-panel/protocol/validation.test.ts | 50 +- web-panel/protocol/validation.ts | 169 +-- web-panel/protocol/web-contract.json | 22 +- .../scripts/custom/remote-access-ping.js | 12 +- web-panel/scripts/default/ipc-logger.js | 99 +- web-panel/scripts/verify-dist.mjs | 90 +- web-panel/src/app/app.tsx | 242 ++-- web-panel/src/app/i18n.ts | 57 +- web-panel/src/app/main.tsx | 24 +- web-panel/src/app/ui/FloatingDock.tsx | 112 +- web-panel/src/app/ui/PlaceholderState.tsx | 40 +- web-panel/src/app/ui/SessionPlaceholder.tsx | 66 +- web-panel/src/app/ui/SettingsDrawer.tsx | 421 ++++--- web-panel/src/app/ui/StatusPill.tsx | 58 +- web-panel/src/app/ui/TopBar.tsx | 68 +- web-panel/src/app/ui/session-states.test.tsx | 73 +- web-panel/src/app/use-dock-auto-hide.ts | 26 +- web-panel/src/app/use-remote-panel.ts | 237 ++-- .../src/appearance/appearance-storage.ts | 36 +- web-panel/src/index.css | 41 +- web-panel/src/library/model/games.test.ts | 73 +- web-panel/src/library/model/games.ts | 50 +- .../library/pinned-games/game-pin-storage.ts | 23 +- .../src/library/pinned-games/use-game-pins.ts | 24 +- web-panel/src/library/ui/GameCover.tsx | 48 +- web-panel/src/library/ui/LibraryDrawer.tsx | 335 ++++-- web-panel/src/po.d.ts | 4 +- .../remote-session/remote-session.client.ts | 220 ++-- .../remote-session/remote-session.protocol.ts | 88 +- .../remote-session.reducer.test.ts | 184 +-- .../remote-session/remote-session.reducer.ts | 325 ++--- .../src/remote-session/remote-session.urls.ts | 25 +- web-panel/src/remote-session/selectors.ts | 4 +- .../src/remote-session/use-remote-session.ts | 360 +++--- web-panel/src/shared/lib/ui.ts | 69 +- web-panel/src/shared/storage.test.ts | 52 +- web-panel/src/shared/storage.ts | 154 +-- web-panel/src/shared/ui/Drawer.tsx | 174 +-- web-panel/src/shared/ui/Icon.tsx | 357 ++++-- web-panel/src/shared/ui/IconButton.tsx | 111 +- web-panel/src/shared/ui/SearchInput.tsx | 66 +- .../src/trainer/controls/ActionButton.tsx | 28 +- .../src/trainer/controls/CheatControl.tsx | 40 +- .../trainer/controls/IncrementalControl.tsx | 58 +- .../src/trainer/controls/NumberControl.tsx | 92 +- .../src/trainer/controls/ScalarControl.tsx | 79 +- .../src/trainer/controls/SelectionControl.tsx | 181 +-- .../src/trainer/controls/SliderControl.tsx | 34 +- .../src/trainer/controls/ToggleControl.tsx | 38 +- .../src/trainer/controls/format-number.ts | 42 +- web-panel/src/trainer/controls/shared.tsx | 169 +-- web-panel/src/trainer/controls/step.ts | 18 +- .../src/trainer/model/categories.test.ts | 62 +- web-panel/src/trainer/model/categories.ts | 103 +- web-panel/src/trainer/model/values.ts | 43 +- .../pinned-cheats/use-pinned-cheats.ts | 36 +- .../trainer/presets/preset-storage.test.ts | 53 +- .../src/trainer/presets/preset-storage.ts | 120 +- web-panel/src/trainer/presets/use-presets.ts | 94 +- web-panel/src/trainer/ui/CategoryIcon.tsx | 42 +- web-panel/src/trainer/ui/CategorySection.tsx | 240 ++-- web-panel/src/trainer/ui/CheatTile.tsx | 354 +++--- web-panel/src/trainer/ui/QuickActions.tsx | 313 ++--- web-panel/src/trainer/ui/TrainerHeader.tsx | 100 +- web-panel/src/trainer/ui/category-labels.ts | 34 +- web-panel/tsconfig.app.json | 21 +- web-panel/tsconfig.json | 23 +- web-panel/tsconfig.node.json | 44 +- web-panel/vite.config.ts | 72 +- web-panel/vitest.config.ts | 26 +- 121 files changed, 7853 insertions(+), 7410 deletions(-) delete mode 100644 web-panel/.prettierignore delete mode 100644 web-panel/.prettierrc create mode 100644 web-panel/biome.json create mode 100644 web-panel/bridge/scripts/package.json delete mode 100644 web-panel/eslint.config.js diff --git a/.gitattributes b/.gitattributes index 3c72233..03757c2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,44 @@ +# Auto-detect text files and normalize line endings to LF +* text=auto eol=lf + +# Web panel & source files +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.json text eol=lf +*.css text eol=lf +*.html text eol=lf +*.md text eol=lf +*.svg text eol=lf +*.po text eol=lf + +# Windows scripts / C# solutions +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=lf +*.cs text eol=crlf +*.sln text eol=crlf +*.csproj text eol=crlf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.dll binary +*.exe binary +*.zip binary + +# GitHub Linguist # The web panel is the bundled frontend shipped inside the C# patcher. # Mark it as vendored so GitHub Linguist keeps it out of the repository's -# language statistics — the project is a C# app, not a TypeScript one. +# language statistics - the project is a C# app, not a TypeScript one. web-panel/** linguist-vendored diff --git a/.gitignore b/.gitignore index f782a44..75af64a 100644 --- a/.gitignore +++ b/.gitignore @@ -145,4 +145,6 @@ appsettings.json *DotSettings.user .tmp .source -web-panel/bridge/wand-remote-bridge.cjs \ No newline at end of file +web-panel/bridge/wand-remote-bridge.cjs +.vscode +docs \ No newline at end of file diff --git a/web-panel/.prettierignore b/web-panel/.prettierignore deleted file mode 100644 index 0b4a1db..0000000 --- a/web-panel/.prettierignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules/ -coverage/ -.pnpm-store/ -pnpm-lock.yaml -package-lock.json -pnpm-lock.yaml -yarn.lock diff --git a/web-panel/.prettierrc b/web-panel/.prettierrc deleted file mode 100644 index 9000bfa..0000000 --- a/web-panel/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "endOfLine": "lf", - "semi": false, - "singleQuote": false, - "tabWidth": 2, - "trailingComma": "es5", - "printWidth": 80, - "plugins": ["prettier-plugin-tailwindcss"], - "tailwindStylesheet": "src/index.css", - "tailwindFunctions": ["cn", "cva"] -} diff --git a/web-panel/biome.json b/web-panel/biome.json new file mode 100644 index 0000000..5129e52 --- /dev/null +++ b/web-panel/biome.json @@ -0,0 +1,106 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": [ + "src/**", + "protocol/**", + "bridge/**", + "scripts/**", + "*.ts", + "*.js", + "*.json", + "*.html", + "*.css", + "!**/dist", + "!**/node_modules", + "!src/locales", + "!fixtures" + ] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 4, + "lineEnding": "lf", + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "all", + "indentStyle": "space", + "indentWidth": 4 + } + }, + "json": { + "formatter": { + "indentStyle": "space", + "indentWidth": 4 + } + }, + "css": { + "parser": { + "tailwindDirectives": true + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 4 + } + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "correctness": { + "noUnusedVariables": "warn" + }, + "style": { + "noNonNullAssertion": "off", + "noDescendingSpecificity": "off" + }, + "suspicious": { + "noExplicitAny": "off" + } + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": ["bridge/scripts/**", "scripts/**"], + "linter": { + "rules": { + "correctness": { + "noUnusedVariables": "off", + "noUnusedFunctionParameters": "off" + } + } + } + }, + { + "includes": ["bridge/src/**"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ] +} diff --git a/web-panel/bridge/build.mjs b/web-panel/bridge/build.mjs index c56e4ed..6503d4d 100644 --- a/web-panel/bridge/build.mjs +++ b/web-panel/bridge/build.mjs @@ -1,63 +1,59 @@ -import { build } from "esbuild" -import { readdir } from "node:fs/promises" -import { dirname, resolve } from "node:path" -import { fileURLToPath } from "node:url" +import { readdir } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; -const bridgeRoot = dirname(fileURLToPath(import.meta.url)) -const webPanelRoot = resolve(bridgeRoot, "..") -const distRoot = resolve(webPanelRoot, "dist") -const bridgeEntryPoint = resolve(bridgeRoot, "src", "index.ts") -const bridgeOutfile = resolve(distRoot, "bridge.cjs") -const rendererScriptsRoot = resolve(bridgeRoot, "scripts", "default") -const rendererScriptsOutdir = resolve(distRoot, "renderer-scripts") +const bridgeRoot = dirname(fileURLToPath(import.meta.url)); +const webPanelRoot = resolve(bridgeRoot, '..'); +const distRoot = resolve(webPanelRoot, 'dist'); +const bridgeEntryPoint = resolve(bridgeRoot, 'src', 'index.ts'); +const bridgeOutfile = resolve(distRoot, 'bridge.cjs'); +const rendererScriptsRoot = resolve(bridgeRoot, 'scripts', 'default'); +const rendererScriptsOutdir = resolve(distRoot, 'renderer-scripts'); await build({ banner: { - js: "// Generated by bridge/build.mjs. Do not edit this bundle by hand.", + js: '// Generated by bridge/build.mjs. Do not edit this bundle by hand.', }, bundle: true, entryPoints: [bridgeEntryPoint], - format: "cjs", - legalComments: "none", + format: 'cjs', + legalComments: 'none', minify: true, outfile: bridgeOutfile, - platform: "node", - target: "node16", -}) + platform: 'node', + target: 'node16', +}); -const EXCLUDED_RENDERER_SCRIPTS = new Set(["activate-pro.js"]) +const EXCLUDED_RENDERER_SCRIPTS = new Set(['activate-pro.js']); -const rendererEntries = ( - await readdir(rendererScriptsRoot, { withFileTypes: true }) -) +const rendererEntries = (await readdir(rendererScriptsRoot, { withFileTypes: true })) .filter( (entry) => entry.isFile() && - entry.name.endsWith(".js") && - !EXCLUDED_RENDERER_SCRIPTS.has(entry.name) + entry.name.endsWith('.js') && + !EXCLUDED_RENDERER_SCRIPTS.has(entry.name), ) - .map((entry) => resolve(rendererScriptsRoot, entry.name)) + .map((entry) => resolve(rendererScriptsRoot, entry.name)); if (rendererEntries.length === 0) { - throw new Error(`No renderer script entries found in ${rendererScriptsRoot}`) + throw new Error(`No renderer script entries found in ${rendererScriptsRoot}`); } await build({ banner: { - js: "// Generated by bridge/build.mjs. Do not edit this bundle by hand.", + js: '// Generated by bridge/build.mjs. Do not edit this bundle by hand.', }, bundle: true, - entryNames: "[name]", + entryNames: '[name]', entryPoints: rendererEntries, - format: "iife", - legalComments: "none", + format: 'iife', + legalComments: 'none', minify: true, outdir: rendererScriptsOutdir, - platform: "browser", - target: "es2020", -}) + platform: 'browser', + target: 'es2020', +}); -console.log(`Built ${bridgeOutfile}`) -console.log( - `Built ${rendererEntries.length} renderer script(s) in ${rendererScriptsOutdir}` -) +console.log(`Built ${bridgeOutfile}`); +console.log(`Built ${rendererEntries.length} renderer script(s) in ${rendererScriptsOutdir}`); diff --git a/web-panel/bridge/dev-server.mjs b/web-panel/bridge/dev-server.mjs index 912b166..e27481c 100644 --- a/web-panel/bridge/dev-server.mjs +++ b/web-panel/bridge/dev-server.mjs @@ -1,5 +1,5 @@ -import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { WebSocketServer } from 'ws'; @@ -25,196 +25,221 @@ const trainerValues = structuredClone(demoSession.trainerValues); const wss = new WebSocketServer({ noServer: true }); function jsonMessage(type, payload, requestId = null) { - return JSON.stringify({ - type, - version: webContract.protocolVersion, - requestId, - payload, - }); + return JSON.stringify({ + type, + version: webContract.protocolVersion, + requestId, + payload, + }); } function sendSnapshot(ws) { - ws.send( - jsonMessage('trainer_meta', trainerMeta) - ); - ws.send( - jsonMessage('trainer_values', trainerValues) - ); + ws.send(jsonMessage('trainer_meta', trainerMeta)); + ws.send(jsonMessage('trainer_values', trainerValues)); } function broadcast(type, payload, requestId = null) { - const serialized = jsonMessage(type, payload, requestId); - for (const client of wss.clients) { - if (client.readyState === 1) { - client.send(serialized); + const serialized = jsonMessage(type, payload, requestId); + for (const client of wss.clients) { + if (client.readyState === 1) { + client.send(serialized); + } } - } } function normalizeValue(target, value) { - const cheat = trainerMeta.schema.cheats.find((entry) => entry.target === target); - if (!cheat) { + const cheat = trainerMeta.schema.cheats.find((entry) => entry.target === target); + if (!cheat) { + return value; + } + + if (cheat.type === 'toggle') { + return Boolean(value); + } + + if (cheat.type === 'slider' || cheat.type === 'number') { + const numeric = typeof value === 'string' ? Number(value) : value; + return Number.isFinite(numeric) ? numeric : trainerValues.values[target]; + } + return value; - } - - if (cheat.type === 'toggle') { - return Boolean(value); - } - - if (cheat.type === 'slider' || cheat.type === 'number') { - const numeric = typeof value === 'string' ? Number(value) : value; - return Number.isFinite(numeric) ? numeric : trainerValues.values[target]; - } - - return value; } function contentTypeFor(filePath) { - if (filePath.endsWith('.html')) return 'text/html; charset=utf-8'; - if (filePath.endsWith('.js')) return 'application/javascript; charset=utf-8'; - if (filePath.endsWith('.css')) return 'text/css; charset=utf-8'; - if (filePath.endsWith('.json')) return 'application/json; charset=utf-8'; - if (filePath.endsWith('.svg')) return 'image/svg+xml'; - return 'application/octet-stream'; + if (filePath.endsWith('.html')) return 'text/html; charset=utf-8'; + if (filePath.endsWith('.js')) return 'application/javascript; charset=utf-8'; + if (filePath.endsWith('.css')) return 'text/css; charset=utf-8'; + if (filePath.endsWith('.json')) return 'application/json; charset=utf-8'; + if (filePath.endsWith('.svg')) return 'image/svg+xml'; + return 'application/octet-stream'; } async function serveFile(res, filePath) { - try { - const content = await readFile(filePath); - res.writeHead(200, { - 'Content-Type': contentTypeFor(filePath), - 'Cache-Control': 'no-store', - }); - res.end(content); - } catch { - res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end('Not found'); - } + try { + const content = await readFile(filePath); + res.writeHead(200, { + 'Content-Type': contentTypeFor(filePath), + 'Cache-Control': 'no-store', + }); + res.end(content); + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + } } const server = createServer(async (req, res) => { - const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); - if (url.pathname === '/' || url.pathname === '') { - res.writeHead(302, { Location: REMOTE_BASE_PATH }); - res.end(); - return; - } + if (url.pathname === '/' || url.pathname === '') { + res.writeHead(302, { Location: REMOTE_BASE_PATH }); + res.end(); + return; + } - if (url.pathname === REMOTE_BASE_PATH.slice(0, -1)) { - res.writeHead(302, { Location: REMOTE_BASE_PATH }); - res.end(); - return; - } + if (url.pathname === REMOTE_BASE_PATH.slice(0, -1)) { + res.writeHead(302, { Location: REMOTE_BASE_PATH }); + res.end(); + return; + } - if (url.pathname === REMOTE_BASE_PATH) { - await serveFile(res, path.join(distDir, 'index.html')); - return; - } + if (url.pathname === REMOTE_BASE_PATH) { + await serveFile(res, path.join(distDir, 'index.html')); + return; + } - if (url.pathname === REMOTE_HEALTH_PATH) { - res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ ok: true, trainerId: trainerMeta.trainer.trainerId })); - return; - } + if (url.pathname === REMOTE_HEALTH_PATH) { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ ok: true, trainerId: trainerMeta.trainer.trainerId })); + return; + } - if (url.pathname.startsWith(REMOTE_ASSETS_PREFIX)) { - const relativePath = url.pathname.replace(REMOTE_BASE_PATH, ''); - await serveFile(res, path.join(distDir, relativePath)); - return; - } + if (url.pathname.startsWith(REMOTE_ASSETS_PREFIX)) { + const relativePath = url.pathname.replace(REMOTE_BASE_PATH, ''); + await serveFile(res, path.join(distDir, relativePath)); + return; + } - res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end('Not found'); + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); }); server.on('upgrade', (request, socket, head) => { - const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`); - if (url.pathname !== REMOTE_WS_PATH) { - socket.destroy(); - return; - } + const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`); + if (url.pathname !== REMOTE_WS_PATH) { + socket.destroy(); + return; + } - wss.handleUpgrade(request, socket, head, (ws) => { - wss.emit('connection', ws, request); - }); + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); }); wss.on('connection', (ws) => { - ws.on('error', console.error); + ws.on('error', console.error); - ws.on('message', (raw) => { - try { - const message = JSON.parse(String(raw)); - if (message?.version !== webContract.protocolVersion || typeof message?.type !== 'string' || !message?.payload) { - ws.send(jsonMessage('error', { - code: 'invalid_message', - message: 'Expected a compatible protocol envelope.', - }, message?.requestId ?? null)); - return; - } - if (message?.type === 'hello') { - ws.send( - jsonMessage('hello_ack', { - sessionId: `sess_${Date.now()}`, - accepted: true, - serverVersion: '0.1.0-demo', - protocolVersion: webContract.protocolVersion, - }, message.requestId ?? null) - ); - sendSnapshot(ws); - return; - } + ws.on('message', (raw) => { + try { + const message = JSON.parse(String(raw)); + if ( + message?.version !== webContract.protocolVersion || + typeof message?.type !== 'string' || + !message?.payload + ) { + ws.send( + jsonMessage( + 'error', + { + code: 'invalid_message', + message: 'Expected a compatible protocol envelope.', + }, + message?.requestId ?? null, + ), + ); + return; + } + if (message?.type === 'hello') { + ws.send( + jsonMessage( + 'hello_ack', + { + sessionId: `sess_${Date.now()}`, + accepted: true, + serverVersion: '0.1.0-demo', + protocolVersion: webContract.protocolVersion, + }, + message.requestId ?? null, + ), + ); + sendSnapshot(ws); + return; + } - if (message?.type === 'set_value') { - const target = message.payload?.target; - if (message.payload?.trainerId !== trainerMeta.trainer.trainerId || typeof target !== 'string' || !(target in trainerValues.values)) { - ws.send( - jsonMessage('set_value_result', { - ok: false, - trainerId: trainerMeta.trainer.trainerId, - target: typeof target === 'string' ? target : '', - error: { - code: 'invalid_target', - message: 'Unknown cheat target.', - }, - }, message.requestId ?? null) - ); - return; + if (message?.type === 'set_value') { + const target = message.payload?.target; + if ( + message.payload?.trainerId !== trainerMeta.trainer.trainerId || + typeof target !== 'string' || + !(target in trainerValues.values) + ) { + ws.send( + jsonMessage( + 'set_value_result', + { + ok: false, + trainerId: trainerMeta.trainer.trainerId, + target: typeof target === 'string' ? target : '', + error: { + code: 'invalid_target', + message: 'Unknown cheat target.', + }, + }, + message.requestId ?? null, + ), + ); + return; + } + + const previousValue = trainerValues.values[target]; + const nextValue = normalizeValue(target, message.payload?.value); + trainerValues.values[target] = nextValue; + + ws.send( + jsonMessage( + 'set_value_result', + { + ok: true, + trainerId: trainerMeta.trainer.trainerId, + target, + }, + message.requestId ?? null, + ), + ); + + broadcast('value_changed', { + trainerId: trainerMeta.trainer.trainerId, + target, + value: nextValue, + oldValue: previousValue, + source: 'remote', + cheatId: message.payload?.cheatId, + }); + } + } catch (error) { + ws.send( + jsonMessage('error', { + code: 'invalid_message', + message: + error instanceof Error ? error.message : 'Failed to parse client message.', + }), + ); } - - const previousValue = trainerValues.values[target]; - const nextValue = normalizeValue(target, message.payload?.value); - trainerValues.values[target] = nextValue; - - ws.send( - jsonMessage('set_value_result', { - ok: true, - trainerId: trainerMeta.trainer.trainerId, - target, - }, message.requestId ?? null) - ); - - broadcast('value_changed', { - trainerId: trainerMeta.trainer.trainerId, - target, - value: nextValue, - oldValue: previousValue, - source: 'remote', - cheatId: message.payload?.cheatId, - }); - } - } catch (error) { - ws.send( - jsonMessage('error', { - code: 'invalid_message', - message: error instanceof Error ? error.message : 'Failed to parse client message.', - }) - ); - } - }); + }); }); server.listen(port, host, () => { - console.log(`Wand web panel bridge listening on http://${host === DEFAULT_REMOTE_HOST ? 'localhost' : host}:${port}${REMOTE_BASE_PATH}`); + console.log( + `Wand web panel bridge listening on http://${host === DEFAULT_REMOTE_HOST ? 'localhost' : host}:${port}${REMOTE_BASE_PATH}`, + ); }); diff --git a/web-panel/bridge/package.json b/web-panel/bridge/package.json index 5bbefff..1cd945a 100644 --- a/web-panel/bridge/package.json +++ b/web-panel/bridge/package.json @@ -1,3 +1,3 @@ { - "type": "commonjs" + "type": "commonjs" } diff --git a/web-panel/bridge/scripts/default/activate-pro.js b/web-panel/bridge/scripts/default/activate-pro.js index 221f991..e4e7201 100644 --- a/web-panel/bridge/scripts/default/activate-pro.js +++ b/web-panel/bridge/scripts/default/activate-pro.js @@ -2,6 +2,6 @@ // patch (EPatchType.ActivatePro). This renderer-side variant is kept for future // use and is excluded from bridge/build.mjs (EXCLUDED_RENDERER_SCRIPTS), so it is // neither bundled nor injected. To re-enable, remove it from that exclusion list. -import { installActivatePro } from "./activate-pro/index.js" +import { installActivatePro } from './activate-pro/index.js'; -installActivatePro(globalThis.WandEnhancer) +installActivatePro(globalThis.WandEnhancer); diff --git a/web-panel/bridge/scripts/default/activate-pro/index.js b/web-panel/bridge/scripts/default/activate-pro/index.js index a36fa5b..9ccc267 100644 --- a/web-panel/bridge/scripts/default/activate-pro/index.js +++ b/web-panel/bridge/scripts/default/activate-pro/index.js @@ -2,139 +2,132 @@ // (EPatchType.ActivatePro). This renderer-side variant patches the account service // prototype to inject the Pro subscription at the source. Kept for future use; // the entry `../activate-pro.js` is excluded from bridge/build.mjs. -import { createLogger } from "../installed-apps-sync/logger.js" +import { createLogger } from '../installed-apps-sync/logger.js'; import { findExportedConstructor, getWebpackRequire, isRecord, -} from "../installed-apps-sync/runtime.js" +} from '../installed-apps-sync/runtime.js'; -const GLOBAL_FLAG = "__wandActivateProInstalled" -const SERVICE_PATCH_KEY = "__wandEnhancerProAccountServicePatched" +const GLOBAL_FLAG = '__wandActivateProInstalled'; +const SERVICE_PATCH_KEY = '__wandEnhancerProAccountServicePatched'; const ACCOUNT_SERVICE_METHODS = [ - "getUserAccount", - "setAccountLanguage", - "setAccountWandBrandExperience", -] -const RETRY_DELAY_MS = 400 -const MAX_ATTEMPTS = 90 -const DEFAULT_SUBSCRIPTION = Object.freeze({ period: "yearly", state: "active" }) + 'getUserAccount', + 'setAccountLanguage', + 'setAccountWandBrandExperience', +]; +const RETRY_DELAY_MS = 400; +const MAX_ATTEMPTS = 90; +const DEFAULT_SUBSCRIPTION = Object.freeze({ period: 'yearly', state: 'active' }); export function installActivatePro(WandEnhancer) { if (globalThis[GLOBAL_FLAG]) { - return + return; } - globalThis[GLOBAL_FLAG] = true + globalThis[GLOBAL_FLAG] = true; const state = { attempts: 0, log: createLogger(WandEnhancer), - } + }; - state.log("info", "Activate Pro bootstrap starting.") - retryBootstrap(state) + state.log('info', 'Activate Pro bootstrap starting.'); + retryBootstrap(state); } function retryBootstrap(state) { if (patchAccountService(state)) { - return + return; } - state.attempts += 1 + state.attempts += 1; if (state.attempts < MAX_ATTEMPTS) { - setTimeout(() => retryBootstrap(state), RETRY_DELAY_MS) - return + setTimeout(() => retryBootstrap(state), RETRY_DELAY_MS); + return; } - state.log("error", "Activate Pro bootstrap exhausted; account service not found.") + state.log('error', 'Activate Pro bootstrap exhausted; account service not found.'); } function patchAccountService(state) { - const webpackRequire = getWebpackRequire() + const webpackRequire = getWebpackRequire(); if (!webpackRequire) { - return false + return false; } const ctor = findExportedConstructor( webpackRequire, (prototype) => - typeof prototype.getUserAccount === "function" && - typeof prototype.setAccountLanguage === "function" && - typeof prototype.setAccountWandBrandExperience === "function" - ) + typeof prototype.getUserAccount === 'function' && + typeof prototype.setAccountLanguage === 'function' && + typeof prototype.setAccountWandBrandExperience === 'function', + ); if (!ctor?.prototype) { - return false + return false; } - const prototype = ctor.prototype + const prototype = ctor.prototype; if (prototype[SERVICE_PATCH_KEY]) { - return true + return true; } try { for (const name of ACCOUNT_SERVICE_METHODS) { - const original = prototype[name] - if (typeof original !== "function") { - continue + const original = prototype[name]; + if (typeof original !== 'function') { + continue; } prototype[name] = function patchedAccountMethod(...args) { return Promise.resolve(original.apply(this, args)).then((account) => - normalizeProAccount(account) - ) - } + normalizeProAccount(account), + ); + }; } - Object.defineProperty(prototype, SERVICE_PATCH_KEY, { value: true }) - state.log("info", "Pro account service patched.") - return true + Object.defineProperty(prototype, SERVICE_PATCH_KEY, { value: true }); + state.log('info', 'Pro account service patched.'); + return true; } catch (error) { - state.log( - "warn", - "Failed to patch account service.", - error?.stack || String(error) - ) - return false + state.log('warn', 'Failed to patch account service.', error?.stack || String(error)); + return false; } } function normalizeProAccount(account) { if (!isRecord(account)) { - return account + return account; } - const nextSubscription = normalizeProSubscription(account.subscription) + const nextSubscription = normalizeProSubscription(account.subscription); if (nextSubscription === account.subscription) { - return account + return account; } return { ...account, subscription: nextSubscription, - } + }; } function normalizeProSubscription(subscription) { if (!isRecord(subscription)) { - return { ...DEFAULT_SUBSCRIPTION } + return { ...DEFAULT_SUBSCRIPTION }; } - const nextSubscription = { ...subscription } - let changed = false + const nextSubscription = { ...subscription }; + let changed = false; - if ( - typeof nextSubscription.period !== "string" || - !nextSubscription.period.trim() - ) { - nextSubscription.period = DEFAULT_SUBSCRIPTION.period - changed = true + if (typeof nextSubscription.period !== 'string' || !nextSubscription.period.trim()) { + nextSubscription.period = DEFAULT_SUBSCRIPTION.period; + changed = true; } - if (nextSubscription.state !== "active") { - nextSubscription.state = DEFAULT_SUBSCRIPTION.state - changed = true + if (nextSubscription.state !== 'active') { + nextSubscription.state = DEFAULT_SUBSCRIPTION.state; + changed = true; } - return changed ? nextSubscription : subscription + return changed ? nextSubscription : subscription; } diff --git a/web-panel/bridge/scripts/default/installed-apps-sync.js b/web-panel/bridge/scripts/default/installed-apps-sync.js index a2377fa..8eded13 100644 --- a/web-panel/bridge/scripts/default/installed-apps-sync.js +++ b/web-panel/bridge/scripts/default/installed-apps-sync.js @@ -1,3 +1,3 @@ -import { installInstalledAppsSync } from "./installed-apps-sync/index.js" +import { installInstalledAppsSync } from './installed-apps-sync/index.js'; -installInstalledAppsSync(globalThis.WandEnhancer) +installInstalledAppsSync(globalThis.WandEnhancer); diff --git a/web-panel/bridge/scripts/default/installed-apps-sync/artwork.js b/web-panel/bridge/scripts/default/installed-apps-sync/artwork.js index c7a7219..7c8abcb 100644 --- a/web-panel/bridge/scripts/default/installed-apps-sync/artwork.js +++ b/web-panel/bridge/scripts/default/installed-apps-sync/artwork.js @@ -1,255 +1,239 @@ import { - DATA_IMAGE_URL_PREFIX, - IMAGE_FIELD_NAMES, - MAX_IMAGE_URL_SEARCH_DEPTH, - PROTOCOL_RELATIVE_IMAGE_URL_PATTERN, - REMOTE_IMAGE_URL_PATTERN, - SIDEBAR_GAME_ROW_CONTAINER_SELECTOR, - SIDEBAR_GAME_ROW_IMAGE_SELECTOR, - SIDEBAR_GAME_ROW_MORE_SELECTOR, - SIDEBAR_GAME_ROW_TITLE_SELECTOR, - SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN, - STEAM_APP_ID_FIELD_NAMES, - STEAM_APP_ID_PATTERN, - STEAM_CONTAINER_FIELD_PATTERN, - STEAM_CONTAINER_ID_FIELD_NAMES, - STEAM_PLATFORM, - WEMOD_STEAM_COMMUNITY_CDN_BASE_URL, -} from "./constants.js" -import { isRecord, safeString, toStringId } from "./runtime.js" + DATA_IMAGE_URL_PREFIX, + IMAGE_FIELD_NAMES, + MAX_IMAGE_URL_SEARCH_DEPTH, + PROTOCOL_RELATIVE_IMAGE_URL_PATTERN, + REMOTE_IMAGE_URL_PATTERN, + SIDEBAR_GAME_ROW_CONTAINER_SELECTOR, + SIDEBAR_GAME_ROW_IMAGE_SELECTOR, + SIDEBAR_GAME_ROW_MORE_SELECTOR, + SIDEBAR_GAME_ROW_TITLE_SELECTOR, + SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN, + STEAM_APP_ID_FIELD_NAMES, + STEAM_APP_ID_PATTERN, + STEAM_CONTAINER_FIELD_PATTERN, + STEAM_CONTAINER_ID_FIELD_NAMES, + STEAM_PLATFORM, + WEMOD_STEAM_COMMUNITY_CDN_BASE_URL, +} from './constants.js'; +import { isRecord, safeString, toStringId } from './runtime.js'; export function pickImageUrl(...values) { - for (const value of values) { - const imageUrl = normalizeImageUrl(value) - if (imageUrl) { - return imageUrl + for (const value of values) { + const imageUrl = normalizeImageUrl(value); + if (imageUrl) { + return imageUrl; + } } - } - return null + return null; } export function normalizeImageUrl(value, depth = 0) { - if (typeof value === "string") { - const trimmed = value.trim() - if ( - REMOTE_IMAGE_URL_PATTERN.test(trimmed) || - trimmed.startsWith(DATA_IMAGE_URL_PREFIX) - ) { - return trimmed + if (typeof value === 'string') { + const trimmed = value.trim(); + if (REMOTE_IMAGE_URL_PATTERN.test(trimmed) || trimmed.startsWith(DATA_IMAGE_URL_PREFIX)) { + return trimmed; + } + + if (PROTOCOL_RELATIVE_IMAGE_URL_PATTERN.test(trimmed)) { + return `https:${trimmed}`; + } + + return null; } - if (PROTOCOL_RELATIVE_IMAGE_URL_PATTERN.test(trimmed)) { - return `https:${trimmed}` + if (depth > MAX_IMAGE_URL_SEARCH_DEPTH || !value) { + return null; } - return null - } - - if (depth > MAX_IMAGE_URL_SEARCH_DEPTH || !value) { - return null - } - - if (Array.isArray(value)) { - for (const entry of value) { - const imageUrl = normalizeImageUrl(entry, depth + 1) - if (imageUrl) { - return imageUrl - } - } - - return null - } - - if (!isRecord(value)) { - return null - } - - for (const key of IMAGE_FIELD_NAMES) { - const imageUrl = normalizeImageUrl(value[key], depth + 1) - if (imageUrl) { - return imageUrl - } - } - - return null -} - -export function getSteamClientIconUrl(...values) { - for (const value of values) { - const steamAppId = toStringId(value) - if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) { - return `${WEMOD_STEAM_COMMUNITY_CDN_BASE_URL}/${steamAppId}/client_icon/96.webp` - } - } - - return null -} - -export function getSidebarGameRowClientIcons() { - const byTitleId = new Map() - const byTitleName = new Map() - if (typeof document === "undefined") { - return { byTitleId, byTitleName } - } - - const containers = document.querySelectorAll( - SIDEBAR_GAME_ROW_CONTAINER_SELECTOR - ) - for (const container of containers) { - const imageElement = container.querySelector( - SIDEBAR_GAME_ROW_IMAGE_SELECTOR - ) - const titleElement = container.querySelector( - SIDEBAR_GAME_ROW_TITLE_SELECTOR - ) - const moreButton = container.querySelector(SIDEBAR_GAME_ROW_MORE_SELECTOR) - if (!imageElement || !titleElement) { - continue - } - - const backgroundImageUrl = getCssBackgroundImageUrl( - safeString(imageElement.style?.backgroundImage) || - getComputedStyle(imageElement).backgroundImage - ) - if (!backgroundImageUrl) { - continue - } - - const tooltipId = safeString( - moreButton?.getAttribute?.("data-tooltip-trigger-for") - ) - const titleId = - tooltipId.match(SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN)?.[1] ?? null - const titleName = normalizeTitleMatchKey(titleElement.textContent) - - if (titleId) { - byTitleId.set(titleId, backgroundImageUrl) - } - - if (titleName) { - byTitleName.set(titleName, backgroundImageUrl) - } - } - - return { byTitleId, byTitleName } -} - -export function getSidebarGameRowClientIconUrl( - sidebarIcons, - titleId, - ...names -) { - const normalizedTitleId = toStringId(titleId) - if (normalizedTitleId && sidebarIcons.byTitleId.has(normalizedTitleId)) { - return sidebarIcons.byTitleId.get(normalizedTitleId) ?? null - } - - for (const name of names) { - const normalizedName = normalizeTitleMatchKey(name) - if (normalizedName && sidebarIcons.byTitleName.has(normalizedName)) { - return sidebarIcons.byTitleName.get(normalizedName) ?? null - } - } - - return null -} - -export function findSteamAppId(...roots) { - const seen = new Set() - const queue = roots.map((value) => ({ value, depth: 0, steamContext: false })) - - while (queue.length > 0) { - const current = queue.shift() - const value = current?.value - const depth = current?.depth ?? 0 - const steamContext = current?.steamContext ?? false - - if (!value || depth > MAX_IMAGE_URL_SEARCH_DEPTH || seen.has(value)) { - continue - } - - if (typeof value === "string" || typeof value === "number") { - const steamAppId = steamContext ? toStringId(value) : null - if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) { - return steamAppId - } - continue - } - - seen.add(value) - if (Array.isArray(value)) { - for (const entry of value) { - queue.push({ value: entry, depth: depth + 1, steamContext }) - } - continue + for (const entry of value) { + const imageUrl = normalizeImageUrl(entry, depth + 1); + if (imageUrl) { + return imageUrl; + } + } + + return null; } if (!isRecord(value)) { - continue + return null; } - for (const [key, entry] of Object.entries(value)) { - const steamAppId = getSteamAppIdFromEntry(key, entry, steamContext) - if (steamAppId) { - return steamAppId - } - - if (isRecord(entry) || Array.isArray(entry)) { - queue.push({ - value: entry, - depth: depth + 1, - steamContext: steamContext || STEAM_CONTAINER_FIELD_PATTERN.test(key), - }) - } + for (const key of IMAGE_FIELD_NAMES) { + const imageUrl = normalizeImageUrl(value[key], depth + 1); + if (imageUrl) { + return imageUrl; + } } - } - return null + return null; +} + +export function getSteamClientIconUrl(...values) { + for (const value of values) { + const steamAppId = toStringId(value); + if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) { + return `${WEMOD_STEAM_COMMUNITY_CDN_BASE_URL}/${steamAppId}/client_icon/96.webp`; + } + } + + return null; +} + +export function getSidebarGameRowClientIcons() { + const byTitleId = new Map(); + const byTitleName = new Map(); + if (typeof document === 'undefined') { + return { byTitleId, byTitleName }; + } + + const containers = document.querySelectorAll(SIDEBAR_GAME_ROW_CONTAINER_SELECTOR); + for (const container of containers) { + const imageElement = container.querySelector(SIDEBAR_GAME_ROW_IMAGE_SELECTOR); + const titleElement = container.querySelector(SIDEBAR_GAME_ROW_TITLE_SELECTOR); + const moreButton = container.querySelector(SIDEBAR_GAME_ROW_MORE_SELECTOR); + if (!imageElement || !titleElement) { + continue; + } + + const backgroundImageUrl = getCssBackgroundImageUrl( + safeString(imageElement.style?.backgroundImage) || + getComputedStyle(imageElement).backgroundImage, + ); + if (!backgroundImageUrl) { + continue; + } + + const tooltipId = safeString(moreButton?.getAttribute?.('data-tooltip-trigger-for')); + const titleId = tooltipId.match(SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN)?.[1] ?? null; + const titleName = normalizeTitleMatchKey(titleElement.textContent); + + if (titleId) { + byTitleId.set(titleId, backgroundImageUrl); + } + + if (titleName) { + byTitleName.set(titleName, backgroundImageUrl); + } + } + + return { byTitleId, byTitleName }; +} + +export function getSidebarGameRowClientIconUrl(sidebarIcons, titleId, ...names) { + const normalizedTitleId = toStringId(titleId); + if (normalizedTitleId && sidebarIcons.byTitleId.has(normalizedTitleId)) { + return sidebarIcons.byTitleId.get(normalizedTitleId) ?? null; + } + + for (const name of names) { + const normalizedName = normalizeTitleMatchKey(name); + if (normalizedName && sidebarIcons.byTitleName.has(normalizedName)) { + return sidebarIcons.byTitleName.get(normalizedName) ?? null; + } + } + + return null; +} + +export function findSteamAppId(...roots) { + const seen = new Set(); + const queue = roots.map((value) => ({ value, depth: 0, steamContext: false })); + + while (queue.length > 0) { + const current = queue.shift(); + const value = current?.value; + const depth = current?.depth ?? 0; + const steamContext = current?.steamContext ?? false; + + if (!value || depth > MAX_IMAGE_URL_SEARCH_DEPTH || seen.has(value)) { + continue; + } + + if (typeof value === 'string' || typeof value === 'number') { + const steamAppId = steamContext ? toStringId(value) : null; + if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) { + return steamAppId; + } + continue; + } + + seen.add(value); + + if (Array.isArray(value)) { + for (const entry of value) { + queue.push({ value: entry, depth: depth + 1, steamContext }); + } + continue; + } + + if (!isRecord(value)) { + continue; + } + + for (const [key, entry] of Object.entries(value)) { + const steamAppId = getSteamAppIdFromEntry(key, entry, steamContext); + if (steamAppId) { + return steamAppId; + } + + if (isRecord(entry) || Array.isArray(entry)) { + queue.push({ + value: entry, + depth: depth + 1, + steamContext: steamContext || STEAM_CONTAINER_FIELD_PATTERN.test(key), + }); + } + } + } + + return null; } export function getInstalledAppSteamAppId(platform, sku) { - if (safeString(platform).toLowerCase() !== STEAM_PLATFORM) { - return null - } + if (safeString(platform).toLowerCase() !== STEAM_PLATFORM) { + return null; + } - return sku + return sku; } function normalizeTitleMatchKey(value) { - const normalized = safeString(value).trim().toLowerCase().replace(/\s+/g, " ") - return normalized || null + const normalized = safeString(value).trim().toLowerCase().replace(/\s+/g, ' '); + return normalized || null; } function getCssBackgroundImageUrl(value) { - const backgroundImage = safeString(value) - if (!backgroundImage || backgroundImage === "none") { - return null - } + const backgroundImage = safeString(value); + if (!backgroundImage || backgroundImage === 'none') { + return null; + } - const match = backgroundImage.match(/url\((['"]?)(.*?)\1\)/i) - if (!match?.[2]) { - return null - } + const match = backgroundImage.match(/url\((['"]?)(.*?)\1\)/i); + if (!match?.[2]) { + return null; + } - return normalizeImageUrl(match[2]) + return normalizeImageUrl(match[2]); } function getSteamAppIdFromEntry(key, entry, steamContext) { - if (STEAM_APP_ID_FIELD_NAMES.has(key)) { - const steamAppId = toStringId(entry) - if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) { - return steamAppId + if (STEAM_APP_ID_FIELD_NAMES.has(key)) { + const steamAppId = toStringId(entry); + if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) { + return steamAppId; + } } - } - if (steamContext && STEAM_CONTAINER_ID_FIELD_NAMES.has(key)) { - const steamAppId = toStringId(entry) - if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) { - return steamAppId + if (steamContext && STEAM_CONTAINER_ID_FIELD_NAMES.has(key)) { + const steamAppId = toStringId(entry); + if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) { + return steamAppId; + } } - } - return null + return null; } diff --git a/web-panel/bridge/scripts/default/installed-apps-sync/constants.js b/web-panel/bridge/scripts/default/installed-apps-sync/constants.js index a816494..e42b549 100644 --- a/web-panel/bridge/scripts/default/installed-apps-sync/constants.js +++ b/web-panel/bridge/scripts/default/installed-apps-sync/constants.js @@ -1,98 +1,96 @@ -export const GLOBAL_FLAG = "__wandInstalledAppsSyncInstalled" -export const BIND_CHANNEL = "wand-remote-set-handler-bind" -export const SYNC_CHANNEL = "wand-remote-installed-apps" -export const TRAINER_SNAPSHOT_CHANNEL = "wand-remote-sync" -export const GAME_STATUS_CHANNEL = "wand-remote-game-status" -export const COMMAND_REQUEST_CHANNEL = "wand-remote-command" -export const COMMAND_RESPONSE_CHANNEL = "wand-remote-command-response" -export const REMOTE_COMMAND_LAUNCH = "launch" -export const REMOTE_COMMAND_STOP = "stop" -export const REMOTE_COMMAND_TRIGGER = "remote" -export const RETRY_DELAY_MS = 1000 -export const MAX_BOOTSTRAP_ATTEMPTS = 60 -export const SYNC_INTERVAL_MS = 15000 -export const OPTIONAL_SERVICES_RETRY_INTERVAL_MS = 1000 -export const MAX_OPTIONAL_SERVICES_ATTEMPTS = 60 -export const FOLLOW_UP_SYNC_DELAY_MS = 2500 -export const UNAVAILABLE_TITLES_BATCH_SIZE = 250 -export const BOOTSTRAP_LOG_THROTTLE_ATTEMPTS = 5 -export const CONTAINER_LOG_THROTTLE_ATTEMPTS = 10 -export const CONTAINER_GRAPH_MAX_DEPTH = 4 +export const GLOBAL_FLAG = '__wandInstalledAppsSyncInstalled'; +export const BIND_CHANNEL = 'wand-remote-set-handler-bind'; +export const SYNC_CHANNEL = 'wand-remote-installed-apps'; +export const TRAINER_SNAPSHOT_CHANNEL = 'wand-remote-sync'; +export const GAME_STATUS_CHANNEL = 'wand-remote-game-status'; +export const COMMAND_REQUEST_CHANNEL = 'wand-remote-command'; +export const COMMAND_RESPONSE_CHANNEL = 'wand-remote-command-response'; +export const REMOTE_COMMAND_LAUNCH = 'launch'; +export const REMOTE_COMMAND_STOP = 'stop'; +export const REMOTE_COMMAND_TRIGGER = 'remote'; +export const RETRY_DELAY_MS = 1000; +export const MAX_BOOTSTRAP_ATTEMPTS = 60; +export const SYNC_INTERVAL_MS = 15000; +export const OPTIONAL_SERVICES_RETRY_INTERVAL_MS = 1000; +export const MAX_OPTIONAL_SERVICES_ATTEMPTS = 60; +export const FOLLOW_UP_SYNC_DELAY_MS = 2500; +export const UNAVAILABLE_TITLES_BATCH_SIZE = 250; +export const BOOTSTRAP_LOG_THROTTLE_ATTEMPTS = 5; +export const CONTAINER_LOG_THROTTLE_ATTEMPTS = 10; +export const CONTAINER_GRAPH_MAX_DEPTH = 4; // Wand's webpack module exports the trainer-launch-request class under key `vO`. // Required so `trainerService.launch(req)` records `getMetadata(vO)` state in Wand. See AGENTS.md "Remote Play". -export const TRAINER_LAUNCH_REQUEST_EXPORT_KEY = "vO" +export const TRAINER_LAUNCH_REQUEST_EXPORT_KEY = 'vO'; export const SNAPSHOT_ENTRY_KEY_PREFIX = Object.freeze({ - TITLE: "title:", - GAME: "game:", - APP: "app:", -}) -export const GAME_LAUNCHED_EVENT = "game-launched" -export const GAME_ENDED_EVENT = "game-ended" -export const SYNTHETIC_SESSION_RUNNING_EVENT = "trainer-running" -export const SYNTHETIC_SESSION_IDLE_EVENT = "trainer-idle" -export const TRAINER_ENDED_EVENT = "trainer-ended" -export const REMOTE_STOP_EVENT = "remote-stop" -export const STEAM_PLATFORM = "steam" -export const STEAM_APP_ID_PATTERN = /^\d+$/ -export const WEMOD_STEAM_COMMUNITY_CDN_BASE_URL = - "https://api-cdn.wemod.com/steam_community" -export const REMOTE_IMAGE_URL_PATTERN = /^https?:\/\//i -export const PROTOCOL_RELATIVE_IMAGE_URL_PATTERN = /^\/\// -export const DATA_IMAGE_URL_PREFIX = "data:image/" -export const MAX_IMAGE_URL_SEARCH_DEPTH = 4 -export const SIDEBAR_GAME_ROW_CONTAINER_SELECTOR = ".sidebar-game-row-container" -export const SIDEBAR_GAME_ROW_IMAGE_SELECTOR = ".sidebar-game-row-image" -export const SIDEBAR_GAME_ROW_TITLE_SELECTOR = ".sidebar-game-row-title" -export const SIDEBAR_GAME_ROW_MORE_SELECTOR = ".sidebar-game-row-more" -export const SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN = - /sidebar-game-row-(.+?)-more-button-tooltip/ -export const STEAM_APP_ID_FIELD_NAMES = new Set(["steamAppId", "steamAppID"]) -export const STEAM_CONTAINER_FIELD_PATTERN = /steam/i -export const STEAM_CONTAINER_ID_FIELD_NAMES = new Set(["appId", "appID", "id"]) -export const LOG_PREFIX = "[wand-installed-apps-sync]" -export const LOG_FILE_NAME = "wand-remote-installed-apps-sync.log" -export const EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS = new Set(["standalone"]) + TITLE: 'title:', + GAME: 'game:', + APP: 'app:', +}); +export const GAME_LAUNCHED_EVENT = 'game-launched'; +export const GAME_ENDED_EVENT = 'game-ended'; +export const SYNTHETIC_SESSION_RUNNING_EVENT = 'trainer-running'; +export const SYNTHETIC_SESSION_IDLE_EVENT = 'trainer-idle'; +export const TRAINER_ENDED_EVENT = 'trainer-ended'; +export const REMOTE_STOP_EVENT = 'remote-stop'; +export const STEAM_PLATFORM = 'steam'; +export const STEAM_APP_ID_PATTERN = /^\d+$/; +export const WEMOD_STEAM_COMMUNITY_CDN_BASE_URL = 'https://api-cdn.wemod.com/steam_community'; +export const REMOTE_IMAGE_URL_PATTERN = /^https?:\/\//i; +export const PROTOCOL_RELATIVE_IMAGE_URL_PATTERN = /^\/\//; +export const DATA_IMAGE_URL_PREFIX = 'data:image/'; +export const MAX_IMAGE_URL_SEARCH_DEPTH = 4; +export const SIDEBAR_GAME_ROW_CONTAINER_SELECTOR = '.sidebar-game-row-container'; +export const SIDEBAR_GAME_ROW_IMAGE_SELECTOR = '.sidebar-game-row-image'; +export const SIDEBAR_GAME_ROW_TITLE_SELECTOR = '.sidebar-game-row-title'; +export const SIDEBAR_GAME_ROW_MORE_SELECTOR = '.sidebar-game-row-more'; +export const SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN = /sidebar-game-row-(.+?)-more-button-tooltip/; +export const STEAM_APP_ID_FIELD_NAMES = new Set(['steamAppId', 'steamAppID']); +export const STEAM_CONTAINER_FIELD_PATTERN = /steam/i; +export const STEAM_CONTAINER_ID_FIELD_NAMES = new Set(['appId', 'appID', 'id']); +export const LOG_PREFIX = '[wand-installed-apps-sync]'; +export const LOG_FILE_NAME = 'wand-remote-installed-apps-sync.log'; +export const EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS = new Set(['standalone']); export const IMAGE_FIELD_NAMES = [ - "imageUrl", - "imageURL", - "iconUrl", - "iconURL", - "coverUrl", - "coverURL", - "thumbnailUrl", - "thumbnailURL", - "logoUrl", - "logoURL", - "headerImageUrl", - "headerImageURL", - "boxArtUrl", - "boxartUrl", - "posterUrl", - "tileUrl", - "capsuleUrl", - "heroUrl", - "backgroundUrl", - "image", - "icon", - "cover", - "thumbnail", - "logo", - "headerImage", - "header", - "boxArt", - "boxart", - "poster", - "tile", - "capsule", - "hero", - "background", - "large", - "medium", - "small", - "original", - "source", - "href", - "uri", - "url", - "src", -] + 'imageUrl', + 'imageURL', + 'iconUrl', + 'iconURL', + 'coverUrl', + 'coverURL', + 'thumbnailUrl', + 'thumbnailURL', + 'logoUrl', + 'logoURL', + 'headerImageUrl', + 'headerImageURL', + 'boxArtUrl', + 'boxartUrl', + 'posterUrl', + 'tileUrl', + 'capsuleUrl', + 'heroUrl', + 'backgroundUrl', + 'image', + 'icon', + 'cover', + 'thumbnail', + 'logo', + 'headerImage', + 'header', + 'boxArt', + 'boxart', + 'poster', + 'tile', + 'capsule', + 'hero', + 'background', + 'large', + 'medium', + 'small', + 'original', + 'source', + 'href', + 'uri', + 'url', + 'src', +]; diff --git a/web-panel/bridge/scripts/default/installed-apps-sync/game-status.js b/web-panel/bridge/scripts/default/installed-apps-sync/game-status.js index 5e9dbd4..f53b4ec 100644 --- a/web-panel/bridge/scripts/default/installed-apps-sync/game-status.js +++ b/web-panel/bridge/scripts/default/installed-apps-sync/game-status.js @@ -1,325 +1,309 @@ import { - GAME_ENDED_EVENT, - GAME_LAUNCHED_EVENT, - GAME_STATUS_CHANNEL, - SYNTHETIC_SESSION_IDLE_EVENT, - SYNTHETIC_SESSION_RUNNING_EVENT, - TRAINER_ENDED_EVENT, - TRAINER_SNAPSHOT_CHANNEL, -} from "./constants.js" -import { invokeIpc, isRecord, safeString, toStringId } from "./runtime.js" + GAME_ENDED_EVENT, + GAME_LAUNCHED_EVENT, + GAME_STATUS_CHANNEL, + SYNTHETIC_SESSION_IDLE_EVENT, + SYNTHETIC_SESSION_RUNNING_EVENT, + TRAINER_ENDED_EVENT, + TRAINER_SNAPSHOT_CHANNEL, +} from './constants.js'; +import { invokeIpc, isRecord, safeString, toStringId } from './runtime.js'; export function createIdleGameSession() { - return { - state: "idle", - event: "snapshot", - processId: null, - gameId: null, - titleId: null, - titleName: null, - sessionDurationSeconds: null, - startedAt: null, - endedAt: null, - } + return { + state: 'idle', + event: 'snapshot', + processId: null, + gameId: null, + titleId: null, + titleName: null, + sessionDurationSeconds: null, + startedAt: null, + endedAt: null, + }; } export function createIdleTrainerStatus() { - return { - state: "idle", - event: "snapshot", - trainerId: null, - displayName: null, - gameId: null, - titleId: null, - } + return { + state: 'idle', + event: 'snapshot', + trainerId: null, + displayName: null, + gameId: null, + titleId: null, + }; } export function installGameStatusSubscriptions(state) { - let installed = false + let installed = false; - if ( - state.gameLifecycleService && - !state.gameLifecycleSubscriptionsInstalled - ) { - installed = installLifecycleSubscriptions(state) || installed - } + if (state.gameLifecycleService && !state.gameLifecycleSubscriptionsInstalled) { + installed = installLifecycleSubscriptions(state) || installed; + } - if ( - state.trainerVisibilityService && - !state.trainerVisibilitySubscriptionInstalled - ) { - state.currentRunningTrainer = normalizeRunningTrainerStatus( - state.trainerVisibilityService.runningTrainer, - "snapshot" - ) - syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer) - installed = installTrainerVisibilitySubscription(state) || installed - state.trainerVisibilitySubscriptionInstalled = true - } + if (state.trainerVisibilityService && !state.trainerVisibilitySubscriptionInstalled) { + state.currentRunningTrainer = normalizeRunningTrainerStatus( + state.trainerVisibilityService.runningTrainer, + 'snapshot', + ); + syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer); + installed = installTrainerVisibilitySubscription(state) || installed; + state.trainerVisibilitySubscriptionInstalled = true; + } - if ( - state.trainerService && - !state.trainerEndedSubscriptionInstalled && - typeof state.trainerService.onTrainerEnded === "function" - ) { - state.trainerService.onTrainerEnded(() => { - clearTrainerSnapshot(state, TRAINER_ENDED_EVENT, true) - }) - state.trainerEndedSubscriptionInstalled = true - installed = true - } + if ( + state.trainerService && + !state.trainerEndedSubscriptionInstalled && + typeof state.trainerService.onTrainerEnded === 'function' + ) { + state.trainerService.onTrainerEnded(() => { + clearTrainerSnapshot(state, TRAINER_ENDED_EVENT, true); + }); + state.trainerEndedSubscriptionInstalled = true; + installed = true; + } - if (!installed) { - return - } + if (!installed) { + return; + } - state.log( - "info", - "Game status hooks installed.", - `lifecycle=${state.gameLifecycleSubscriptionsInstalled ? "yes" : "no"}, trainer=${state.trainerVisibilitySubscriptionInstalled ? "yes" : "no"}, trainerEnded=${state.trainerEndedSubscriptionInstalled ? "yes" : "no"}` - ) - void syncGameStatus(state, true) + state.log( + 'info', + 'Game status hooks installed.', + `lifecycle=${state.gameLifecycleSubscriptionsInstalled ? 'yes' : 'no'}, trainer=${state.trainerVisibilitySubscriptionInstalled ? 'yes' : 'no'}, trainerEnded=${state.trainerEndedSubscriptionInstalled ? 'yes' : 'no'}`, + ); + void syncGameStatus(state, true); } export function clearTrainerSnapshot(state, reason, clearSession = false) { - state.currentRunningTrainer = { - ...createIdleTrainerStatus(), - event: reason, - } + state.currentRunningTrainer = { + ...createIdleTrainerStatus(), + event: reason, + }; - if (clearSession) { - clearGameSession(state, reason) - } else if ( - state.currentGameSession.state === "running" && - isSyntheticGameSessionEvent(state.currentGameSession.event) - ) { - syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer) - } + if (clearSession) { + clearGameSession(state, reason); + } else if ( + state.currentGameSession.state === 'running' && + isSyntheticGameSessionEvent(state.currentGameSession.event) + ) { + syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer); + } - void syncGameStatus(state, true) - void invokeIpc(state, TRAINER_SNAPSHOT_CHANNEL, null, "Trainer snapshot clear") + void syncGameStatus(state, true); + void invokeIpc(state, TRAINER_SNAPSHOT_CHANNEL, null, 'Trainer snapshot clear'); } export async function syncGameStatus(state, force = false) { - if (!state.ipcRenderer) { - return false - } + if (!state.ipcRenderer) { + return false; + } - const snapshot = buildGameStatusSnapshot(state) - const signature = makeGameStatusSignature(snapshot) - if (!force && signature === state.lastGameStatusSignature) { - return false - } + const snapshot = buildGameStatusSnapshot(state); + const signature = makeGameStatusSignature(snapshot); + if (!force && signature === state.lastGameStatusSignature) { + return false; + } - state.lastGameStatusSignature = signature + state.lastGameStatusSignature = signature; - const sent = await invokeIpc( - state, - GAME_STATUS_CHANNEL, - snapshot, - "Game status snapshot", - "error" - ) - if (sent) { - state.log( - "info", - "Game status snapshot sent.", - `session=${snapshot.session.state}/${snapshot.session.event}, trainer=${snapshot.trainer.state}/${snapshot.trainer.event}` - ) - } - return sent + const sent = await invokeIpc( + state, + GAME_STATUS_CHANNEL, + snapshot, + 'Game status snapshot', + 'error', + ); + if (sent) { + state.log( + 'info', + 'Game status snapshot sent.', + `session=${snapshot.session.state}/${snapshot.session.event}, trainer=${snapshot.trainer.state}/${snapshot.trainer.event}`, + ); + } + return sent; } function installLifecycleSubscriptions(state) { - let installed = false + let installed = false; - if (typeof state.gameLifecycleService.onGameLaunched === "function") { - state.gameLifecycleService.onGameLaunched((event) => { - state.currentGameSession = { - state: "running", - event: GAME_LAUNCHED_EVENT, - processId: - typeof event?.processId === "number" ? event.processId : null, - gameId: toStringId(event?.gameId), - titleId: toStringId(event?.titleId), - titleName: safeString(event?.titleName), - sessionDurationSeconds: null, - startedAt: new Date().toISOString(), - endedAt: null, - } - void syncGameStatus(state, true) - }) - installed = true - } + if (typeof state.gameLifecycleService.onGameLaunched === 'function') { + state.gameLifecycleService.onGameLaunched((event) => { + state.currentGameSession = { + state: 'running', + event: GAME_LAUNCHED_EVENT, + processId: typeof event?.processId === 'number' ? event.processId : null, + gameId: toStringId(event?.gameId), + titleId: toStringId(event?.titleId), + titleName: safeString(event?.titleName), + sessionDurationSeconds: null, + startedAt: new Date().toISOString(), + endedAt: null, + }; + void syncGameStatus(state, true); + }); + installed = true; + } - if (typeof state.gameLifecycleService.onGameEnded === "function") { - state.gameLifecycleService.onGameEnded((event) => { - clearGameSession( - state, - GAME_ENDED_EVENT, - typeof event?.sessionDurationSeconds === "number" - ? event.sessionDurationSeconds - : state.currentGameSession.sessionDurationSeconds - ) - void syncGameStatus(state, true) - }) - installed = true - } + if (typeof state.gameLifecycleService.onGameEnded === 'function') { + state.gameLifecycleService.onGameEnded((event) => { + clearGameSession( + state, + GAME_ENDED_EVENT, + typeof event?.sessionDurationSeconds === 'number' + ? event.sessionDurationSeconds + : state.currentGameSession.sessionDurationSeconds, + ); + void syncGameStatus(state, true); + }); + installed = true; + } - if (installed) { - state.gameLifecycleSubscriptionsInstalled = true - } + if (installed) { + state.gameLifecycleSubscriptionsInstalled = true; + } - return installed + return installed; } function installTrainerVisibilitySubscription(state) { - if ( - typeof state.trainerVisibilityService.onRunningTrainerChanged !== "function" - ) { - return false - } + if (typeof state.trainerVisibilityService.onRunningTrainerChanged !== 'function') { + return false; + } - state.trainerVisibilityService.onRunningTrainerChanged((runningTrainer) => { - state.currentRunningTrainer = normalizeRunningTrainerStatus( - runningTrainer, - runningTrainer ? "trainer-running" : "trainer-idle" - ) - syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer) - void syncGameStatus(state, true) - }) + state.trainerVisibilityService.onRunningTrainerChanged((runningTrainer) => { + state.currentRunningTrainer = normalizeRunningTrainerStatus( + runningTrainer, + runningTrainer ? 'trainer-running' : 'trainer-idle', + ); + syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer); + void syncGameStatus(state, true); + }); - return true + return true; } -function normalizeRunningTrainerStatus(runningTrainer, event = "snapshot") { - const info = isRecord(runningTrainer?.info) - ? runningTrainer.info - : isRecord(runningTrainer) - ? runningTrainer - : null - if (!info) { - return { - ...createIdleTrainerStatus(), - event, +function normalizeRunningTrainerStatus(runningTrainer, event = 'snapshot') { + const info = isRecord(runningTrainer?.info) + ? runningTrainer.info + : isRecord(runningTrainer) + ? runningTrainer + : null; + if (!info) { + return { + ...createIdleTrainerStatus(), + event, + }; } - } - return { - state: "running", - event, - trainerId: toStringId(info.trainerId) || toStringId(info.id), - displayName: safeString( - info.displayName, - info.gameName, - info.titleName, - info.title, - info.name - ), - gameId: toStringId(info.gameId), - titleId: toStringId(info.titleId), - } + return { + state: 'running', + event, + trainerId: toStringId(info.trainerId) || toStringId(info.id), + displayName: safeString( + info.displayName, + info.gameName, + info.titleName, + info.title, + info.name, + ), + gameId: toStringId(info.gameId), + titleId: toStringId(info.titleId), + }; } function syncGameSessionFromTrainerStatus(state, trainerStatus) { - if (trainerStatus?.state === "running") { + if (trainerStatus?.state === 'running') { + if ( + state.currentGameSession.state === 'running' && + !isSyntheticGameSessionEvent(state.currentGameSession.event) + ) { + return false; + } + + state.currentGameSession = { + state: 'running', + event: SYNTHETIC_SESSION_RUNNING_EVENT, + processId: state.currentGameSession.processId, + gameId: trainerStatus.gameId ?? state.currentGameSession.gameId, + titleId: trainerStatus.titleId ?? state.currentGameSession.titleId, + titleName: trainerStatus.displayName ?? state.currentGameSession.titleName, + sessionDurationSeconds: null, + startedAt: + state.currentGameSession.state === 'running' && + isSyntheticGameSessionEvent(state.currentGameSession.event) + ? state.currentGameSession.startedAt + : new Date().toISOString(), + endedAt: null, + }; + + return true; + } + if ( - state.currentGameSession.state === "running" && - !isSyntheticGameSessionEvent(state.currentGameSession.event) + state.currentGameSession.state !== 'running' || + !isSyntheticGameSessionEvent(state.currentGameSession.event) ) { - return false + return false; } - state.currentGameSession = { - state: "running", - event: SYNTHETIC_SESSION_RUNNING_EVENT, - processId: state.currentGameSession.processId, - gameId: trainerStatus.gameId ?? state.currentGameSession.gameId, - titleId: trainerStatus.titleId ?? state.currentGameSession.titleId, - titleName: - trainerStatus.displayName ?? state.currentGameSession.titleName, - sessionDurationSeconds: null, - startedAt: - state.currentGameSession.state === "running" && - isSyntheticGameSessionEvent(state.currentGameSession.event) - ? state.currentGameSession.startedAt - : new Date().toISOString(), - endedAt: null, - } + const startedAt = state.currentGameSession.startedAt; + const sessionDurationSeconds = startedAt + ? Math.max(0, Math.round((Date.now() - new Date(startedAt).getTime()) / 1000)) + : null; - return true - } + clearGameSession(state, SYNTHETIC_SESSION_IDLE_EVENT, sessionDurationSeconds); - if ( - state.currentGameSession.state !== "running" || - !isSyntheticGameSessionEvent(state.currentGameSession.event) - ) { - return false - } - - const startedAt = state.currentGameSession.startedAt - const sessionDurationSeconds = startedAt - ? Math.max( - 0, - Math.round((Date.now() - new Date(startedAt).getTime()) / 1000) - ) - : null - - clearGameSession(state, SYNTHETIC_SESSION_IDLE_EVENT, sessionDurationSeconds) - - return true + return true; } function clearGameSession( - state, - event, - sessionDurationSeconds = state.currentGameSession.sessionDurationSeconds -) { - state.currentGameSession = { - state: "idle", + state, event, - processId: null, - gameId: null, - titleId: null, - titleName: null, - sessionDurationSeconds, - startedAt: state.currentGameSession.startedAt, - endedAt: new Date().toISOString(), - } + sessionDurationSeconds = state.currentGameSession.sessionDurationSeconds, +) { + state.currentGameSession = { + state: 'idle', + event, + processId: null, + gameId: null, + titleId: null, + titleName: null, + sessionDurationSeconds, + startedAt: state.currentGameSession.startedAt, + endedAt: new Date().toISOString(), + }; } function isSyntheticGameSessionEvent(event) { - return ( - event === SYNTHETIC_SESSION_RUNNING_EVENT || - event === SYNTHETIC_SESSION_IDLE_EVENT - ) + return event === SYNTHETIC_SESSION_RUNNING_EVENT || event === SYNTHETIC_SESSION_IDLE_EVENT; } function buildGameStatusSnapshot(state) { - return { - instanceId: "wand-game-status", - updatedAt: new Date().toISOString(), - session: { ...state.currentGameSession }, - trainer: { ...state.currentRunningTrainer }, - } + return { + instanceId: 'wand-game-status', + updatedAt: new Date().toISOString(), + session: { ...state.currentGameSession }, + trainer: { ...state.currentRunningTrainer }, + }; } function makeGameStatusSignature(snapshot) { - return [ - snapshot.session.state, - snapshot.session.event, - snapshot.session.processId ?? "", - snapshot.session.gameId ?? "", - snapshot.session.titleId ?? "", - snapshot.session.titleName ?? "", - snapshot.session.sessionDurationSeconds ?? "", - snapshot.session.startedAt ?? "", - snapshot.session.endedAt ?? "", - snapshot.trainer.state, - snapshot.trainer.event, - snapshot.trainer.trainerId ?? "", - snapshot.trainer.displayName ?? "", - snapshot.trainer.gameId ?? "", - snapshot.trainer.titleId ?? "", - ].join("|") + return [ + snapshot.session.state, + snapshot.session.event, + snapshot.session.processId ?? '', + snapshot.session.gameId ?? '', + snapshot.session.titleId ?? '', + snapshot.session.titleName ?? '', + snapshot.session.sessionDurationSeconds ?? '', + snapshot.session.startedAt ?? '', + snapshot.session.endedAt ?? '', + snapshot.trainer.state, + snapshot.trainer.event, + snapshot.trainer.trainerId ?? '', + snapshot.trainer.displayName ?? '', + snapshot.trainer.gameId ?? '', + snapshot.trainer.titleId ?? '', + ].join('|'); } diff --git a/web-panel/bridge/scripts/default/installed-apps-sync/index.js b/web-panel/bridge/scripts/default/installed-apps-sync/index.js index 6fcd19f..9371265 100644 --- a/web-panel/bridge/scripts/default/installed-apps-sync/index.js +++ b/web-panel/bridge/scripts/default/installed-apps-sync/index.js @@ -1,450 +1,436 @@ import { - BIND_CHANNEL, - BOOTSTRAP_LOG_THROTTLE_ATTEMPTS, - COMMAND_REQUEST_CHANNEL, - CONTAINER_LOG_THROTTLE_ATTEMPTS, - FOLLOW_UP_SYNC_DELAY_MS, - GLOBAL_FLAG, - MAX_BOOTSTRAP_ATTEMPTS, - MAX_OPTIONAL_SERVICES_ATTEMPTS, - OPTIONAL_SERVICES_RETRY_INTERVAL_MS, - RETRY_DELAY_MS, - SYNC_CHANNEL, - SYNC_INTERVAL_MS, -} from "./constants.js" + BIND_CHANNEL, + BOOTSTRAP_LOG_THROTTLE_ATTEMPTS, + COMMAND_REQUEST_CHANNEL, + CONTAINER_LOG_THROTTLE_ATTEMPTS, + FOLLOW_UP_SYNC_DELAY_MS, + GLOBAL_FLAG, + MAX_BOOTSTRAP_ATTEMPTS, + MAX_OPTIONAL_SERVICES_ATTEMPTS, + OPTIONAL_SERVICES_RETRY_INTERVAL_MS, + RETRY_DELAY_MS, + SYNC_CHANNEL, + SYNC_INTERVAL_MS, +} from './constants.js'; import { - createIdleGameSession, - createIdleTrainerStatus, - installGameStatusSubscriptions, -} from "./game-status.js" + createIdleGameSession, + createIdleTrainerStatus, + installGameStatusSubscriptions, +} from './game-status.js'; import { - buildSnapshot, - makeInstalledAppsSignature, - refreshUnavailableTitles, - resolveInstalledData, -} from "./installed-data.js" -import { createLogger } from "./logger.js" -import { handleRemoteCommandRequest } from "./remote-commands.js" + buildSnapshot, + makeInstalledAppsSignature, + refreshUnavailableTitles, + resolveInstalledData, +} from './installed-data.js'; +import { createLogger } from './logger.js'; +import { handleRemoteCommandRequest } from './remote-commands.js'; import { - formatError, - getAppRoot, - getAureliaContainer, - getRequire, - getWebpackRequire, - hasAppRoot, - invokeIpc, - isRecord, - summarizeAureliaSubtree, -} from "./runtime.js" + formatError, + getAppRoot, + getAureliaContainer, + getRequire, + getWebpackRequire, + hasAppRoot, + invokeIpc, + isRecord, + summarizeAureliaSubtree, +} from './runtime.js'; import { - getInstalledAppsService, - getStoreRef, - hasMissingOptionalServices, - hasUnresolvedServices, - resolveOptionalServices, -} from "./services.js" + getInstalledAppsService, + getStoreRef, + hasMissingOptionalServices, + hasUnresolvedServices, + resolveOptionalServices, +} from './services.js'; export function installInstalledAppsSync(WandEnhancer) { - if (globalThis[GLOBAL_FLAG]) { - return - } + if (globalThis[GLOBAL_FLAG]) { + return; + } - globalThis[GLOBAL_FLAG] = true + globalThis[GLOBAL_FLAG] = true; - const state = createState(WandEnhancer) - state.resolveRemoteCommandServices = () => resolveRemoteCommandServices(state) - state.queueSync = (force = false) => queueSync(state, force) - state.queueFollowUpSync = () => queueFollowUpSync(state) + const state = createState(WandEnhancer); + state.resolveRemoteCommandServices = () => resolveRemoteCommandServices(state); + state.queueSync = (force = false) => queueSync(state, force); + state.queueFollowUpSync = () => queueFollowUpSync(state); - state.log( - "info", - "Script loaded.", - `logFile=${globalThis.__wandInstalledAppsSyncLogFile || "console-only"}` - ) - retryBootstrap(state) + state.log( + 'info', + 'Script loaded.', + `logFile=${globalThis.__wandInstalledAppsSyncLogFile || 'console-only'}`, + ); + retryBootstrap(state); } function createState(WandEnhancer) { - return { - WandEnhancer, - log: createLogger(WandEnhancer), - lastSignature: null, - lastGameStatusSignature: null, - refreshTimer: null, - followUpSyncTimer: null, - pollTimer: null, - optionalServicesTimer: null, - bootstrapAttempts: 0, - optionalServicesAttempts: 0, - bridgeBound: false, - bridgeBinding: false, - refreshPatched: false, - installedAppsService: null, - gameLifecycleService: null, - trainerVisibilityService: null, - unavailableTitlesService: null, - storeRef: null, - ipcRenderer: null, - lastBootstrapReason: null, - gameLifecycleSubscriptionsInstalled: false, - trainerVisibilitySubscriptionInstalled: false, - trainerEndedSubscriptionInstalled: false, - unavailableTitlesFetchKey: null, - unavailableTitlesFetchPromise: null, - unavailableTitlesById: {}, - trainerApiService: null, - trainerService: null, - trainerLaunchRequestCtor: null, - commandListenerInstalled: false, - missingOptionalServiceWarnings: new Set(), - currentGameSession: createIdleGameSession(), - currentRunningTrainer: createIdleTrainerStatus(), - resolveRemoteCommandServices: null, - queueSync: null, - queueFollowUpSync: null, - } + return { + WandEnhancer, + log: createLogger(WandEnhancer), + lastSignature: null, + lastGameStatusSignature: null, + refreshTimer: null, + followUpSyncTimer: null, + pollTimer: null, + optionalServicesTimer: null, + bootstrapAttempts: 0, + optionalServicesAttempts: 0, + bridgeBound: false, + bridgeBinding: false, + refreshPatched: false, + installedAppsService: null, + gameLifecycleService: null, + trainerVisibilityService: null, + unavailableTitlesService: null, + storeRef: null, + ipcRenderer: null, + lastBootstrapReason: null, + gameLifecycleSubscriptionsInstalled: false, + trainerVisibilitySubscriptionInstalled: false, + trainerEndedSubscriptionInstalled: false, + unavailableTitlesFetchKey: null, + unavailableTitlesFetchPromise: null, + unavailableTitlesById: {}, + trainerApiService: null, + trainerService: null, + trainerLaunchRequestCtor: null, + commandListenerInstalled: false, + missingOptionalServiceWarnings: new Set(), + currentGameSession: createIdleGameSession(), + currentRunningTrainer: createIdleTrainerStatus(), + resolveRemoteCommandServices: null, + queueSync: null, + queueFollowUpSync: null, + }; } function setBootstrapReason(state, reason) { - if ( - reason === state.lastBootstrapReason && - state.bootstrapAttempts % BOOTSTRAP_LOG_THROTTLE_ATTEMPTS !== 0 - ) { - return - } + if ( + reason === state.lastBootstrapReason && + state.bootstrapAttempts % BOOTSTRAP_LOG_THROTTLE_ATTEMPTS !== 0 + ) { + return; + } - state.lastBootstrapReason = reason - state.log( - "info", - `Bootstrap waiting: ${reason}.`, - `attempt=${state.bootstrapAttempts + 1}/${MAX_BOOTSTRAP_ATTEMPTS}` - ) + state.lastBootstrapReason = reason; + state.log( + 'info', + `Bootstrap waiting: ${reason}.`, + `attempt=${state.bootstrapAttempts + 1}/${MAX_BOOTSTRAP_ATTEMPTS}`, + ); } async function bindBridge(state) { - if (state.bridgeBound || state.bridgeBinding || !state.ipcRenderer) { - return - } + if (state.bridgeBound || state.bridgeBinding || !state.ipcRenderer) { + return; + } - if (!state.commandListenerInstalled) { - state.ipcRenderer.on(COMMAND_REQUEST_CHANNEL, (event, request) => - handleRemoteCommandRequest(state, event, request) - ) - state.commandListenerInstalled = true - state.log("info", "Bridge remote command handler installed.") - } + if (!state.commandListenerInstalled) { + state.ipcRenderer.on(COMMAND_REQUEST_CHANNEL, (event, request) => + handleRemoteCommandRequest(state, event, request), + ); + state.commandListenerInstalled = true; + state.log('info', 'Bridge remote command handler installed.'); + } - // Await the bind: invoke rejects asynchronously, so marking the bridge bound up - // front left set-value permanently dead whenever the main-process handler was not - // registered yet - and the log still claimed success. - state.bridgeBinding = true - try { - await state.ipcRenderer.invoke(BIND_CHANNEL) - state.bridgeBound = true - state.log("info", "Bridge set-value handler bound.") - } catch (error) { - state.log("warn", "Bridge bind failed; will retry on the next sync.", formatError(error)) - } finally { - state.bridgeBinding = false - } + // Await the bind: invoke rejects asynchronously, so marking the bridge bound up + // front left set-value permanently dead whenever the main-process handler was not + // registered yet - and the log still claimed success. + state.bridgeBinding = true; + try { + await state.ipcRenderer.invoke(BIND_CHANNEL); + state.bridgeBound = true; + state.log('info', 'Bridge set-value handler bound.'); + } catch (error) { + state.log('warn', 'Bridge bind failed; will retry on the next sync.', formatError(error)); + } finally { + state.bridgeBinding = false; + } } async function syncInstalledApps(state, force = false) { - if (!state.ipcRenderer || (!state.installedAppsService && !state.storeRef)) { - return false - } - - const data = resolveInstalledData(state) - if (!data) { - if (state.installedAppsService) { - state.log( - "warn", - "Service resolved but installedApps is empty/undefined. Store fallback also unavailable." - ) + if (!state.ipcRenderer || (!state.installedAppsService && !state.storeRef)) { + return false; } - return false - } - if (Object.keys(data.rawInstalledApps).length > 0) { - await refreshUnavailableTitles(state, data.rawInstalledApps, force) - } + const data = resolveInstalledData(state); + if (!data) { + if (state.installedAppsService) { + state.log( + 'warn', + 'Service resolved but installedApps is empty/undefined. Store fallback also unavailable.', + ); + } + return false; + } - const snapshot = buildSnapshot(state) - if (!snapshot) { - state.log("warn", "Snapshot build returned nothing.") - return false - } + if (Object.keys(data.rawInstalledApps).length > 0) { + await refreshUnavailableTitles(state, data.rawInstalledApps, force); + } - const signature = makeInstalledAppsSignature(snapshot) - if (!force && signature === state.lastSignature) { - return false - } + const snapshot = buildSnapshot(state); + if (!snapshot) { + state.log('warn', 'Snapshot build returned nothing.'); + return false; + } - state.lastSignature = signature + const signature = makeInstalledAppsSignature(snapshot); + if (!force && signature === state.lastSignature) { + return false; + } - const sent = await invokeIpc( - state, - SYNC_CHANNEL, - snapshot, - "Installed apps snapshot", - "error" - ) - if (sent) { - state.log( - "info", - "Installed apps snapshot sent.", - `apps=${snapshot.apps.length}, catalogGames=${snapshot.diagnostics.catalogGames}, rawInstalledApps=${snapshot.diagnostics.rawInstalledApps}` - ) - } - return sent + state.lastSignature = signature; + + const sent = await invokeIpc(state, SYNC_CHANNEL, snapshot, 'Installed apps snapshot', 'error'); + if (sent) { + state.log( + 'info', + 'Installed apps snapshot sent.', + `apps=${snapshot.apps.length}, catalogGames=${snapshot.diagnostics.catalogGames}, rawInstalledApps=${snapshot.diagnostics.rawInstalledApps}`, + ); + } + return sent; } function queueSync(state, force = false) { - if (state.refreshTimer) { - clearTimeout(state.refreshTimer) - } + if (state.refreshTimer) { + clearTimeout(state.refreshTimer); + } - state.refreshTimer = setTimeout(() => { - state.refreshTimer = null - void syncInstalledApps(state, force) - }, 0) + state.refreshTimer = setTimeout(() => { + state.refreshTimer = null; + void syncInstalledApps(state, force); + }, 0); } function queueFollowUpSync(state) { - if (state.followUpSyncTimer) { - clearTimeout(state.followUpSyncTimer) - } + if (state.followUpSyncTimer) { + clearTimeout(state.followUpSyncTimer); + } - state.followUpSyncTimer = setTimeout(() => { - state.followUpSyncTimer = null - void syncInstalledApps(state, true) - }, FOLLOW_UP_SYNC_DELAY_MS) + state.followUpSyncTimer = setTimeout(() => { + state.followUpSyncTimer = null; + void syncInstalledApps(state, true); + }, FOLLOW_UP_SYNC_DELAY_MS); } function patchRefreshApps(state) { - if ( - !state.installedAppsService || - state.refreshPatched || - typeof state.installedAppsService.refreshApps !== "function" - ) { - return - } + if ( + !state.installedAppsService || + state.refreshPatched || + typeof state.installedAppsService.refreshApps !== 'function' + ) { + return; + } - const originalRefreshApps = state.installedAppsService.refreshApps.bind( - state.installedAppsService - ) - state.refreshPatched = true - state.log("info", "refreshApps hook installed.") + const originalRefreshApps = state.installedAppsService.refreshApps.bind( + state.installedAppsService, + ); + state.refreshPatched = true; + state.log('info', 'refreshApps hook installed.'); - state.installedAppsService.refreshApps = async (...args) => { - const result = await originalRefreshApps(...args) - state.log("info", "refreshApps completed; queueing installed apps sync.") - queueSync(state, true) - queueFollowUpSync(state) - return result - } + state.installedAppsService.refreshApps = async (...args) => { + const result = await originalRefreshApps(...args); + state.log('info', 'refreshApps completed; queueing installed apps sync.'); + queueSync(state, true); + queueFollowUpSync(state); + return result; + }; } function resolveRemoteCommandServices(state) { - const container = getAureliaContainer() - const webpackRequire = getWebpackRequire() - if (!container || !webpackRequire) { - return false - } + const container = getAureliaContainer(); + const webpackRequire = getWebpackRequire(); + if (!container || !webpackRequire) { + return false; + } - resolveRuntimeServices(state, container, webpackRequire) - return true + resolveRuntimeServices(state, container, webpackRequire); + return true; } function resolveRuntimeServices(state, container, webpackRequire) { - resolveOptionalServices(state, container, webpackRequire) - installGameStatusSubscriptions(state) + resolveOptionalServices(state, container, webpackRequire); + installGameStatusSubscriptions(state); - if (!hasMissingOptionalServices(state)) { - stopOptionalServicesRetry(state) - } + if (!hasMissingOptionalServices(state)) { + stopOptionalServicesRetry(state); + } } function stopOptionalServicesRetry(state) { - if (!state.optionalServicesTimer) { - return - } + if (!state.optionalServicesTimer) { + return; + } - clearInterval(state.optionalServicesTimer) - state.optionalServicesTimer = null + clearInterval(state.optionalServicesTimer); + state.optionalServicesTimer = null; } function startOptionalServicesRetry(state) { - if (state.optionalServicesTimer || !hasMissingOptionalServices(state)) { - return - } - - state.optionalServicesTimer = setInterval(() => { - state.optionalServicesAttempts += 1 - - const container = getAureliaContainer() - const webpackRequire = getWebpackRequire() - if (container && webpackRequire) { - resolveRuntimeServices(state, container, webpackRequire) + if (state.optionalServicesTimer || !hasMissingOptionalServices(state)) { + return; } - if ( - state.optionalServicesTimer && - state.optionalServicesAttempts >= MAX_OPTIONAL_SERVICES_ATTEMPTS - ) { - stopOptionalServicesRetry(state) - state.log( - "warn", - "Optional service retry exhausted.", - `attempts=${state.optionalServicesAttempts}` - ) - } - }, OPTIONAL_SERVICES_RETRY_INTERVAL_MS) + state.optionalServicesTimer = setInterval(() => { + state.optionalServicesAttempts += 1; - state.log( - "info", - "Optional service retry timer started.", - `${OPTIONAL_SERVICES_RETRY_INTERVAL_MS}ms` - ) + const container = getAureliaContainer(); + const webpackRequire = getWebpackRequire(); + if (container && webpackRequire) { + resolveRuntimeServices(state, container, webpackRequire); + } + + if ( + state.optionalServicesTimer && + state.optionalServicesAttempts >= MAX_OPTIONAL_SERVICES_ATTEMPTS + ) { + stopOptionalServicesRetry(state); + state.log( + 'warn', + 'Optional service retry exhausted.', + `attempts=${state.optionalServicesAttempts}`, + ); + } + }, OPTIONAL_SERVICES_RETRY_INTERVAL_MS); + + state.log( + 'info', + 'Optional service retry timer started.', + `${OPTIONAL_SERVICES_RETRY_INTERVAL_MS}ms`, + ); } function bootstrap(state) { - if (!hasAppRoot()) { - setBootstrapReason(state, "app root not ready") - return false - } - - if (!state.ipcRenderer && !resolveIpcRenderer(state)) { - return false - } - - const webpackRequire = getWebpackRequire() - if (!webpackRequire) { - setBootstrapReason(state, "webpack runtime not ready") - return false - } - - const container = getAureliaContainer() - if (!container) { - logMissingContainer(state) - setBootstrapReason(state, "Aurelia container not ready") - return false - } - - state.log("info", "Aurelia container resolved.") - - if (!state.storeRef) { - state.storeRef = getStoreRef(state, container, webpackRequire) - if (!state.storeRef) { - state.log( - "warn", - "Store reference unavailable; unsupported installed titles will be missing." - ) + if (!hasAppRoot()) { + setBootstrapReason(state, 'app root not ready'); + return false; } - } - state.installedAppsService = getInstalledAppsService( - state, - container, - webpackRequire - ) - if (!state.installedAppsService) { - setBootstrapReason(state, "installed apps service not ready") - return false - } - - resolveRuntimeServices(state, container, webpackRequire) - startOptionalServicesRetry(state) - - if (!isRecord(state.installedAppsService.installedApps)) { - state.log( - "info", - "Service instance has no installedApps data yet; reading from store until refreshApps populates it." - ) - if (!state.storeRef) { - state.log("warn", "Store fallback also unavailable; will retry on poll.") + if (!state.ipcRenderer && !resolveIpcRenderer(state)) { + return false; } - } - void bindBridge(state) - patchRefreshApps(state) - queueSync(state, true) - queueFollowUpSync(state) - startPollTimer(state) + const webpackRequire = getWebpackRequire(); + if (!webpackRequire) { + setBootstrapReason(state, 'webpack runtime not ready'); + return false; + } - state.log("info", "Installed apps sync ready.") - return true + const container = getAureliaContainer(); + if (!container) { + logMissingContainer(state); + setBootstrapReason(state, 'Aurelia container not ready'); + return false; + } + + state.log('info', 'Aurelia container resolved.'); + + if (!state.storeRef) { + state.storeRef = getStoreRef(state, container, webpackRequire); + if (!state.storeRef) { + state.log( + 'warn', + 'Store reference unavailable; unsupported installed titles will be missing.', + ); + } + } + + state.installedAppsService = getInstalledAppsService(state, container, webpackRequire); + if (!state.installedAppsService) { + setBootstrapReason(state, 'installed apps service not ready'); + return false; + } + + resolveRuntimeServices(state, container, webpackRequire); + startOptionalServicesRetry(state); + + if (!isRecord(state.installedAppsService.installedApps)) { + state.log( + 'info', + 'Service instance has no installedApps data yet; reading from store until refreshApps populates it.', + ); + if (!state.storeRef) { + state.log('warn', 'Store fallback also unavailable; will retry on poll.'); + } + } + + void bindBridge(state); + patchRefreshApps(state); + queueSync(state, true); + queueFollowUpSync(state); + startPollTimer(state); + + state.log('info', 'Installed apps sync ready.'); + return true; } function resolveIpcRenderer(state) { - const electron = getRequire()?.("electron") - if (!electron?.ipcRenderer) { - setBootstrapReason(state, "electron ipcRenderer not ready") - return false - } + const electron = getRequire()?.('electron'); + if (!electron?.ipcRenderer) { + setBootstrapReason(state, 'electron ipcRenderer not ready'); + return false; + } - state.ipcRenderer = electron.ipcRenderer - state.log("info", "Electron ipcRenderer resolved.") - return true + state.ipcRenderer = electron.ipcRenderer; + state.log('info', 'Electron ipcRenderer resolved.'); + return true; } function startPollTimer(state) { - if (state.pollTimer) { - return - } - - state.pollTimer = setInterval(() => { - if (hasUnresolvedServices(state)) { - const container = getAureliaContainer() - const webpackRequire = getWebpackRequire() - if (container && webpackRequire) { - resolveRuntimeServices(state, container, webpackRequire) - } + if (state.pollTimer) { + return; } - void bindBridge(state) - void syncInstalledApps(state) - }, SYNC_INTERVAL_MS) + state.pollTimer = setInterval(() => { + if (hasUnresolvedServices(state)) { + const container = getAureliaContainer(); + const webpackRequire = getWebpackRequire(); + if (container && webpackRequire) { + resolveRuntimeServices(state, container, webpackRequire); + } + } - state.log( - "info", - "Installed apps poll timer started.", - `${SYNC_INTERVAL_MS}ms` - ) + void bindBridge(state); + void syncInstalledApps(state); + }, SYNC_INTERVAL_MS); + + state.log('info', 'Installed apps poll timer started.', `${SYNC_INTERVAL_MS}ms`); } function logMissingContainer(state) { - if (state.bootstrapAttempts % CONTAINER_LOG_THROTTLE_ATTEMPTS !== 0) { - return - } + if (state.bootstrapAttempts % CONTAINER_LOG_THROTTLE_ATTEMPTS !== 0) { + return; + } - const root = getAppRoot() - const aureliaKeys = root - ? Object.getOwnPropertyNames(root) - .filter((key) => key.startsWith("__") || key === "au") - .join(", ") - : "root=null" - state.log( - "warn", - "Aurelia container not found.", - `rootProps=${aureliaKeys || "(none)"}, subtree=${summarizeAureliaSubtree(root)}` - ) + const root = getAppRoot(); + const aureliaKeys = root + ? Object.getOwnPropertyNames(root) + .filter((key) => key.startsWith('__') || key === 'au') + .join(', ') + : 'root=null'; + state.log( + 'warn', + 'Aurelia container not found.', + `rootProps=${aureliaKeys || '(none)'}, subtree=${summarizeAureliaSubtree(root)}`, + ); } function retryBootstrap(state) { - if (bootstrap(state)) { - return - } + if (bootstrap(state)) { + return; + } - state.bootstrapAttempts += 1 - if (state.bootstrapAttempts < MAX_BOOTSTRAP_ATTEMPTS) { - setTimeout(() => retryBootstrap(state), RETRY_DELAY_MS) - return - } + state.bootstrapAttempts += 1; + if (state.bootstrapAttempts < MAX_BOOTSTRAP_ATTEMPTS) { + setTimeout(() => retryBootstrap(state), RETRY_DELAY_MS); + return; + } - state.log( - "error", - "Installed apps sync bootstrap exhausted.", - state.lastBootstrapReason || "unknown reason" - ) + state.log( + 'error', + 'Installed apps sync bootstrap exhausted.', + state.lastBootstrapReason || 'unknown reason', + ); } diff --git a/web-panel/bridge/scripts/default/installed-apps-sync/installed-data.js b/web-panel/bridge/scripts/default/installed-apps-sync/installed-data.js index 7fd59d9..229d899 100644 --- a/web-panel/bridge/scripts/default/installed-apps-sync/installed-data.js +++ b/web-panel/bridge/scripts/default/installed-apps-sync/installed-data.js @@ -1,246 +1,240 @@ import { - EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS, - SNAPSHOT_ENTRY_KEY_PREFIX, - UNAVAILABLE_TITLES_BATCH_SIZE, -} from "./constants.js" + findSteamAppId, + getInstalledAppSteamAppId, + getSidebarGameRowClientIcons, + getSidebarGameRowClientIconUrl, + getSteamClientIconUrl, + pickImageUrl, +} from './artwork.js'; import { - findSteamAppId, - getInstalledAppSteamAppId, - getSidebarGameRowClientIconUrl, - getSidebarGameRowClientIcons, - getSteamClientIconUrl, - pickImageUrl, -} from "./artwork.js" + EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS, + SNAPSHOT_ENTRY_KEY_PREFIX, + UNAVAILABLE_TITLES_BATCH_SIZE, +} from './constants.js'; import { - getBasename, - formatError, - isRecord, - normalizeStringList, - safeString, - toStringId, -} from "./runtime.js" + formatError, + getBasename, + isRecord, + normalizeStringList, + safeString, + toStringId, +} from './runtime.js'; export function resolveInstalledData(state) { - const storeState = getStoreState(state.storeRef) + const storeState = getStoreState(state.storeRef); - if (isRecord(state.installedAppsService?.installedApps)) { - return { - rawInstalledApps: state.installedAppsService.installedApps, - catalog: isRecord(state.installedAppsService.catalog) - ? state.installedAppsService.catalog - : isRecord(storeState?.catalog) - ? storeState.catalog - : {}, - installedGameVersions: isRecord( - state.installedAppsService.installedVersions - ) - ? state.installedAppsService.installedVersions - : isRecord(storeState?.installedGameVersions) - ? storeState.installedGameVersions - : {}, - correlatedUnavailableTitles: getResolvedUnavailableTitles( - state, - storeState?.correlatedUnavailableTitles - ), - source: "service", + if (isRecord(state.installedAppsService?.installedApps)) { + return { + rawInstalledApps: state.installedAppsService.installedApps, + catalog: isRecord(state.installedAppsService.catalog) + ? state.installedAppsService.catalog + : isRecord(storeState?.catalog) + ? storeState.catalog + : {}, + installedGameVersions: isRecord(state.installedAppsService.installedVersions) + ? state.installedAppsService.installedVersions + : isRecord(storeState?.installedGameVersions) + ? storeState.installedGameVersions + : {}, + correlatedUnavailableTitles: getResolvedUnavailableTitles( + state, + storeState?.correlatedUnavailableTitles, + ), + source: 'service', + }; } - } - if (isRecord(storeState?.installedApps)) { - return { - rawInstalledApps: storeState.installedApps, - catalog: isRecord(storeState.catalog) ? storeState.catalog : {}, - installedGameVersions: isRecord(storeState.installedGameVersions) - ? storeState.installedGameVersions - : {}, - correlatedUnavailableTitles: getResolvedUnavailableTitles( - state, - storeState.correlatedUnavailableTitles - ), - source: "store", + if (isRecord(storeState?.installedApps)) { + return { + rawInstalledApps: storeState.installedApps, + catalog: isRecord(storeState.catalog) ? storeState.catalog : {}, + installedGameVersions: isRecord(storeState.installedGameVersions) + ? storeState.installedGameVersions + : {}, + correlatedUnavailableTitles: getResolvedUnavailableTitles( + state, + storeState.correlatedUnavailableTitles, + ), + source: 'store', + }; } - } - return null + return null; } -export async function refreshUnavailableTitles( - state, - rawInstalledApps, - force = false -) { - if (!state.unavailableTitlesService) { - return state.unavailableTitlesById - } - - const correlationIds = getCorrelationIdsForUnavailableTitles(rawInstalledApps) - const fetchKey = correlationIds.join("\n") - if (!fetchKey) { - state.unavailableTitlesFetchKey = "" - state.unavailableTitlesById = {} - return state.unavailableTitlesById - } - - if (!force && fetchKey === state.unavailableTitlesFetchKey) { - if (state.unavailableTitlesFetchPromise) { - await state.unavailableTitlesFetchPromise +export async function refreshUnavailableTitles(state, rawInstalledApps, force = false) { + if (!state.unavailableTitlesService) { + return state.unavailableTitlesById; } - return state.unavailableTitlesById - } - state.unavailableTitlesFetchKey = fetchKey - state.unavailableTitlesFetchPromise = fetchUnavailableTitles( - state, - correlationIds - ) - await state.unavailableTitlesFetchPromise - return state.unavailableTitlesById + const correlationIds = getCorrelationIdsForUnavailableTitles(rawInstalledApps); + const fetchKey = correlationIds.join('\n'); + if (!fetchKey) { + state.unavailableTitlesFetchKey = ''; + state.unavailableTitlesById = {}; + return state.unavailableTitlesById; + } + + if (!force && fetchKey === state.unavailableTitlesFetchKey) { + if (state.unavailableTitlesFetchPromise) { + await state.unavailableTitlesFetchPromise; + } + return state.unavailableTitlesById; + } + + state.unavailableTitlesFetchKey = fetchKey; + state.unavailableTitlesFetchPromise = fetchUnavailableTitles(state, correlationIds); + await state.unavailableTitlesFetchPromise; + return state.unavailableTitlesById; } export function buildSnapshot(state) { - const data = resolveInstalledData(state) - if (!data) { - if (state.installedAppsService) { - state.log( - "warn", - "Service resolved but installedApps is empty/undefined. Store fallback also unavailable." - ) - } - return null - } - - const { - rawInstalledApps, - catalog, - installedGameVersions, - correlatedUnavailableTitles, - source, - } = data - const catalogGames = isRecord(catalog.games) ? catalog.games : {} - const catalogTitles = isRecord(catalog.titles) ? catalog.titles : {} - const sidebarGameRowClientIcons = getSidebarGameRowClientIcons() - const entriesByKey = new Map() - let matchedCatalogGames = 0 - let matchedUnavailableGames = 0 - - state.log( - "info", - `Building snapshot from ${source}.`, - `rawInstalledApps=${Object.keys(rawInstalledApps).length}, catalogGames=${Object.keys(catalogGames).length}, installedGameVersions=${Object.keys(installedGameVersions).length}, unavailableTitles=${Object.keys(correlatedUnavailableTitles).length}` - ) - - for (const [gameId, versions] of Object.entries(installedGameVersions)) { - if (!Array.isArray(versions)) { - continue + const data = resolveInstalledData(state); + if (!data) { + if (state.installedAppsService) { + state.log( + 'warn', + 'Service resolved but installedApps is empty/undefined. Store fallback also unavailable.', + ); + } + return null; } - const game = catalogGames[gameId] - if (!isRecord(game)) { - continue - } - - const preferredApp = pickPreferredInstalledApp( - rawInstalledApps, - game, - versions - ) - if (!preferredApp) { - continue - } - - const titleId = toStringId(game.titleId) - const title = titleId - ? catalogTitles[titleId] || catalogTitles[game.titleId] || null - : null - const sidebarClientIconUrl = getSidebarGameRowClientIconUrl( - sidebarGameRowClientIcons, - titleId, - title?.name, - title?.displayName, - game.displayName, - game.title, - game.name, - preferredApp.displayName - ) - - upsertSnapshotEntry(entriesByKey, { - ...preferredApp, - displayName: safeString( - title?.name, - title?.displayName, - game.displayName, - game.title, - game.name, - preferredApp.displayName, - gameId - ), - imageUrl: pickImageUrlForTitle(title, game, preferredApp, sidebarClientIconUrl, versions), - gameId: String(gameId), - titleId, - }) - matchedCatalogGames += 1 - } - - for (const unavailableTitle of Object.values(correlatedUnavailableTitles)) { - if (!isRecord(unavailableTitle) || !Array.isArray(unavailableTitle.games)) { - continue - } - - const titleId = toStringId(unavailableTitle.id) - for (const game of unavailableTitle.games) { - if (!isRecord(game) || !Array.isArray(game.correlationIds)) { - continue - } - - const preferredApp = pickPreferredInstalledApp( + const { rawInstalledApps, - game - ) - if (!preferredApp) { - continue - } + catalog, + installedGameVersions, + correlatedUnavailableTitles, + source, + } = data; + const catalogGames = isRecord(catalog.games) ? catalog.games : {}; + const catalogTitles = isRecord(catalog.titles) ? catalog.titles : {}; + const sidebarGameRowClientIcons = getSidebarGameRowClientIcons(); + const entriesByKey = new Map(); + let matchedCatalogGames = 0; + let matchedUnavailableGames = 0; - const sidebarClientIconUrl = getSidebarGameRowClientIconUrl( - sidebarGameRowClientIcons, - titleId, - unavailableTitle.name, - game.name, - preferredApp.displayName - ) - upsertSnapshotEntry(entriesByKey, { - ...preferredApp, - displayName: safeString( - unavailableTitle.name, - game.name, - preferredApp.displayName, - preferredApp.correlationId - ), - imageUrl: pickImageUrlForTitle(unavailableTitle, game, preferredApp, sidebarClientIconUrl), - gameId: toStringId(game.id), - titleId, - }) - matchedUnavailableGames += 1 + state.log( + 'info', + `Building snapshot from ${source}.`, + `rawInstalledApps=${Object.keys(rawInstalledApps).length}, catalogGames=${Object.keys(catalogGames).length}, installedGameVersions=${Object.keys(installedGameVersions).length}, unavailableTitles=${Object.keys(correlatedUnavailableTitles).length}`, + ); + + for (const [gameId, versions] of Object.entries(installedGameVersions)) { + if (!Array.isArray(versions)) { + continue; + } + + const game = catalogGames[gameId]; + if (!isRecord(game)) { + continue; + } + + const preferredApp = pickPreferredInstalledApp(rawInstalledApps, game, versions); + if (!preferredApp) { + continue; + } + + const titleId = toStringId(game.titleId); + const title = titleId + ? catalogTitles[titleId] || catalogTitles[game.titleId] || null + : null; + const sidebarClientIconUrl = getSidebarGameRowClientIconUrl( + sidebarGameRowClientIcons, + titleId, + title?.name, + title?.displayName, + game.displayName, + game.title, + game.name, + preferredApp.displayName, + ); + + upsertSnapshotEntry(entriesByKey, { + ...preferredApp, + displayName: safeString( + title?.name, + title?.displayName, + game.displayName, + game.title, + game.name, + preferredApp.displayName, + gameId, + ), + imageUrl: pickImageUrlForTitle( + title, + game, + preferredApp, + sidebarClientIconUrl, + versions, + ), + gameId: String(gameId), + titleId, + }); + matchedCatalogGames += 1; } - } - const apps = Array.from(entriesByKey.values()).sort(compareInstalledAppRecords) + for (const unavailableTitle of Object.values(correlatedUnavailableTitles)) { + if (!isRecord(unavailableTitle) || !Array.isArray(unavailableTitle.games)) { + continue; + } - return { - instanceId: "wand-installed-apps", - updatedAt: new Date().toISOString(), - apps, - diagnostics: { - catalogGames: Object.keys(catalogGames).length, - catalogTitles: Object.keys(catalogTitles).length, - installedGameVersions: Object.keys(installedGameVersions).length, - correlatedUnavailableTitles: Object.keys(correlatedUnavailableTitles) - .length, - matchedCatalogGames, - matchedUnavailableGames, - myGames: apps.length, - rawInstalledApps: Object.keys(rawInstalledApps).length, - }, - } + const titleId = toStringId(unavailableTitle.id); + for (const game of unavailableTitle.games) { + if (!isRecord(game) || !Array.isArray(game.correlationIds)) { + continue; + } + + const preferredApp = pickPreferredInstalledApp(rawInstalledApps, game); + if (!preferredApp) { + continue; + } + + const sidebarClientIconUrl = getSidebarGameRowClientIconUrl( + sidebarGameRowClientIcons, + titleId, + unavailableTitle.name, + game.name, + preferredApp.displayName, + ); + upsertSnapshotEntry(entriesByKey, { + ...preferredApp, + displayName: safeString( + unavailableTitle.name, + game.name, + preferredApp.displayName, + preferredApp.correlationId, + ), + imageUrl: pickImageUrlForTitle( + unavailableTitle, + game, + preferredApp, + sidebarClientIconUrl, + ), + gameId: toStringId(game.id), + titleId, + }); + matchedUnavailableGames += 1; + } + } + + const apps = Array.from(entriesByKey.values()).sort(compareInstalledAppRecords); + + return { + instanceId: 'wand-installed-apps', + updatedAt: new Date().toISOString(), + apps, + diagnostics: { + catalogGames: Object.keys(catalogGames).length, + catalogTitles: Object.keys(catalogTitles).length, + installedGameVersions: Object.keys(installedGameVersions).length, + correlatedUnavailableTitles: Object.keys(correlatedUnavailableTitles).length, + matchedCatalogGames, + matchedUnavailableGames, + myGames: apps.length, + rawInstalledApps: Object.keys(rawInstalledApps).length, + }, + }; } /** @@ -249,330 +243,295 @@ export function buildSnapshot(state) { * game moving install directory never reached the panel. */ export function makeInstalledAppsSignature(snapshot) { - return JSON.stringify(snapshot.apps) + return JSON.stringify(snapshot.apps); } export function toInstalledAppRecord(correlationId, app) { - if ( - !isRecord(app) || - typeof correlationId !== "string" || - !correlationId.trim() - ) { - return null - } + if (!isRecord(app) || typeof correlationId !== 'string' || !correlationId.trim()) { + return null; + } - const [fallbackPlatform, fallbackSku] = correlationId.split(":") - const platform = safeString(app.platform, fallbackPlatform) - const sku = safeString(app.sku, fallbackSku) - if (!platform || !sku) { - return null - } + const [fallbackPlatform, fallbackSku] = correlationId.split(':'); + const platform = safeString(app.platform, fallbackPlatform); + const sku = safeString(app.sku, fallbackSku); + if (!platform || !sku) { + return null; + } - const location = typeof app.location === "string" ? app.location : "" - const alternateLocations = normalizeStringList(app.alternateLocations) + const location = typeof app.location === 'string' ? app.location : ''; + const alternateLocations = normalizeStringList(app.alternateLocations); - return { - platform, - sku, - correlationId, - displayName: safeString( - app.displayName, - app.titleName, - app.gameName, - app.name, - getBasename(location), - correlationId - ), - location, - alternateLocations, - imageUrl: pickImageUrl( - app.imageUrl, - app.iconUrl, - app.coverUrl, - app.thumbnailUrl, - app.logoUrl, - app.headerImageUrl, - app.icon, - app.images, - app.assets, - getSteamClientIconUrl( - findSteamAppId(app), - getInstalledAppSteamAppId(platform, sku) - ) - ), - platformLastPlayedTimestamp: - typeof app.platformLastPlayedTimestamp === "number" - ? app.platformLastPlayedTimestamp - : null, - platformTotalPlaytimeMinutes: - typeof app.platformTotalPlaytimeMinutes === "number" - ? app.platformTotalPlaytimeMinutes - : null, - } + return { + platform, + sku, + correlationId, + displayName: safeString( + app.displayName, + app.titleName, + app.gameName, + app.name, + getBasename(location), + correlationId, + ), + location, + alternateLocations, + imageUrl: pickImageUrl( + app.imageUrl, + app.iconUrl, + app.coverUrl, + app.thumbnailUrl, + app.logoUrl, + app.headerImageUrl, + app.icon, + app.images, + app.assets, + getSteamClientIconUrl(findSteamAppId(app), getInstalledAppSteamAppId(platform, sku)), + ), + platformLastPlayedTimestamp: + typeof app.platformLastPlayedTimestamp === 'number' + ? app.platformLastPlayedTimestamp + : null, + platformTotalPlaytimeMinutes: + typeof app.platformTotalPlaytimeMinutes === 'number' + ? app.platformTotalPlaytimeMinutes + : null, + }; } export function compareInstalledAppRecords(left, right) { - const lastPlayedDiff = - (right.platformLastPlayedTimestamp ?? 0) - - (left.platformLastPlayedTimestamp ?? 0) - if (lastPlayedDiff !== 0) { - return lastPlayedDiff - } + const lastPlayedDiff = + (right.platformLastPlayedTimestamp ?? 0) - (left.platformLastPlayedTimestamp ?? 0); + if (lastPlayedDiff !== 0) { + return lastPlayedDiff; + } - const playtimeDiff = - (right.platformTotalPlaytimeMinutes ?? 0) - - (left.platformTotalPlaytimeMinutes ?? 0) - if (playtimeDiff !== 0) { - return playtimeDiff - } + const playtimeDiff = + (right.platformTotalPlaytimeMinutes ?? 0) - (left.platformTotalPlaytimeMinutes ?? 0); + if (playtimeDiff !== 0) { + return playtimeDiff; + } - return compareByIdentity(left, right) + return compareByIdentity(left, right); } export function getInstalledVersionsForGame(gameId, data) { - const versions = Array.isArray(data?.installedGameVersions?.[gameId]) - ? data.installedGameVersions[gameId] - : [] - return Array.from( - new Set( - versions - .map((entry) => entry?.version) - .filter( - (entry) => typeof entry === "string" || typeof entry === "number" - ) - ) - ) + const versions = Array.isArray(data?.installedGameVersions?.[gameId]) + ? data.installedGameVersions[gameId] + : []; + return Array.from( + new Set( + versions + .map((entry) => entry?.version) + .filter((entry) => typeof entry === 'string' || typeof entry === 'number'), + ), + ); } function getStoreState(storeRef) { - const state = - typeof storeRef?.state?.getValue === "function" - ? storeRef.state.getValue() - : null - return isRecord(state) ? state : null + const state = + typeof storeRef?.state?.getValue === 'function' ? storeRef.state.getValue() : null; + return isRecord(state) ? state : null; } function getResolvedUnavailableTitles(state, liveTitles) { - const liveCount = isRecord(liveTitles) ? Object.keys(liveTitles).length : 0 - return liveCount > 0 ? liveTitles : state.unavailableTitlesById + const liveCount = isRecord(liveTitles) ? Object.keys(liveTitles).length : 0; + return liveCount > 0 ? liveTitles : state.unavailableTitlesById; } function getCorrelationIdsForUnavailableTitles(rawInstalledApps) { - return Object.entries(rawInstalledApps) - .filter( - ([, app]) => - isRecord(app) && - !EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS.has(safeString(app.platform)) - ) - .map(([correlationId]) => correlationId) - .sort() + return Object.entries(rawInstalledApps) + .filter( + ([, app]) => + isRecord(app) && + !EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS.has(safeString(app.platform)), + ) + .map(([correlationId]) => correlationId) + .sort(); } async function fetchUnavailableTitles(state, correlationIds) { - const nextTitlesById = {} + const nextTitlesById = {}; - try { - for ( - let index = 0; - index < correlationIds.length; - index += UNAVAILABLE_TITLES_BATCH_SIZE - ) { - const batch = correlationIds.slice( - index, - index + UNAVAILABLE_TITLES_BATCH_SIZE - ) - const response = - await state.unavailableTitlesService.getUnavailableTitlesByCorrelationIds( - batch - ) - for (const title of normalizeUnavailableTitlesResponse(response)) { - nextTitlesById[title.id] = title - } + try { + for (let index = 0; index < correlationIds.length; index += UNAVAILABLE_TITLES_BATCH_SIZE) { + const batch = correlationIds.slice(index, index + UNAVAILABLE_TITLES_BATCH_SIZE); + const response = + await state.unavailableTitlesService.getUnavailableTitlesByCorrelationIds(batch); + for (const title of normalizeUnavailableTitlesResponse(response)) { + nextTitlesById[title.id] = title; + } + } + + state.unavailableTitlesById = nextTitlesById; + state.log( + 'info', + 'Unavailable titles refreshed.', + `correlationIds=${correlationIds.length}, titles=${Object.keys(nextTitlesById).length}`, + ); + } catch (error) { + state.log('warn', 'Unavailable titles refresh failed.', formatError(error)); + } finally { + state.unavailableTitlesFetchPromise = null; } - - state.unavailableTitlesById = nextTitlesById - state.log( - "info", - "Unavailable titles refreshed.", - `correlationIds=${correlationIds.length}, titles=${Object.keys(nextTitlesById).length}` - ) - } catch (error) { - state.log( - "warn", - "Unavailable titles refresh failed.", - formatError(error) - ) - } finally { - state.unavailableTitlesFetchPromise = null - } } function normalizeUnavailableTitlesResponse(value) { - const titles = Array.isArray(value) - ? value - : Array.isArray(value?.data) - ? value.data - : [] - return titles.map(normalizeUnavailableTitle).filter(Boolean) + const titles = Array.isArray(value) ? value : Array.isArray(value?.data) ? value.data : []; + return titles.map(normalizeUnavailableTitle).filter(Boolean); } function normalizeUnavailableTitle(title) { - if (!isRecord(title)) { - return null - } + if (!isRecord(title)) { + return null; + } - const titleId = toStringId(title.id ?? title.titleId) - if (!titleId) { - return null - } + const titleId = toStringId(title.id ?? title.titleId); + if (!titleId) { + return null; + } - const games = Array.isArray(title.games) - ? title.games.map(normalizeUnavailableTitleGame).filter(Boolean) - : [] - if (games.length === 0) { - return null - } + const games = Array.isArray(title.games) + ? title.games.map(normalizeUnavailableTitleGame).filter(Boolean) + : []; + if (games.length === 0) { + return null; + } - return { - ...title, - id: titleId, - name: safeString(title.name, title.titleName, titleId), - games, - } + return { + ...title, + id: titleId, + name: safeString(title.name, title.titleName, titleId), + games, + }; } function normalizeUnavailableTitleGame(game) { - if (!isRecord(game)) { - return null - } + if (!isRecord(game)) { + return null; + } - const gameId = toStringId(game.id ?? game.gameId) - const correlationIds = normalizeStringList(game.correlationIds) - if (!gameId || correlationIds.length === 0) { - return null - } + const gameId = toStringId(game.id ?? game.gameId); + const correlationIds = normalizeStringList(game.correlationIds); + if (!gameId || correlationIds.length === 0) { + return null; + } - return { - ...game, - id: gameId, - platformId: safeString(game.platformId, "unknown"), - correlationIds, - flags: typeof game.flags === "number" ? game.flags : 0, - name: safeString(game.name, game.titleName, game.title, gameId), - } + return { + ...game, + id: gameId, + platformId: safeString(game.platformId, 'unknown'), + correlationIds, + flags: typeof game.flags === 'number' ? game.flags : 0, + name: safeString(game.name, game.titleName, game.title, gameId), + }; } function collectCorrelationIds(game, versions) { - const entries = [] + const entries = []; - if (Array.isArray(game?.correlationIds)) { - for (const correlationId of game.correlationIds) { - if (typeof correlationId === "string" && correlationId.trim()) { - entries.push({ correlationId: correlationId.trim(), version: null }) - } + if (Array.isArray(game?.correlationIds)) { + for (const correlationId of game.correlationIds) { + if (typeof correlationId === 'string' && correlationId.trim()) { + entries.push({ correlationId: correlationId.trim(), version: null }); + } + } } - } - if (Array.isArray(versions)) { - for (const version of versions) { - if ( - typeof version?.correlationId === "string" && - version.correlationId.trim() - ) { - entries.push({ - correlationId: version.correlationId.trim(), - version: version.version ?? null, - }) - } + if (Array.isArray(versions)) { + for (const version of versions) { + if (typeof version?.correlationId === 'string' && version.correlationId.trim()) { + entries.push({ + correlationId: version.correlationId.trim(), + version: version.version ?? null, + }); + } + } } - } - return entries + return entries; } export function rankInstalledAppCandidates(rawInstalledApps, game, versions) { - return Array.from( - new Map( - collectCorrelationIds(game, versions).map((entry) => [ - entry.correlationId, - entry, - ]) - ).values() - ) - .map((entry) => { - const app = rawInstalledApps?.[entry.correlationId] - const record = toInstalledAppRecord(entry.correlationId, app) - return record ? { app, version: entry.version ?? null, record } : null - }) - .filter(Boolean) - .sort((left, right) => compareInstalledAppRecords(left.record, right.record)) + return Array.from( + new Map( + collectCorrelationIds(game, versions).map((entry) => [entry.correlationId, entry]), + ).values(), + ) + .map((entry) => { + const app = rawInstalledApps?.[entry.correlationId]; + const record = toInstalledAppRecord(entry.correlationId, app); + return record ? { app, version: entry.version ?? null, record } : null; + }) + .filter(Boolean) + .sort((left, right) => compareInstalledAppRecords(left.record, right.record)); } function pickPreferredInstalledApp(rawInstalledApps, game, versions) { - return rankInstalledAppCandidates(rawInstalledApps, game, versions)[0]?.record || null + return rankInstalledAppCandidates(rawInstalledApps, game, versions)[0]?.record || null; } function upsertSnapshotEntry(entriesByKey, entry) { - const key = getSnapshotEntryKey(entry) - const current = entriesByKey.get(key) - if (!current || compareInstalledAppRecords(entry, current) < 0) { - entriesByKey.set(key, entry) - } + const key = getSnapshotEntryKey(entry); + const current = entriesByKey.get(key); + if (!current || compareInstalledAppRecords(entry, current) < 0) { + entriesByKey.set(key, entry); + } } function getSnapshotEntryKey(entry) { - if (entry.titleId) { - return `${SNAPSHOT_ENTRY_KEY_PREFIX.TITLE}${entry.titleId}` - } + if (entry.titleId) { + return `${SNAPSHOT_ENTRY_KEY_PREFIX.TITLE}${entry.titleId}`; + } - if (entry.gameId) { - return `${SNAPSHOT_ENTRY_KEY_PREFIX.GAME}${entry.gameId}` - } + if (entry.gameId) { + return `${SNAPSHOT_ENTRY_KEY_PREFIX.GAME}${entry.gameId}`; + } - return `${SNAPSHOT_ENTRY_KEY_PREFIX.APP}${entry.correlationId}` + return `${SNAPSHOT_ENTRY_KEY_PREFIX.APP}${entry.correlationId}`; } function compareByIdentity(left, right) { - const displayNameDiff = left.displayName.localeCompare(right.displayName) - if (displayNameDiff !== 0) { - return displayNameDiff - } + const displayNameDiff = left.displayName.localeCompare(right.displayName); + if (displayNameDiff !== 0) { + return displayNameDiff; + } - const platformDiff = left.platform.localeCompare(right.platform) - if (platformDiff !== 0) { - return platformDiff - } + const platformDiff = left.platform.localeCompare(right.platform); + if (platformDiff !== 0) { + return platformDiff; + } - return left.sku.localeCompare(right.sku) + return left.sku.localeCompare(right.sku); } function pickImageUrlForTitle(title, game, preferredApp, sidebarClientIconUrl, versions) { - const steamRoots = versions !== undefined - ? [title, game, versions, preferredApp] - : [title, game, preferredApp] + const steamRoots = + versions !== undefined + ? [title, game, versions, preferredApp] + : [title, game, preferredApp]; - return pickImageUrl( - title?.imageUrl, - title?.iconUrl, - title?.coverUrl, - title?.thumbnailUrl, - title?.logoUrl, - title?.headerImageUrl, - getSteamClientIconUrl( - findSteamAppId(...steamRoots), - getInstalledAppSteamAppId(preferredApp.platform, preferredApp.sku) - ), - sidebarClientIconUrl, - title?.images, - title?.assets, - game.imageUrl, - game.iconUrl, - game.coverUrl, - game.thumbnailUrl, - game.logoUrl, - game.headerImageUrl, - game.images, - game.assets, - preferredApp.imageUrl - ) + return pickImageUrl( + title?.imageUrl, + title?.iconUrl, + title?.coverUrl, + title?.thumbnailUrl, + title?.logoUrl, + title?.headerImageUrl, + getSteamClientIconUrl( + findSteamAppId(...steamRoots), + getInstalledAppSteamAppId(preferredApp.platform, preferredApp.sku), + ), + sidebarClientIconUrl, + title?.images, + title?.assets, + game.imageUrl, + game.iconUrl, + game.coverUrl, + game.thumbnailUrl, + game.logoUrl, + game.headerImageUrl, + game.images, + game.assets, + preferredApp.imageUrl, + ); } diff --git a/web-panel/bridge/scripts/default/installed-apps-sync/logger.js b/web-panel/bridge/scripts/default/installed-apps-sync/logger.js index 49cad57..9dd1bfd 100644 --- a/web-panel/bridge/scripts/default/installed-apps-sync/logger.js +++ b/web-panel/bridge/scripts/default/installed-apps-sync/logger.js @@ -1,47 +1,46 @@ -import { LOG_FILE_NAME, LOG_PREFIX } from "./constants.js" -import { getRequire } from "./runtime.js" +import { LOG_FILE_NAME, LOG_PREFIX } from './constants.js'; +import { getRequire } from './runtime.js'; // Logging runs inside Wand's own process; a failure here must never take the app down. export function createLogger(WandEnhancer) { - let filePath = null - - try { - const require = getRequire() - const os = require?.("node:os") - const path = require?.("node:path") - if (os && path) { - filePath = path.join(os.tmpdir(), LOG_FILE_NAME) - globalThis.__wandInstalledAppsSyncLogFile = filePath - } - } catch {} - - return function log(level, message, detail) { - const method = - level === "error" ? "error" : level === "warn" ? "warn" : "info" - const line = `[${new Date().toISOString()}] [${level}] ${message}${detail ? ` :: ${detail}` : ""}` + let filePath = null; try { - console[method](LOG_PREFIX, message, detail || "") + const require = getRequire(); + const os = require?.('node:os'); + const path = require?.('node:path'); + if (os && path) { + filePath = path.join(os.tmpdir(), LOG_FILE_NAME); + globalThis.__wandInstalledAppsSyncLogFile = filePath; + } } catch {} - try { - if (WandEnhancer?.log) { - WandEnhancer.log(`${LOG_PREFIX} ${message}`, detail || "") - } - } catch {} + return function log(level, message, detail) { + const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info'; + const line = `[${new Date().toISOString()}] [${level}] ${message}${detail ? ` :: ${detail}` : ''}`; - writeFile(filePath, line) - } + try { + console[method](LOG_PREFIX, message, detail || ''); + } catch {} + + try { + if (WandEnhancer?.log) { + WandEnhancer.log(`${LOG_PREFIX} ${message}`, detail || ''); + } + } catch {} + + writeFile(filePath, line); + }; } function writeFile(filePath, line) { - if (!filePath) { - return - } + if (!filePath) { + return; + } - try { - const require = getRequire() - const fs = require?.("node:fs") - fs?.appendFileSync(filePath, `${line}\n`) - } catch {} + try { + const require = getRequire(); + const fs = require?.('node:fs'); + fs?.appendFileSync(filePath, `${line}\n`); + } catch {} } diff --git a/web-panel/bridge/scripts/default/installed-apps-sync/remote-commands.js b/web-panel/bridge/scripts/default/installed-apps-sync/remote-commands.js index be7fa7a..0868b3c 100644 --- a/web-panel/bridge/scripts/default/installed-apps-sync/remote-commands.js +++ b/web-panel/bridge/scripts/default/installed-apps-sync/remote-commands.js @@ -1,260 +1,224 @@ import { - COMMAND_RESPONSE_CHANNEL, - REMOTE_COMMAND_LAUNCH, - REMOTE_COMMAND_STOP, - REMOTE_COMMAND_TRIGGER, - REMOTE_STOP_EVENT, -} from "./constants.js" -import { clearTrainerSnapshot, syncGameStatus } from "./game-status.js" + COMMAND_RESPONSE_CHANNEL, + REMOTE_COMMAND_LAUNCH, + REMOTE_COMMAND_STOP, + REMOTE_COMMAND_TRIGGER, + REMOTE_STOP_EVENT, +} from './constants.js'; +import { clearTrainerSnapshot, syncGameStatus } from './game-status.js'; import { - getInstalledVersionsForGame, - rankInstalledAppCandidates, - resolveInstalledData, -} from "./installed-data.js" + getInstalledVersionsForGame, + rankInstalledAppCandidates, + resolveInstalledData, +} from './installed-data.js'; import { - formatError, - getPreferredLocale, - invokeIpc, - isRecord, - safeString, - toStringId, -} from "./runtime.js" + formatError, + getPreferredLocale, + invokeIpc, + isRecord, + safeString, + toStringId, +} from './runtime.js'; export function handleRemoteCommandRequest(state, _event, request) { - void (async () => { - let response + void (async () => { + let response; - if (request?.action === REMOTE_COMMAND_LAUNCH) { - response = await executeRemoteLaunchCommand(state, request) - } else if (request?.action === REMOTE_COMMAND_STOP) { - response = await executeRemoteStopCommand(state, request) - } else { - response = buildCommandResponse(request, false, { - code: "invalid_command", - message: "Unknown remote command.", - }) - } + if (request?.action === REMOTE_COMMAND_LAUNCH) { + response = await executeRemoteLaunchCommand(state, request); + } else if (request?.action === REMOTE_COMMAND_STOP) { + response = await executeRemoteStopCommand(state, request); + } else { + response = buildCommandResponse(request, false, { + code: 'invalid_command', + message: 'Unknown remote command.', + }); + } - await sendRemoteCommandResponse(state, response) - })() + await sendRemoteCommandResponse(state, response); + })(); } function buildCommandResponse(request, ok, error = null) { - const response = { - requestId: safeString(request?.requestId), - ok, - action: - request?.action === REMOTE_COMMAND_STOP - ? REMOTE_COMMAND_STOP - : REMOTE_COMMAND_LAUNCH, - gameId: toStringId(request?.gameId), - titleId: toStringId(request?.titleId), - } + const response = { + requestId: safeString(request?.requestId), + ok, + action: + request?.action === REMOTE_COMMAND_STOP ? REMOTE_COMMAND_STOP : REMOTE_COMMAND_LAUNCH, + gameId: toStringId(request?.gameId), + titleId: toStringId(request?.titleId), + }; - if (!error) { - return response - } + if (!error) { + return response; + } - return { - ...response, - error, - } + return { + ...response, + error, + }; } async function executeRemoteLaunchCommand(state, request) { - const gameId = toStringId(request?.gameId) - if (!gameId) { - return buildCommandResponse(request, false, { - code: "invalid_game", - message: "A game id is required to launch a trainer.", - }) - } + const gameId = toStringId(request?.gameId); + if (!gameId) { + return buildCommandResponse(request, false, { + code: 'invalid_game', + message: 'A game id is required to launch a trainer.', + }); + } - if (!state.resolveRemoteCommandServices()) { - return buildCommandResponse(request, false, { - code: "bridge_not_ready", - message: "The Wand renderer container is not ready yet.", - }) - } + if (!state.resolveRemoteCommandServices()) { + return buildCommandResponse(request, false, { + code: 'bridge_not_ready', + message: 'The Wand renderer container is not ready yet.', + }); + } - if (!state.trainerService) { - return buildCommandResponse(request, false, { - code: "trainer_service_missing", - message: "The Wand trainer service is not available yet.", - }) - } + if (!state.trainerService) { + return buildCommandResponse(request, false, { + code: 'trainer_service_missing', + message: 'The Wand trainer service is not available yet.', + }); + } - if (!state.trainerLaunchRequestCtor) { - return buildCommandResponse(request, false, { - code: "trainer_launch_missing", - message: - "The Wand trainer launch request constructor is not available yet.", - }) - } + if (!state.trainerLaunchRequestCtor) { + return buildCommandResponse(request, false, { + code: 'trainer_launch_missing', + message: 'The Wand trainer launch request constructor is not available yet.', + }); + } - const data = resolveInstalledData(state) - if (!data) { - return buildCommandResponse(request, false, { - code: "installations_missing", - message: "Installed game data is not available yet.", - }) - } + const data = resolveInstalledData(state); + if (!data) { + return buildCommandResponse(request, false, { + code: 'installations_missing', + message: 'Installed game data is not available yet.', + }); + } - const launchInfo = getLaunchInfoForGame(gameId, data) - if (!isRecord(launchInfo.app)) { - return buildCommandResponse(request, false, { - code: "game_not_installed", - message: "Wand could not resolve a preferred installation for this game.", - }) - } + const launchInfo = getLaunchInfoForGame(gameId, data); + if (!isRecord(launchInfo.app)) { + return buildCommandResponse(request, false, { + code: 'game_not_installed', + message: 'Wand could not resolve a preferred installation for this game.', + }); + } - const trainerInfo = await resolveTrainerInfoForGame(state, gameId, data) - if (!trainerInfo) { - return buildCommandResponse(request, false, { - code: "trainer_not_found", - message: "Wand could not find a compatible trainer for this game.", - }) - } + const trainerInfo = await resolveTrainerInfoForGame(state, gameId, data); + if (!trainerInfo) { + return buildCommandResponse(request, false, { + code: 'trainer_not_found', + message: 'Wand could not find a compatible trainer for this game.', + }); + } - try { - const launchRequest = new state.trainerLaunchRequestCtor( - trainerInfo, - launchInfo.app, - launchInfo.version, - REMOTE_COMMAND_TRIGGER - ) - await state.trainerService.launch(launchRequest) - state.queueSync(true) - state.queueFollowUpSync() - void syncGameStatus(state, true) - return buildCommandResponse(request, true) - } catch (error) { - state.log( - "warn", - "Remote trainer launch failed.", - formatError(error) - ) - return buildCommandResponse(request, false, { - code: "launch_failed", - message: "Failed to launch the trainer.", - }) - } + try { + const launchRequest = new state.trainerLaunchRequestCtor( + trainerInfo, + launchInfo.app, + launchInfo.version, + REMOTE_COMMAND_TRIGGER, + ); + await state.trainerService.launch(launchRequest); + state.queueSync(true); + state.queueFollowUpSync(); + void syncGameStatus(state, true); + return buildCommandResponse(request, true); + } catch (error) { + state.log('warn', 'Remote trainer launch failed.', formatError(error)); + return buildCommandResponse(request, false, { + code: 'launch_failed', + message: 'Failed to launch the trainer.', + }); + } } async function executeRemoteStopCommand(state, request) { - if (!state.resolveRemoteCommandServices()) { - return buildCommandResponse(request, false, { - code: "bridge_not_ready", - message: "The Wand renderer container is not ready yet.", - }) - } + if (!state.resolveRemoteCommandServices()) { + return buildCommandResponse(request, false, { + code: 'bridge_not_ready', + message: 'The Wand renderer container is not ready yet.', + }); + } - if ( - !state.trainerService || - typeof state.trainerService.endTrainer !== "function" - ) { - return buildCommandResponse(request, false, { - code: "trainer_service_missing", - message: "The Wand trainer service is not available yet.", - }) - } + if (!state.trainerService || typeof state.trainerService.endTrainer !== 'function') { + return buildCommandResponse(request, false, { + code: 'trainer_service_missing', + message: 'The Wand trainer service is not available yet.', + }); + } - if ( - !state.trainerService.trainer && - state.currentRunningTrainer.state !== "running" - ) { - return buildCommandResponse(request, false, { - code: "no_active_trainer", - message: "No trainer is running right now.", - }) - } + if (!state.trainerService.trainer && state.currentRunningTrainer.state !== 'running') { + return buildCommandResponse(request, false, { + code: 'no_active_trainer', + message: 'No trainer is running right now.', + }); + } - try { - await state.trainerService.endTrainer() - clearTrainerSnapshot(state, REMOTE_STOP_EVENT, true) - return buildCommandResponse(request, true) - } catch (error) { - state.log( - "warn", - "Remote trainer stop failed.", - formatError(error) - ) - return buildCommandResponse(request, false, { - code: "stop_failed", - message: "Failed to stop the running trainer.", - }) - } + try { + await state.trainerService.endTrainer(); + clearTrainerSnapshot(state, REMOTE_STOP_EVENT, true); + return buildCommandResponse(request, true); + } catch (error) { + state.log('warn', 'Remote trainer stop failed.', formatError(error)); + return buildCommandResponse(request, false, { + code: 'stop_failed', + message: 'Failed to stop the running trainer.', + }); + } } function getLaunchInfoForGame(gameId, data) { - const versions = Array.isArray(data?.installedGameVersions?.[gameId]) - ? data.installedGameVersions[gameId] - : [] - const game = isRecord(data?.catalog?.games?.[gameId]) - ? data.catalog.games[gameId] - : null + const versions = Array.isArray(data?.installedGameVersions?.[gameId]) + ? data.installedGameVersions[gameId] + : []; + const game = isRecord(data?.catalog?.games?.[gameId]) ? data.catalog.games[gameId] : null; - const top = rankInstalledAppCandidates( - data?.rawInstalledApps ?? {}, - game, - versions - )[0] + const top = rankInstalledAppCandidates(data?.rawInstalledApps ?? {}, game, versions)[0]; - return top ? { app: top.app, version: top.version } : { app: null, version: null } + return top ? { app: top.app, version: top.version } : { app: null, version: null }; } async function resolveTrainerInfoForGame(state, gameId, data) { - if (!state.trainerApiService) { - return null - } - - try { - const localTrainer = unwrapTrainerInfo( - await state.trainerApiService.getLatestLocalTrainerForGame(gameId) - ) - if (localTrainer) { - return localTrainer + if (!state.trainerApiService) { + return null; } - } catch (error) { - state.log( - "warn", - "Local trainer lookup failed.", - formatError(error) - ) - } - try { - return unwrapTrainerInfo( - await state.trainerApiService.getMostCompatibleTrainerForGame( - gameId, - getPreferredLocale(), - getInstalledVersionsForGame(gameId, data), - false - ) - ) - } catch (error) { - state.log( - "warn", - "Compatible trainer lookup failed.", - formatError(error) - ) - return null - } + try { + const localTrainer = unwrapTrainerInfo( + await state.trainerApiService.getLatestLocalTrainerForGame(gameId), + ); + if (localTrainer) { + return localTrainer; + } + } catch (error) { + state.log('warn', 'Local trainer lookup failed.', formatError(error)); + } + + try { + return unwrapTrainerInfo( + await state.trainerApiService.getMostCompatibleTrainerForGame( + gameId, + getPreferredLocale(), + getInstalledVersionsForGame(gameId, data), + false, + ), + ); + } catch (error) { + state.log('warn', 'Compatible trainer lookup failed.', formatError(error)); + return null; + } } function unwrapTrainerInfo(value) { - if (isRecord(value?.trainer)) { - return value.trainer - } + if (isRecord(value?.trainer)) { + return value.trainer; + } - return isRecord(value) ? value : null + return isRecord(value) ? value : null; } async function sendRemoteCommandResponse(state, response) { - await invokeIpc( - state, - COMMAND_RESPONSE_CHANNEL, - response, - "Remote command response" - ) + await invokeIpc(state, COMMAND_RESPONSE_CHANNEL, response, 'Remote command response'); } diff --git a/web-panel/bridge/scripts/default/installed-apps-sync/runtime.js b/web-panel/bridge/scripts/default/installed-apps-sync/runtime.js index 89040fb..65bf3e3 100644 --- a/web-panel/bridge/scripts/default/installed-apps-sync/runtime.js +++ b/web-panel/bridge/scripts/default/installed-apps-sync/runtime.js @@ -1,396 +1,390 @@ -import { CONTAINER_GRAPH_MAX_DEPTH } from "./constants.js" +import { CONTAINER_GRAPH_MAX_DEPTH } from './constants.js'; export function isRecord(value) { - return typeof value === "object" && value !== null + return typeof value === 'object' && value !== null; } export function formatError(error) { - return error?.stack || String(error) + return error?.stack || String(error); } -export async function invokeIpc(state, channel, payload, label, level = "warn") { - if (!state.ipcRenderer) { - return false - } +export async function invokeIpc(state, channel, payload, label, level = 'warn') { + if (!state.ipcRenderer) { + return false; + } - try { - await state.ipcRenderer.invoke(channel, payload) - return true - } catch (error) { - state.log(level, `${label} IPC failed.`, formatError(error)) - return false - } + try { + await state.ipcRenderer.invoke(channel, payload); + return true; + } catch (error) { + state.log(level, `${label} IPC failed.`, formatError(error)); + return false; + } } export function isDiagnosticsDebugEnabled() { - return globalThis.__wandInstalledAppsSyncDebug === true + return globalThis.__wandInstalledAppsSyncDebug === true; } -let lastPredicateErrorLogAt = 0 +let lastPredicateErrorLogAt = 0; function logContainerGraphPredicateError(error) { - if (!isDiagnosticsDebugEnabled()) { - return - } + if (!isDiagnosticsDebugEnabled()) { + return; + } - const now = Date.now() - if (now - lastPredicateErrorLogAt < 1000) { - return - } + const now = Date.now(); + if (now - lastPredicateErrorLogAt < 1000) { + return; + } - lastPredicateErrorLogAt = now - console.debug( - "[wand-installed-apps-sync] container graph predicate threw", - formatError(error) - ) + lastPredicateErrorLogAt = now; + console.debug('[wand-installed-apps-sync] container graph predicate threw', formatError(error)); } export function getRequire() { - return ( - globalThis.require || - (typeof window !== "undefined" ? window.require : null) - ) + return globalThis.require || (typeof window !== 'undefined' ? window.require : null); } export function safeString(...values) { - for (const value of values) { - if (typeof value === "string" && value.trim()) { - return value.trim() + for (const value of values) { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } } - } - return "" + return ''; } export function getWebpackRequire() { - const chunk = globalThis.webpackChunkWeMod - if (!Array.isArray(chunk)) { - return null - } + const chunk = globalThis.webpackChunkWeMod; + if (!Array.isArray(chunk)) { + return null; + } - if (typeof chunk.__wandWebpackRequire === "function") { - return chunk.__wandWebpackRequire - } + if (typeof chunk.__wandWebpackRequire === 'function') { + return chunk.__wandWebpackRequire; + } - let resolvedRequire = null - chunk.push([ - [`wand-enhancer-${Date.now()}`], - {}, - (webpackRequire) => { - resolvedRequire = webpackRequire - }, - ]) + let resolvedRequire = null; + chunk.push([ + [`wand-enhancer-${Date.now()}`], + {}, + (webpackRequire) => { + resolvedRequire = webpackRequire; + }, + ]); - if (typeof resolvedRequire === "function") { - chunk.__wandWebpackRequire = resolvedRequire - } + if (typeof resolvedRequire === 'function') { + chunk.__wandWebpackRequire = resolvedRequire; + } - return resolvedRequire + return resolvedRequire; } export function getAppRoot() { - return ( - document.getElementById("root") || - document.querySelector("[aurelia-app]") || - document.querySelector("root") - ) + return ( + document.getElementById('root') || + document.querySelector('[aurelia-app]') || + document.querySelector('root') + ); } export function hasAppRoot() { - return Boolean(getAppRoot()) + return Boolean(getAppRoot()); } -let cachedAureliaContainer = null +let cachedAureliaContainer = null; function isUsableContainer(container) { - return isRecord(container) && typeof container.get === "function" + return isRecord(container) && typeof container.get === 'function'; } export function getAureliaContainer() { - if (isUsableContainer(cachedAureliaContainer)) { - return cachedAureliaContainer - } + if (isUsableContainer(cachedAureliaContainer)) { + return cachedAureliaContainer; + } - const resolved = resolveAureliaContainer() - if (resolved) { - cachedAureliaContainer = resolved - } + const resolved = resolveAureliaContainer(); + if (resolved) { + cachedAureliaContainer = resolved; + } - return resolved + return resolved; } function resolveAureliaContainer() { - const root = getAppRoot() - const rootContainer = getContainerFromSubtree(root) - if (rootContainer) { - return rootContainer - } + const root = getAppRoot(); + const rootContainer = getContainerFromSubtree(root); + if (rootContainer) { + return rootContainer; + } - const bodyContainer = getContainerFromElement(document.body) - if (bodyContainer) { - return bodyContainer - } + const bodyContainer = getContainerFromElement(document.body); + if (bodyContainer) { + return bodyContainer; + } - if (isRecord(globalThis.aurelia) && globalThis.aurelia.container) { - return globalThis.aurelia.container - } + if (isRecord(globalThis.aurelia) && globalThis.aurelia.container) { + return globalThis.aurelia.container; + } - return null + return null; } export function summarizeAureliaSubtree(root) { - if (!isDiagnosticsDebugEnabled()) { - return "(debug-disabled)" - } - - if (!root) { - return "root=null" - } - - let elementsWithAu = 0 - let controllerEntries = 0 - let namedAuEntries = 0 - - function inspectElement(element) { - if (!isRecord(element?.au)) { - return + if (!isDiagnosticsDebugEnabled()) { + return '(debug-disabled)'; } - elementsWithAu += 1 - - if (element.au.controller) { - controllerEntries += 1 + if (!root) { + return 'root=null'; } - for (const [key, value] of Object.entries(element.au)) { - if (key !== "controller" && isRecord(value)) { - namedAuEntries += 1 - } + let elementsWithAu = 0; + let controllerEntries = 0; + let namedAuEntries = 0; + + function inspectElement(element) { + if (!isRecord(element?.au)) { + return; + } + + elementsWithAu += 1; + + if (element.au.controller) { + controllerEntries += 1; + } + + for (const [key, value] of Object.entries(element.au)) { + if (key !== 'controller' && isRecord(value)) { + namedAuEntries += 1; + } + } } - } - inspectElement(root) + inspectElement(root); - const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT) - let element = walker.nextNode() - while (element) { - inspectElement(element) - element = walker.nextNode() - } + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let element = walker.nextNode(); + while (element) { + inspectElement(element); + element = walker.nextNode(); + } - return `elementsWithAu=${elementsWithAu}, controllerEntries=${controllerEntries}, namedAuEntries=${namedAuEntries}` + return `elementsWithAu=${elementsWithAu}, controllerEntries=${controllerEntries}, namedAuEntries=${namedAuEntries}`; } export function findExportedConstructor(webpackRequire, predicate) { - const cache = webpackRequire?.c - if (!cache || typeof cache !== "object") { - return null - } + const cache = webpackRequire?.c; + if (!cache || typeof cache !== 'object') { + return null; + } - for (const record of Object.values(cache)) { - const exports = record?.exports - const candidates = [] + for (const record of Object.values(cache)) { + const exports = record?.exports; + const candidates = []; - if (typeof exports === "function") { - candidates.push(exports) - } else if (isRecord(exports)) { - if (typeof exports.default === "function") { - candidates.push(exports.default) - } + if (typeof exports === 'function') { + candidates.push(exports); + } else if (isRecord(exports)) { + if (typeof exports.default === 'function') { + candidates.push(exports.default); + } - for (const value of Object.values(exports)) { - if (typeof value === "function") { - candidates.push(value) + for (const value of Object.values(exports)) { + if (typeof value === 'function') { + candidates.push(value); + } + } + } + + for (const candidate of candidates) { + if (candidate?.prototype && predicate(candidate.prototype)) { + return candidate; + } } - } } - for (const candidate of candidates) { - if (candidate?.prototype && predicate(candidate.prototype)) { - return candidate - } - } - } - - return null + return null; } export function findInstanceInContainerGraph( - root, - predicate, - maxDepth = CONTAINER_GRAPH_MAX_DEPTH + root, + predicate, + maxDepth = CONTAINER_GRAPH_MAX_DEPTH, ) { - if (!root) { - return null - } - - const seen = new Set() - const queue = [{ value: root, depth: 0 }] - - while (queue.length > 0) { - const current = queue.shift() - const value = current?.value - const depth = current?.depth ?? 0 - if (!value || seen.has(value)) { - continue + if (!root) { + return null; } - seen.add(value) + const seen = new Set(); + const queue = [{ value: root, depth: 0 }]; - try { - if (predicate(value)) { - return value - } - } catch (error) { - logContainerGraphPredicateError(error) + while (queue.length > 0) { + const current = queue.shift(); + const value = current?.value; + const depth = current?.depth ?? 0; + if (!value || seen.has(value)) { + continue; + } + + seen.add(value); + + try { + if (predicate(value)) { + return value; + } + } catch (error) { + logContainerGraphPredicateError(error); + } + + if (depth >= maxDepth) { + continue; + } + + enqueueNestedValues(queue, seen, value, depth + 1); } - if (depth >= maxDepth) { - continue - } - - enqueueNestedValues(queue, seen, value, depth + 1) - } - - return null + return null; } export function getBasename(location) { - if (typeof location !== "string" || !location.trim()) { - return "" - } + if (typeof location !== 'string' || !location.trim()) { + return ''; + } - const normalized = location.replace(/\\/g, "/").replace(/\/+$/, "") - const leaf = normalized.split("/").filter(Boolean).pop() - return leaf ? leaf.trim() : "" + const normalized = location.replace(/\\/g, '/').replace(/\/+$/, ''); + const leaf = normalized.split('/').filter(Boolean).pop(); + return leaf ? leaf.trim() : ''; } export function toStringId(value) { - if (typeof value === "string" && value.trim()) { - return value.trim() - } + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } - if (typeof value === "number" && Number.isFinite(value)) { - return String(value) - } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } - return null + return null; } export function normalizeStringList(value) { - if (!Array.isArray(value)) { - return [] - } + if (!Array.isArray(value)) { + return []; + } - return value - .filter((entry) => typeof entry === "string" && entry.trim()) - .map((entry) => entry.trim()) + return value + .filter((entry) => typeof entry === 'string' && entry.trim()) + .map((entry) => entry.trim()); } export function getPreferredLocale() { - return safeString( - document.documentElement?.lang, - Array.isArray(globalThis.navigator?.languages) - ? globalThis.navigator.languages.find( - (entry) => typeof entry === "string" && entry.trim() - ) - : "", - globalThis.navigator?.language, - "en-US" - ) + return safeString( + document.documentElement?.lang, + Array.isArray(globalThis.navigator?.languages) + ? globalThis.navigator.languages.find( + (entry) => typeof entry === 'string' && entry.trim(), + ) + : '', + globalThis.navigator?.language, + 'en-US', + ); } function getContainerFromAu(au) { - if (!isRecord(au)) { - return null - } - - if (au.container) { - return au.container - } - - const directControllerContainer = - au.controller?.container || au.controller?.viewModel?.container - if (directControllerContainer) { - return directControllerContainer - } - - for (const value of Object.values(au)) { - if (!isRecord(value)) { - continue + if (!isRecord(au)) { + return null; } - const container = - value.container || - value.controller?.container || - value.viewModel?.container || - value.controller?.viewModel?.container - if (container) { - return container + if (au.container) { + return au.container; } - } - return null + const directControllerContainer = + au.controller?.container || au.controller?.viewModel?.container; + if (directControllerContainer) { + return directControllerContainer; + } + + for (const value of Object.values(au)) { + if (!isRecord(value)) { + continue; + } + + const container = + value.container || + value.controller?.container || + value.viewModel?.container || + value.controller?.viewModel?.container; + if (container) { + return container; + } + } + + return null; } function getContainerFromElement(element) { - if (!element) { - return null - } + if (!element) { + return null; + } - if (element.__aurelia__?.container) { - return element.__aurelia__.container - } + if (element.__aurelia__?.container) { + return element.__aurelia__.container; + } - return getContainerFromAu(element.au) + return getContainerFromAu(element.au); } function getContainerFromSubtree(root) { - if (!root) { - return null - } - - const rootContainer = getContainerFromElement(root) - if (rootContainer) { - return rootContainer - } - - const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT) - let element = walker.nextNode() - while (element) { - const container = getContainerFromElement(element) - if (container) { - return container + if (!root) { + return null; } - element = walker.nextNode() - } + const rootContainer = getContainerFromElement(root); + if (rootContainer) { + return rootContainer; + } - return null + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let element = walker.nextNode(); + while (element) { + const container = getContainerFromElement(element); + if (container) { + return container; + } + + element = walker.nextNode(); + } + + return null; } function enqueueNestedValues(queue, seen, value, depth) { - if (value instanceof Map) { - enqueueIterable(queue, seen, value.values(), depth) - return - } + if (value instanceof Map) { + enqueueIterable(queue, seen, value.values(), depth); + return; + } - if (value instanceof Set || Array.isArray(value)) { - enqueueIterable(queue, seen, value.values(), depth) - return - } + if (value instanceof Set || Array.isArray(value)) { + enqueueIterable(queue, seen, value.values(), depth); + return; + } - if (!isRecord(value) && typeof value !== "function") { - return - } + if (!isRecord(value) && typeof value !== 'function') { + return; + } - enqueueIterable(queue, seen, Object.values(value), depth) + enqueueIterable(queue, seen, Object.values(value), depth); } function enqueueIterable(queue, seen, values, depth) { - for (const entry of values) { - if ((isRecord(entry) || typeof entry === "function") && !seen.has(entry)) { - queue.push({ value: entry, depth }) + for (const entry of values) { + if ((isRecord(entry) || typeof entry === 'function') && !seen.has(entry)) { + queue.push({ value: entry, depth }); + } } - } } diff --git a/web-panel/bridge/scripts/default/installed-apps-sync/services.js b/web-panel/bridge/scripts/default/installed-apps-sync/services.js index e67766a..d4ab53d 100644 --- a/web-panel/bridge/scripts/default/installed-apps-sync/services.js +++ b/web-panel/bridge/scripts/default/installed-apps-sync/services.js @@ -1,264 +1,256 @@ -import { TRAINER_LAUNCH_REQUEST_EXPORT_KEY } from "./constants.js" +import { TRAINER_LAUNCH_REQUEST_EXPORT_KEY } from './constants.js'; import { - findExportedConstructor, - findInstanceInContainerGraph, - formatError, - isRecord, -} from "./runtime.js" + findExportedConstructor, + findInstanceInContainerGraph, + formatError, + isRecord, +} from './runtime.js'; export function hasMissingOptionalServices(state) { - return ( - !state.gameLifecycleService || - !state.trainerVisibilityService || - !state.unavailableTitlesService - ) + return ( + !state.gameLifecycleService || + !state.trainerVisibilityService || + !state.unavailableTitlesService + ); } export function hasUnresolvedServices(state) { - return ( - hasMissingOptionalServices(state) || - !state.trainerApiService || - !state.trainerService || - !state.trainerLaunchRequestCtor - ) + return ( + hasMissingOptionalServices(state) || + !state.trainerApiService || + !state.trainerService || + !state.trainerLaunchRequestCtor + ); } const OPTIONAL_SERVICE_SPECS = [ - { - stateKey: "unavailableTitlesService", - methods: ["getUnavailableTitle", "searchUnavailableTitles", "getUnavailableTitlesByCorrelationIds"], - label: "Unavailable titles service", - }, - { - stateKey: "gameLifecycleService", - methods: ["onGameLaunched", "onGameEnded", "launch"], - label: "Game lifecycle service", - }, - { - stateKey: "trainerVisibilityService", - methods: ["onDisplayTrainerChanged", "onVisibleTrainerChanged", "onRunningTrainerChanged"], - label: "Trainer visibility service", - }, - { - stateKey: "trainerApiService", - methods: ["getLatestLocalTrainerForGame", "getMostCompatibleTrainerForGame", "getTrainerById"], - label: "Trainer API service", - }, - { - stateKey: "trainerService", - methods: ["launch", "endTrainer", "onNewTrainer", "onTrainerEnded"], - label: "Trainer service", - }, -] + { + stateKey: 'unavailableTitlesService', + methods: [ + 'getUnavailableTitle', + 'searchUnavailableTitles', + 'getUnavailableTitlesByCorrelationIds', + ], + label: 'Unavailable titles service', + }, + { + stateKey: 'gameLifecycleService', + methods: ['onGameLaunched', 'onGameEnded', 'launch'], + label: 'Game lifecycle service', + }, + { + stateKey: 'trainerVisibilityService', + methods: ['onDisplayTrainerChanged', 'onVisibleTrainerChanged', 'onRunningTrainerChanged'], + label: 'Trainer visibility service', + }, + { + stateKey: 'trainerApiService', + methods: [ + 'getLatestLocalTrainerForGame', + 'getMostCompatibleTrainerForGame', + 'getTrainerById', + ], + label: 'Trainer API service', + }, + { + stateKey: 'trainerService', + methods: ['launch', 'endTrainer', 'onNewTrainer', 'onTrainerEnded'], + label: 'Trainer service', + }, +]; export function resolveOptionalServices(state, container, webpackRequire) { - for (const spec of OPTIONAL_SERVICE_SPECS) { - if (!state[spec.stateKey]) { - state[spec.stateKey] = resolveOptionalService(state, container, webpackRequire, spec) + for (const spec of OPTIONAL_SERVICE_SPECS) { + if (!state[spec.stateKey]) { + state[spec.stateKey] = resolveOptionalService(state, container, webpackRequire, spec); + } } - } - if (!state.trainerLaunchRequestCtor) { - state.trainerLaunchRequestCtor = getTrainerLaunchRequestCtor(state, webpackRequire) - } + if (!state.trainerLaunchRequestCtor) { + state.trainerLaunchRequestCtor = getTrainerLaunchRequestCtor(state, webpackRequire); + } } export function getInstalledAppsService(state, container, webpackRequire) { - const ctor = findExportedConstructor(webpackRequire, (prototype) => { - return ( - typeof prototype.refreshApps === "function" && - typeof prototype.watchGame === "function" - ) - }) + const ctor = findExportedConstructor(webpackRequire, (prototype) => { + return ( + typeof prototype.refreshApps === 'function' && typeof prototype.watchGame === 'function' + ); + }); - if (!ctor) { - state.log( - "warn", - "Installed apps service constructor not found in webpack cache." - ) - return null - } + if (!ctor) { + state.log('warn', 'Installed apps service constructor not found in webpack cache.'); + return null; + } - try { - const service = container.get(ctor) - const appsCount = isRecord(service.installedApps) - ? Object.keys(service.installedApps).length - : -1 - const catalogGamesCount = isRecord(service.catalog?.games) - ? Object.keys(service.catalog.games).length - : -1 - state.log( - "info", - "Installed apps service resolved.", - `ctor=${ctor.name || ""}, installedApps=${appsCount}, catalogGames=${catalogGamesCount}` - ) - return service - } catch (error) { - state.log( - "warn", - "Failed to resolve installed apps service from Aurelia container.", - formatError(error) - ) - return null - } + try { + const service = container.get(ctor); + const appsCount = isRecord(service.installedApps) + ? Object.keys(service.installedApps).length + : -1; + const catalogGamesCount = isRecord(service.catalog?.games) + ? Object.keys(service.catalog.games).length + : -1; + state.log( + 'info', + 'Installed apps service resolved.', + `ctor=${ctor.name || ''}, installedApps=${appsCount}, catalogGames=${catalogGamesCount}`, + ); + return service; + } catch (error) { + state.log( + 'warn', + 'Failed to resolve installed apps service from Aurelia container.', + formatError(error), + ); + return null; + } } export function getStoreRef(state, container, webpackRequire) { - const storeCtor = findExportedConstructor(webpackRequire, (prototype) => { - return ( - typeof prototype.dispatch === "function" && - typeof prototype.registerAction === "function" && - typeof prototype.unregisterAction === "function" - ) - }) + const storeCtor = findExportedConstructor(webpackRequire, (prototype) => { + return ( + typeof prototype.dispatch === 'function' && + typeof prototype.registerAction === 'function' && + typeof prototype.unregisterAction === 'function' + ); + }); - if (!storeCtor) { - state.log("warn", "Store constructor not found in webpack cache.") - return null - } + if (!storeCtor) { + state.log('warn', 'Store constructor not found in webpack cache.'); + return null; + } - try { - const store = container.get(storeCtor) - const storeState = - typeof store.state?.getValue === "function" - ? store.state.getValue() - : null - const installedAppsCount = isRecord(storeState?.installedApps) - ? Object.keys(storeState.installedApps).length - : -1 - const catalogGamesCount = isRecord(storeState?.catalog?.games) - ? Object.keys(storeState.catalog.games).length - : -1 - state.log( - "info", - "Store resolved via fallback.", - `ctor=${storeCtor.name || ""}, installedApps=${installedAppsCount}, catalogGames=${catalogGamesCount}` - ) - return store - } catch (error) { - state.log( - "warn", - "Failed to resolve Store from container.", - formatError(error) - ) - return null - } + try { + const store = container.get(storeCtor); + const storeState = + typeof store.state?.getValue === 'function' ? store.state.getValue() : null; + const installedAppsCount = isRecord(storeState?.installedApps) + ? Object.keys(storeState.installedApps).length + : -1; + const catalogGamesCount = isRecord(storeState?.catalog?.games) + ? Object.keys(storeState.catalog.games).length + : -1; + state.log( + 'info', + 'Store resolved via fallback.', + `ctor=${storeCtor.name || ''}, installedApps=${installedAppsCount}, catalogGames=${catalogGamesCount}`, + ); + return store; + } catch (error) { + state.log('warn', 'Failed to resolve Store from container.', formatError(error)); + return null; + } } function resolveOptionalService(state, container, webpackRequire, spec) { - const matchesMethods = (target) => hasAllMethods(target, spec.methods) - const ctor = findExportedConstructor(webpackRequire, matchesMethods) - if (ctor) { - return getContainerService(state, container, ctor, spec.stateKey, spec.label) - } + const matchesMethods = (target) => hasAllMethods(target, spec.methods); + const ctor = findExportedConstructor(webpackRequire, matchesMethods); + if (ctor) { + return getContainerService(state, container, ctor, spec.stateKey, spec.label); + } - return findFallbackService( - state, - container, - spec.stateKey, - `${spec.label} constructor not found in webpack cache.`, - matchesMethods - ) + return findFallbackService( + state, + container, + spec.stateKey, + `${spec.label} constructor not found in webpack cache.`, + matchesMethods, + ); } function hasAllMethods(target, methods) { - if (!target) { - return false - } - - for (const method of methods) { - if (typeof target[method] !== "function") { - return false + if (!target) { + return false; } - } - return true + for (const method of methods) { + if (typeof target[method] !== 'function') { + return false; + } + } + + return true; } function getTrainerLaunchRequestCtor(state, webpackRequire) { - const cache = webpackRequire?.c - if (!cache || typeof cache !== "object") { + const cache = webpackRequire?.c; + if (!cache || typeof cache !== 'object') { + warnMissingOptionalService( + state, + 'trainerLaunchRequestCtor', + 'Trainer launch request constructor cache is unavailable.', + ); + return null; + } + + for (const record of Object.values(cache)) { + const exports = record?.exports; + if (!isRecord(exports)) { + continue; + } + + const candidate = exports[TRAINER_LAUNCH_REQUEST_EXPORT_KEY]; + if ( + typeof candidate === 'function' && + typeof exports.ZS === 'function' && + typeof exports.jR === 'function' && + typeof exports.UY === 'function' + ) { + clearMissingOptionalServiceWarning(state, 'trainerLaunchRequestCtor'); + return candidate; + } + } + warnMissingOptionalService( - state, - "trainerLaunchRequestCtor", - "Trainer launch request constructor cache is unavailable." - ) - return null - } - - for (const record of Object.values(cache)) { - const exports = record?.exports - if (!isRecord(exports)) { - continue - } - - const candidate = exports[TRAINER_LAUNCH_REQUEST_EXPORT_KEY] - if ( - typeof candidate === "function" && - typeof exports.ZS === "function" && - typeof exports.jR === "function" && - typeof exports.UY === "function" - ) { - clearMissingOptionalServiceWarning(state, "trainerLaunchRequestCtor") - return candidate - } - } - - warnMissingOptionalService( - state, - "trainerLaunchRequestCtor", - "Trainer launch request constructor not found in webpack cache." - ) - return null + state, + 'trainerLaunchRequestCtor', + 'Trainer launch request constructor not found in webpack cache.', + ); + return null; } function getContainerService(state, container, ctor, warningKey, label) { - try { - const service = container.get(ctor) - clearMissingOptionalServiceWarning(state, warningKey) - state.log( - "info", - `${label} resolved.`, - `ctor=${ctor.name || ""}${service?.runningTrainer ? ", running=yes" : ""}` - ) - return service - } catch (error) { - state.log( - "warn", - `Failed to resolve ${label.toLowerCase()} from Aurelia container.`, - formatError(error) - ) - return null - } + try { + const service = container.get(ctor); + clearMissingOptionalServiceWarning(state, warningKey); + state.log( + 'info', + `${label} resolved.`, + `ctor=${ctor.name || ''}${service?.runningTrainer ? ', running=yes' : ''}`, + ); + return service; + } catch (error) { + state.log( + 'warn', + `Failed to resolve ${label.toLowerCase()} from Aurelia container.`, + formatError(error), + ); + return null; + } } -function findFallbackService( - state, - container, - warningKey, - missingMessage, - predicate -) { - const fallbackService = findInstanceInContainerGraph(container, predicate) - if (fallbackService) { - clearMissingOptionalServiceWarning(state, warningKey) - state.log("info", `${warningKey} resolved from container graph.`) - return fallbackService - } +function findFallbackService(state, container, warningKey, missingMessage, predicate) { + const fallbackService = findInstanceInContainerGraph(container, predicate); + if (fallbackService) { + clearMissingOptionalServiceWarning(state, warningKey); + state.log('info', `${warningKey} resolved from container graph.`); + return fallbackService; + } - warnMissingOptionalService(state, warningKey, missingMessage) - return null + warnMissingOptionalService(state, warningKey, missingMessage); + return null; } function warnMissingOptionalService(state, key, message) { - if (state.missingOptionalServiceWarnings.has(key)) { - return - } + if (state.missingOptionalServiceWarnings.has(key)) { + return; + } - state.missingOptionalServiceWarnings.add(key) - state.log("warn", message) + state.missingOptionalServiceWarnings.add(key); + state.log('warn', message); } function clearMissingOptionalServiceWarning(state, key) { - state.missingOptionalServiceWarnings.delete(key) + state.missingOptionalServiceWarnings.delete(key); } diff --git a/web-panel/bridge/scripts/default/remote-popup-cleanup.js b/web-panel/bridge/scripts/default/remote-popup-cleanup.js index 750e848..197e86a 100644 --- a/web-panel/bridge/scripts/default/remote-popup-cleanup.js +++ b/web-panel/bridge/scripts/default/remote-popup-cleanup.js @@ -1,18 +1,16 @@ -import { - getWebpackRequire, -} from "./installed-apps-sync/runtime.js" -import { resolveQrRenderer as findWandQrRenderer } from "./remote-popup-cleanup/qr-renderer.js" +import { getWebpackRequire } from './installed-apps-sync/runtime.js'; +import { resolveQrRenderer as findWandQrRenderer } from './remote-popup-cleanup/qr-renderer.js'; -;(function installRemotePopupCleanup(WandEnhancer) { - if (globalThis.__wandRemotePopupCleanupInstalled) { - return - } +(function installRemotePopupCleanup(WandEnhancer) { + if (globalThis.__wandRemotePopupCleanupInstalled) { + return; + } - globalThis.__wandRemotePopupCleanupInstalled = true + globalThis.__wandRemotePopupCleanupInstalled = true; - const style = document.createElement("style") - style.id = "wand-remote-popup-cleanup-style" - style.textContent = ` + const style = document.createElement('style'); + style.id = 'wand-remote-popup-cleanup-style'; + style.textContent = ` article.pro-onboarding-card--remote { display: none !important; } @@ -73,84 +71,83 @@ import { resolveQrRenderer as findWandQrRenderer } from "./remote-popup-cleanup/ border-radius: 12px !important; transform: none !important; } - ` - let qrRenderer = null - let refreshScheduled = false + `; + let qrRenderer = null; + let refreshScheduled = false; - const installStyle = () => { - if (!document.getElementById(style.id)) { - document.head.appendChild(style) - } - } + const installStyle = () => { + if (!document.getElementById(style.id)) { + document.head.appendChild(style); + } + }; - const getRemoteUrl = () => - globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl + const getRemoteUrl = () => globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl; - const resolveQrRenderer = () => { - if (qrRenderer) { - return qrRenderer - } + const resolveQrRenderer = () => { + if (qrRenderer) { + return qrRenderer; + } - qrRenderer = findWandQrRenderer(getWebpackRequire()) - return qrRenderer - } + qrRenderer = findWandQrRenderer(getWebpackRequire()); + return qrRenderer; + }; - const updateLinks = (remoteUrl) => { - if (!remoteUrl) { - return - } + const updateLinks = (remoteUrl) => { + if (!remoteUrl) { + return; + } - for (const anchor of document.querySelectorAll("remote-tooltip a[href]")) { - anchor.setAttribute("href", remoteUrl) - anchor.textContent = remoteUrl.replace(/\/$/, "") - } - } + for (const anchor of document.querySelectorAll('remote-tooltip a[href]')) { + anchor.setAttribute('href', remoteUrl); + anchor.textContent = remoteUrl.replace(/\/$/, ''); + } + }; - const updateQrCodes = async (remoteUrl) => { - const renderQr = remoteUrl && resolveQrRenderer() - if (!renderQr) { - return - } + const updateQrCodes = async (remoteUrl) => { + const renderQr = remoteUrl && resolveQrRenderer(); + if (!renderQr) { + return; + } - for (const canvas of document.querySelectorAll("remote-qr-code canvas")) { - if (canvas.dataset.wandRemoteUrl === remoteUrl) { - continue - } + for (const canvas of document.querySelectorAll('remote-qr-code canvas')) { + if (canvas.dataset.wandRemoteUrl === remoteUrl) { + continue; + } - try { - await renderQr(canvas, remoteUrl) - canvas.dataset.wandRemoteUrl = remoteUrl - } catch (error) { - WandEnhancer?.log("Failed to render local remote QR code", error) - } - } - } + try { + await renderQr(canvas, remoteUrl); + canvas.dataset.wandRemoteUrl = remoteUrl; + } catch (error) { + WandEnhancer?.log('Failed to render local remote QR code', error); + } + } + }; - const refresh = () => { - const remoteUrl = getRemoteUrl() - installStyle() - updateLinks(remoteUrl) - void updateQrCodes(remoteUrl) - } + const refresh = () => { + const remoteUrl = getRemoteUrl(); + installStyle(); + updateLinks(remoteUrl); + void updateQrCodes(remoteUrl); + }; - const scheduleRefresh = () => { - if (refreshScheduled) { - return - } + const scheduleRefresh = () => { + if (refreshScheduled) { + return; + } - refreshScheduled = true - setTimeout(() => { - refreshScheduled = false - refresh() - }, 0) - } + refreshScheduled = true; + setTimeout(() => { + refreshScheduled = false; + refresh(); + }, 0); + }; - refresh() + refresh(); - const observer = new MutationObserver(scheduleRefresh) + const observer = new MutationObserver(scheduleRefresh); - observer.observe(document.documentElement, { - childList: true, - subtree: true, - }) -})(globalThis.WandEnhancer) + observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); +})(globalThis.WandEnhancer); diff --git a/web-panel/bridge/scripts/default/remote-popup-cleanup/qr-renderer.js b/web-panel/bridge/scripts/default/remote-popup-cleanup/qr-renderer.js index 146bb2a..49653a2 100644 --- a/web-panel/bridge/scripts/default/remote-popup-cleanup/qr-renderer.js +++ b/web-panel/bridge/scripts/default/remote-popup-cleanup/qr-renderer.js @@ -1,17 +1,14 @@ -import { isRecord } from "../installed-apps-sync/runtime.js" +import { isRecord } from '../installed-apps-sync/runtime.js'; -const WAND_QR_RENDERER_EXPORT = "mo" +const WAND_QR_RENDERER_EXPORT = 'mo'; export function resolveQrRenderer(webpackRequire) { - for (const record of Object.values(webpackRequire?.c || {})) { - const exports = record?.exports - if ( - isRecord(exports) && - typeof exports[WAND_QR_RENDERER_EXPORT] === "function" - ) { - return exports[WAND_QR_RENDERER_EXPORT] + for (const record of Object.values(webpackRequire?.c || {})) { + const exports = record?.exports; + if (isRecord(exports) && typeof exports[WAND_QR_RENDERER_EXPORT] === 'function') { + return exports[WAND_QR_RENDERER_EXPORT]; + } } - } - return null + return null; } diff --git a/web-panel/bridge/scripts/package.json b/web-panel/bridge/scripts/package.json new file mode 100644 index 0000000..4720025 --- /dev/null +++ b/web-panel/bridge/scripts/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/web-panel/bridge/src/bridge-state.ts b/web-panel/bridge/src/bridge-state.ts index 27fa757..fa03062 100644 --- a/web-panel/bridge/src/bridge-state.ts +++ b/web-panel/bridge/src/bridge-state.ts @@ -1,5 +1,11 @@ +import type { + GameStatusPayload, + IncomingMessage, + InstalledAppsPayload, + TrainerMetaPayload, + TrainerValuesPayload, +} from '../../protocol/messages'; import type { BridgeClient, LogFn, ServerInfo } from './types'; -import type { GameStatusPayload, InstalledAppsPayload, TrainerMetaPayload, TrainerValuesPayload, IncomingMessage } from '../../protocol/messages'; const { gameStatusSignature, @@ -15,7 +21,11 @@ const { normalizeGameStatusSnapshot: (snapshot: unknown) => GameStatusPayload | null; normalizeInstalledAppsSnapshot: (snapshot: unknown) => InstalledAppsPayload | null; normalizeSnapshot: (snapshot: unknown) => BridgeStateSnapshot | null; - normalizeTrainerValue: (snapshot: BridgeStateSnapshot, target: string, value: unknown) => unknown; + normalizeTrainerValue: ( + snapshot: BridgeStateSnapshot, + target: string, + value: unknown, + ) => unknown; summarizeInstalledAppsSource: (snapshot: unknown) => string; }; const { cloneValue, isRecord, safeString } = require('./utils') as { @@ -24,10 +34,15 @@ const { cloneValue, isRecord, safeString } = require('./utils') as { safeString: (value: unknown, fallback?: string) => string; }; const { sendJson } = require('./websocket-codec') as { - sendJson: (client: BridgeClient, type: string, payload: unknown, requestId?: string | number | null) => void; + sendJson: ( + client: BridgeClient, + type: string, + payload: unknown, + requestId?: string | number | null, + ) => void; }; -type BridgeStateSnapshot = { trainerMeta: TrainerMetaPayload, trainerValues: TrainerValuesPayload }; +type BridgeStateSnapshot = { trainerMeta: TrainerMetaPayload; trainerValues: TrainerValuesPayload }; type BridgeStateOptions = { clients: Iterable; @@ -42,7 +57,11 @@ function createBridgeState({ clients, log, getServerInfo }: BridgeStateOptions) let currentGameStatus: GameStatusPayload | null = null; let currentGameStatusSignature: string | null = null; - function broadcast(type: IncomingMessage['type'], payload: unknown, requestId: string | null = null) { + function broadcast( + type: IncomingMessage['type'], + payload: unknown, + requestId: string | null = null, + ) { for (const client of clients) { if (client.handshaken) { sendJson(client, type, payload, requestId); @@ -78,7 +97,12 @@ function createBridgeState({ clients, log, getServerInfo }: BridgeStateOptions) function syncTrainerMeta(rawSnapshot: unknown) { const localizedSnapshot = normalizeSnapshot(rawSnapshot); - if (!currentSnapshot || !localizedSnapshot || localizedSnapshot.trainerMeta.trainer.trainerId !== currentSnapshot.trainerMeta.trainer.trainerId) { + if ( + !currentSnapshot || + !localizedSnapshot || + localizedSnapshot.trainerMeta.trainer.trainerId !== + currentSnapshot.trainerMeta.trainer.trainerId + ) { return; } @@ -91,7 +115,7 @@ function createBridgeState({ clients, log, getServerInfo }: BridgeStateOptions) if (!snapshot || !isRecord(change)) return; const target = safeString(change.target); if (!target) return; - + if (safeString(change.trainerId) !== snapshot.trainerMeta.trainer.trainerId) return; const value = normalizeTrainerValue(snapshot, target, change.value); @@ -110,14 +134,20 @@ function createBridgeState({ clients, log, getServerInfo }: BridgeStateOptions) const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps); const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps); if (!nextInstalledApps) { - log('warn', `Ignored invalid installed apps snapshot.${sourceSummary ? ` ${sourceSummary}` : ''}`); + log( + 'warn', + `Ignored invalid installed apps snapshot.${sourceSummary ? ` ${sourceSummary}` : ''}`, + ); return; } const nextSignature = installedAppsSignature(nextInstalledApps); if (nextSignature === currentInstalledAppsSignature) return; currentInstalledApps = nextInstalledApps; currentInstalledAppsSignature = nextSignature; - log('info', `Installed apps snapshot accepted (${nextInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`); + log( + 'info', + `Installed apps snapshot accepted (${nextInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`, + ); broadcast('installed_apps', currentInstalledApps); } @@ -131,7 +161,10 @@ function createBridgeState({ clients, log, getServerInfo }: BridgeStateOptions) if (nextSignature === currentGameStatusSignature) return; currentGameStatus = nextGameStatus; currentGameStatusSignature = nextSignature; - log('info', `Game status snapshot accepted (${nextGameStatus.session.state}/${nextGameStatus.session.event}).`); + log( + 'info', + `Game status snapshot accepted (${nextGameStatus.session.state}/${nextGameStatus.session.event}).`, + ); broadcast('game_status', currentGameStatus); } @@ -158,7 +191,9 @@ function createBridgeState({ clients, log, getServerInfo }: BridgeStateOptions) } return { - get snapshot() { return currentSnapshot; }, + get snapshot() { + return currentSnapshot; + }, buildHealthPayload, clear, sendSnapshot, diff --git a/web-panel/bridge/src/index.ts b/web-panel/bridge/src/index.ts index 55029ad..bc112b3 100644 --- a/web-panel/bridge/src/index.ts +++ b/web-panel/bridge/src/index.ts @@ -1,7 +1,7 @@ const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./runtime'); const { installWandRuntime: installRuntime } = require('./wand/runtime'); -import type { BridgeOptions } from './types'; -import type { ElectronPort } from './types'; + +import type { BridgeOptions, ElectronPort } from './types'; function withDefaultPanelRoot(options: BridgeOptions = {}): BridgeOptions { if (options.panelRoot) { diff --git a/web-panel/bridge/src/logger.ts b/web-panel/bridge/src/logger.ts index ebcd2cf..623ffc3 100644 --- a/web-panel/bridge/src/logger.ts +++ b/web-panel/bridge/src/logger.ts @@ -3,6 +3,7 @@ const os = require('node:os'); const path = require('node:path'); const { BRIDGE_LOG_FILE_NAME } = require('./constants'); + import type { BridgeLogger, BridgeOptions, LogLevel } from './types'; function writeLogLine(logFile: string, level: LogLevel, message: string, error?: unknown) { @@ -12,17 +13,23 @@ function writeLogLine(logFile: string, level: LogLevel, message: string, error?: // Logging runs inside Wand's own process; a failure here must never take the app down. try { console[method](tag, error || ''); - } catch { } + } catch {} try { - const detail = error ? ` :: ${error && typeof error === 'object' && 'stack' in error ? String(error.stack) : String(error)}` : ''; - fs.appendFileSync(logFile, `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`); - } catch { } + const detail = error + ? ` :: ${error && typeof error === 'object' && 'stack' in error ? String(error.stack) : String(error)}` + : ''; + fs.appendFileSync( + logFile, + `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`, + ); + } catch {} } function createBridgeLogger(options: BridgeOptions = {}): BridgeLogger { const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME); - const log = ((level: LogLevel, message: string, error?: unknown) => writeLogLine(logFile, level, message, error)) as BridgeLogger; + const log = ((level: LogLevel, message: string, error?: unknown) => + writeLogLine(logFile, level, message, error)) as BridgeLogger; log.file = logFile; return log; } diff --git a/web-panel/bridge/src/normalizers/command-results.ts b/web-panel/bridge/src/normalizers/command-results.ts index 9e30f22..5808b25 100644 --- a/web-panel/bridge/src/normalizers/command-results.ts +++ b/web-panel/bridge/src/normalizers/command-results.ts @@ -1,25 +1,36 @@ import type { RemoteCommandAction, RemoteCommandResultPayload } from '../../../protocol/messages'; import type { UnknownRecord } from '../types'; + const { isRecord, safeString, toStringId } = require('../utils'); function normalizeRemoteCommandAction(value: unknown): RemoteCommandAction | null { return value === 'launch' || value === 'stop' ? (value as RemoteCommandAction) : null; } -function normalizeRemoteCommandResult(rawResult: unknown, fallback: { action: RemoteCommandAction, gameId?: string | null, titleId?: string | null }): RemoteCommandResultPayload { +function normalizeRemoteCommandResult( + rawResult: unknown, + fallback: { action: RemoteCommandAction; gameId?: string | null; titleId?: string | null }, +): RemoteCommandResultPayload { const raw = isRecord(rawResult) ? (rawResult as UnknownRecord) : null; const action = normalizeRemoteCommandAction(raw ? raw.action : null) || fallback.action; - const gameId = raw ? toStringId(raw.gameId) || fallback.gameId || null : fallback.gameId || null; - const titleId = raw ? toStringId(raw.titleId) || fallback.titleId || null : fallback.titleId || null; + const gameId = raw + ? toStringId(raw.gameId) || fallback.gameId || null + : fallback.gameId || null; + const titleId = raw + ? toStringId(raw.titleId) || fallback.titleId || null + : fallback.titleId || null; const ok = rawResult === true || Boolean(raw && raw.ok === true); const payload = { ok, action, gameId, titleId }; if (ok) return payload; - + const errorRaw = raw && isRecord(raw.error) ? (raw.error as UnknownRecord) : null; if (!errorRaw) { return { ...payload, - error: { code: 'command_rejected', message: 'The renderer rejected the remote command.' }, + error: { + code: 'command_rejected', + message: 'The renderer rejected the remote command.', + }, }; } return { diff --git a/web-panel/bridge/src/normalizers/game-status.ts b/web-panel/bridge/src/normalizers/game-status.ts index 86b9910..157b1fc 100644 --- a/web-panel/bridge/src/normalizers/game-status.ts +++ b/web-panel/bridge/src/normalizers/game-status.ts @@ -1,5 +1,6 @@ import type { GameStatusPayload } from '../../../protocol/messages'; import type { UnknownRecord } from '../types'; + const { isRecord, safeString, toStringId } = require('../utils'); function normalizeGameStatusSnapshot(rawSnapshot: unknown): GameStatusPayload | null { @@ -17,7 +18,10 @@ function normalizeGameStatusSnapshot(rawSnapshot: unknown): GameStatusPayload | gameId: toStringId(rawSession.gameId), titleId: toStringId(rawSession.titleId), titleName: typeof rawSession.titleName === 'string' ? rawSession.titleName : null, - sessionDurationSeconds: typeof rawSession.sessionDurationSeconds === 'number' ? rawSession.sessionDurationSeconds : null, + sessionDurationSeconds: + typeof rawSession.sessionDurationSeconds === 'number' + ? rawSession.sessionDurationSeconds + : null, startedAt: typeof rawSession.startedAt === 'string' ? rawSession.startedAt : null, endedAt: typeof rawSession.endedAt === 'string' ? rawSession.endedAt : null, }, diff --git a/web-panel/bridge/src/normalizers/index.ts b/web-panel/bridge/src/normalizers/index.ts index 82e085f..742c262 100644 --- a/web-panel/bridge/src/normalizers/index.ts +++ b/web-panel/bridge/src/normalizers/index.ts @@ -1,5 +1,14 @@ -import type { CheatArgs, CheatOption, CheatSchema, InstalledAppsPayload, InstalledAppSummary, TrainerMetaPayload, TrainerValuesPayload } from '../../../protocol/messages'; +import type { + CheatArgs, + CheatOption, + CheatSchema, + InstalledAppSummary, + InstalledAppsPayload, + TrainerMetaPayload, + TrainerValuesPayload, +} from '../../../protocol/messages'; import type { UnknownRecord } from '../types'; + const { KNOWN_CHEAT_TYPES } = require('../constants'); const { cloneValue, firstString, isRecord, safeString, toStringId } = require('../utils'); const { normalizeRemoteCommandAction, normalizeRemoteCommandResult } = require('./command-results'); @@ -41,7 +50,11 @@ function normalizeArgs(args: unknown): CheatArgs { if (typeof a.max === 'number') next.max = a.max; if (typeof a.step === 'number') next.step = a.step; if (typeof a.postfix === 'string') next.postfix = a.postfix; - if (typeof a.default === 'string' || typeof a.default === 'number' || typeof a.default === 'boolean') { + if ( + typeof a.default === 'string' || + typeof a.default === 'number' || + typeof a.default === 'boolean' + ) { next.default = a.default; } @@ -85,7 +98,9 @@ function normalizeCheat(cheat: unknown, index: number): CheatSchema | null { } if (Array.isArray(c.hotkeys)) { - normalized.hotkeys = c.hotkeys.filter(Array.isArray).map((group: unknown[]) => group.map((item: unknown) => String(item))); + normalized.hotkeys = c.hotkeys + .filter(Array.isArray) + .map((group: unknown[]) => group.map((item: unknown) => String(item))); } return normalized; @@ -142,13 +157,26 @@ function normalizeInstalledApp(app: unknown): InstalledAppSummary | null { a.gameName, a.name, location.replaceAll('\\', '/').split('/').filter(Boolean).pop() || '', - `${platform}:${sku}` + `${platform}:${sku}`, ), gameId: toStringId(a.gameId), titleId: toStringId(a.titleId), - imageUrl: normalizeImageUrl(a.imageUrl, a.iconUrl, a.coverUrl, a.thumbnailUrl, a.logoUrl, a.headerImageUrl), - platformLastPlayedTimestamp: typeof a.platformLastPlayedTimestamp === 'number' ? a.platformLastPlayedTimestamp : null, - platformTotalPlaytimeMinutes: typeof a.platformTotalPlaytimeMinutes === 'number' ? a.platformTotalPlaytimeMinutes : null, + imageUrl: normalizeImageUrl( + a.imageUrl, + a.iconUrl, + a.coverUrl, + a.thumbnailUrl, + a.logoUrl, + a.headerImageUrl, + ), + platformLastPlayedTimestamp: + typeof a.platformLastPlayedTimestamp === 'number' + ? a.platformLastPlayedTimestamp + : null, + platformTotalPlaytimeMinutes: + typeof a.platformTotalPlaytimeMinutes === 'number' + ? a.platformTotalPlaytimeMinutes + : null, }; } @@ -162,8 +190,11 @@ function normalizeInstalledAppsSnapshot(rawSnapshot: unknown): InstalledAppsPayl apps.sort(compareInstalledApps); const snap = isRecord(rawSnapshot) ? (rawSnapshot as UnknownRecord) : null; return { - instanceId: snap ? safeString(snap.instanceId, 'wand-installed-apps') : 'wand-installed-apps', - updatedAt: snap && typeof snap.updatedAt === 'string' ? snap.updatedAt : new Date().toISOString(), + instanceId: snap + ? safeString(snap.instanceId, 'wand-installed-apps') + : 'wand-installed-apps', + updatedAt: + snap && typeof snap.updatedAt === 'string' ? snap.updatedAt : new Date().toISOString(), apps, }; } @@ -193,7 +224,9 @@ function installedAppsSignature(snapshot: InstalledAppsPayload): string { return JSON.stringify(snapshot.apps); } -function normalizeSnapshot(rawSnapshot: unknown): { trainerMeta: TrainerMetaPayload, trainerValues: TrainerValuesPayload } | null { +function normalizeSnapshot( + rawSnapshot: unknown, +): { trainerMeta: TrainerMetaPayload; trainerValues: TrainerValuesPayload } | null { if (!isRecord(rawSnapshot)) return null; const snap = rawSnapshot as UnknownRecord; if (!isRecord(snap.metadata)) return null; @@ -205,10 +238,10 @@ function normalizeSnapshot(rawSnapshot: unknown): { trainerMeta: TrainerMetaPayl const rawCheats = Array.isArray(blueprint.cheats) ? blueprint.cheats : []; const cheats = rawCheats.map(normalizeCheat).filter(Boolean) as CheatSchema[]; const categories = Array.from(new Set(cheats.map((entry) => entry.category))); - + const trainerInfo = isRecord(snap.trainerInfo) ? (snap.trainerInfo as UnknownRecord) : null; const infoGame = isRecord(info.game) ? (info.game as UnknownRecord) : null; - + const trainerId = safeString(snap.trainerId || trainerInfo?.trainerId); const displayName = firstString( trainerInfo?.displayName, @@ -223,7 +256,7 @@ function normalizeSnapshot(rawSnapshot: unknown): { trainerMeta: TrainerMetaPayl info.name, infoGame?.displayName, infoGame?.name, - infoGame?.title + infoGame?.title, ); if (!trainerId) { @@ -260,7 +293,11 @@ function normalizeSnapshot(rawSnapshot: unknown): { trainerMeta: TrainerMetaPayl }; for (const cheat of cheats) { if (cheat.target in trainerValues.values) { - trainerValues.values[cheat.target] = normalizeTrainerValue({ trainerMeta }, cheat.target, trainerValues.values[cheat.target]); + trainerValues.values[cheat.target] = normalizeTrainerValue( + { trainerMeta }, + cheat.target, + trainerValues.values[cheat.target], + ); } } diff --git a/web-panel/bridge/src/normalizers/trainer.test.ts b/web-panel/bridge/src/normalizers/trainer.test.ts index ea18e24..f0114e0 100644 --- a/web-panel/bridge/src/normalizers/trainer.test.ts +++ b/web-panel/bridge/src/normalizers/trainer.test.ts @@ -3,15 +3,15 @@ import { describe, expect, it } from 'vitest'; import { normalizeTrainerValue } from './trainer'; describe('trainer normalization', () => { - it('normalizes toggle values before they reach clients or Wand', () => { - const snapshot = { - trainerMeta: { - schema: { cheats: [{ target: 'god', type: 'toggle' }] }, - }, - }; + it('normalizes toggle values before they reach clients or Wand', () => { + const snapshot = { + trainerMeta: { + schema: { cheats: [{ target: 'god', type: 'toggle' }] }, + }, + }; - expect(normalizeTrainerValue(snapshot, 'god', 1)).toBe(true); - expect(normalizeTrainerValue(snapshot, 'god', 0)).toBe(false); - expect(normalizeTrainerValue(snapshot, 'speed', 2)).toBe(2); - }); + expect(normalizeTrainerValue(snapshot, 'god', 1)).toBe(true); + expect(normalizeTrainerValue(snapshot, 'god', 0)).toBe(false); + expect(normalizeTrainerValue(snapshot, 'speed', 2)).toBe(2); + }); }); diff --git a/web-panel/bridge/src/normalizers/trainer.ts b/web-panel/bridge/src/normalizers/trainer.ts index ad8a1cc..c082eaa 100644 --- a/web-panel/bridge/src/normalizers/trainer.ts +++ b/web-panel/bridge/src/normalizers/trainer.ts @@ -11,7 +11,7 @@ type SnapshotShape = { export function normalizeTrainerValue( snapshot: SnapshotShape | null | undefined, target: string, - value: unknown + value: unknown, ): unknown { const cheat = snapshot?.trainerMeta?.schema?.cheats?.find((entry) => entry.target === target); return cheat?.type === 'toggle' ? Boolean(value) : cloneValue(value); diff --git a/web-panel/bridge/src/protocol-router.test.ts b/web-panel/bridge/src/protocol-router.test.ts index d3c8524..7a54b02 100644 --- a/web-panel/bridge/src/protocol-router.test.ts +++ b/web-panel/bridge/src/protocol-router.test.ts @@ -4,38 +4,48 @@ import { ECheatType } from '../../protocol/messages'; import { validateClientMessage, validateSetValueTarget } from './protocol-router'; const snapshot = { - trainerMeta: { - trainer: { trainerId: 'active' }, - schema: { cheats: [{ target: 'god', type: ECheatType.Toggle }] }, - }, - trainerValues: { values: { god: false } }, + trainerMeta: { + trainer: { trainerId: 'active' }, + schema: { cheats: [{ target: 'god', type: ECheatType.Toggle }] }, + }, + trainerValues: { values: { god: false } }, }; describe('bridge protocol router', () => { - it('requires a compatible hello before commands', () => { - const command = { - type: 'set_value', - version: 1, - requestId: 'set', - payload: { trainerId: 'active', target: 'god', value: true }, - }; - expect(validateClientMessage(command, false)).toMatchObject({ - ok: false, - error: { code: 'handshake_required' }, + it('requires a compatible hello before commands', () => { + const command = { + type: 'set_value', + version: 1, + requestId: 'set', + payload: { trainerId: 'active', target: 'god', value: true }, + }; + expect(validateClientMessage(command, false)).toMatchObject({ + ok: false, + error: { code: 'handshake_required' }, + }); + expect(validateClientMessage({ ...command, version: 2 }, true)).toMatchObject({ + ok: false, + error: { code: 'protocol_mismatch' }, + }); }); - expect(validateClientMessage({ ...command, version: 2 }, true)).toMatchObject({ - ok: false, - error: { code: 'protocol_mismatch' }, + + it('validates trainer and target while normalizing toggle values', () => { + expect( + validateSetValueTarget( + { + payload: { trainerId: 'other', target: 'god', value: 1 }, + }, + snapshot, + ), + ).toMatchObject({ ok: false, error: { code: 'trainer_mismatch' } }); + + expect( + validateSetValueTarget( + { + payload: { trainerId: 'active', target: 'god', value: 1 }, + }, + snapshot, + ), + ).toMatchObject({ ok: true, value: true }); }); - }); - - it('validates trainer and target while normalizing toggle values', () => { - expect(validateSetValueTarget({ - payload: { trainerId: 'other', target: 'god', value: 1 }, - }, snapshot)).toMatchObject({ ok: false, error: { code: 'trainer_mismatch' } }); - - expect(validateSetValueTarget({ - payload: { trainerId: 'active', target: 'god', value: 1 }, - }, snapshot)).toMatchObject({ ok: true, value: true }); - }); }); diff --git a/web-panel/bridge/src/protocol-router.ts b/web-panel/bridge/src/protocol-router.ts index 07e6ccb..1befa30 100644 --- a/web-panel/bridge/src/protocol-router.ts +++ b/web-panel/bridge/src/protocol-router.ts @@ -1,7 +1,12 @@ +import type { + CheatSchema, + SetValueMessage, + TrainerMetaPayload, + TrainerValuesPayload, +} from '../../protocol/messages'; +import { isOutgoingMessage } from '../../protocol/validation'; import webContract from '../../protocol/web-contract.json'; import { isRecord, safeString } from './utils'; -import { isOutgoingMessage } from '../../protocol/validation'; -import type { CheatSchema, SetValueMessage, TrainerMetaPayload, TrainerValuesPayload } from '../../protocol/messages'; const BRIDGE_PROTOCOL_VERSION = webContract.protocolVersion; @@ -11,7 +16,10 @@ export function validateClientMessage(message: unknown, handshaken: boolean) { } if (message.version !== BRIDGE_PROTOCOL_VERSION) { - return invalid('protocol_mismatch', `Unsupported protocol version ${String(message.version)}.`); + return invalid( + 'protocol_mismatch', + `Unsupported protocol version ${String(message.version)}.`, + ); } if (message.requestId !== null && typeof message.requestId !== 'string') { @@ -48,7 +56,10 @@ type ValidationSnapshot = { trainerValues: Pick; }; -export function validateSetValueTarget(message: Pick, snapshot: ValidationSnapshot | null) { +export function validateSetValueTarget( + message: Pick, + snapshot: ValidationSnapshot | null, +) { const target = safeString(message.payload?.target); const requestedTrainerId = safeString(message.payload?.trainerId); const activeTrainerId = snapshot?.trainerMeta?.trainer?.trainerId || ''; diff --git a/web-panel/bridge/src/renderer-scripts.test.ts b/web-panel/bridge/src/renderer-scripts.test.ts index 75d4504..24cf6b4 100644 --- a/web-panel/bridge/src/renderer-scripts.test.ts +++ b/web-panel/bridge/src/renderer-scripts.test.ts @@ -1,43 +1,45 @@ import { describe, expect, it } from 'vitest'; import { - findSteamAppId, - getSteamClientIconUrl, - normalizeImageUrl, + findSteamAppId, + getSteamClientIconUrl, + normalizeImageUrl, } from '../scripts/default/installed-apps-sync/artwork.js'; import { resolveQrRenderer } from '../scripts/default/remote-popup-cleanup/qr-renderer.js'; describe('installed-apps renderer script models', () => { - it('normalizes captured artwork shapes without a Wand runtime', () => { - expect(normalizeImageUrl({ cover: { imageUrl: '//cdn.example/game.webp' } })) - .toBe('https://cdn.example/game.webp'); - expect(normalizeImageUrl('file:///local/image.png')).toBeNull(); - }); + it('normalizes captured artwork shapes without a Wand runtime', () => { + expect(normalizeImageUrl({ cover: { imageUrl: '//cdn.example/game.webp' } })).toBe( + 'https://cdn.example/game.webp', + ); + expect(normalizeImageUrl('file:///local/image.png')).toBeNull(); + }); - it('finds nested Steam metadata and builds the Wand client icon URL', () => { - const fixture = { - game: { - metadata: { - steam: { - appId: 1245620, - }, - }, - }, - }; + it('finds nested Steam metadata and builds the Wand client icon URL', () => { + const fixture = { + game: { + metadata: { + steam: { + appId: 1245620, + }, + }, + }, + }; - expect(findSteamAppId(fixture)).toBe('1245620'); - expect(getSteamClientIconUrl(findSteamAppId(fixture))) - .toBe('https://api-cdn.wemod.com/steam_community/1245620/client_icon/96.webp'); - }); + expect(findSteamAppId(fixture)).toBe('1245620'); + expect(getSteamClientIconUrl(findSteamAppId(fixture))).toBe( + 'https://api-cdn.wemod.com/steam_community/1245620/client_icon/96.webp', + ); + }); - it('resolves the tree-shaken Wand QR renderer without a create export', () => { - const renderer = () => undefined; - const webpackRequire = { - c: { - qrCode: { exports: { mo: renderer } }, - }, - }; + it('resolves the tree-shaken Wand QR renderer without a create export', () => { + const renderer = () => undefined; + const webpackRequire = { + c: { + qrCode: { exports: { mo: renderer } }, + }, + }; - expect(resolveQrRenderer(webpackRequire)).toBe(renderer); - }); + expect(resolveQrRenderer(webpackRequire)).toBe(renderer); + }); }); diff --git a/web-panel/bridge/src/runtime.integration.test.ts b/web-panel/bridge/src/runtime.integration.test.ts index f5a49c7..f37311e 100644 --- a/web-panel/bridge/src/runtime.integration.test.ts +++ b/web-panel/bridge/src/runtime.integration.test.ts @@ -3,228 +3,259 @@ import { describe, expect, it } from 'vitest'; import { WebSocket as NodeWebSocket } from 'ws'; describe('production bridge runtime', () => { - it('preserves the public API and sends cached snapshots after hello', async () => { - const bridge = require('../../dist/bridge.cjs'); - expect(Object.keys(bridge).sort()).toEqual(['createBridgeRuntime', 'ensureBridge', 'installWandRuntime']); + it('preserves the public API and sends cached snapshots after hello', async () => { + const bridge = require('../../dist/bridge.cjs'); + expect(Object.keys(bridge).sort()).toEqual([ + 'createBridgeRuntime', + 'ensureBridge', + 'installWandRuntime', + ]); - const port = await getFreePort(); - const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port }); - runtime.sync(rawTrainerSnapshot()); - runtime.syncInstalledApps({ - apps: [{ - platform: 'steam', - sku: '123', - displayName: 'Game', - location: 'C:\\private\\Game', - alternateLocations: ['D:\\also-private\\Game'], - }], + const port = await getFreePort(); + const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port }); + runtime.sync(rawTrainerSnapshot()); + runtime.syncInstalledApps({ + apps: [ + { + platform: 'steam', + sku: '123', + displayName: 'Game', + location: 'C:\\private\\Game', + alternateLocations: ['D:\\also-private\\Game'], + }, + ], + }); + + try { + await waitUntil(() => runtime.listening); + const messages = await connectAndCollect(port, 4); + expect(messages.map((message) => message.type)).toEqual([ + 'hello_ack', + 'trainer_meta', + 'trainer_values', + 'installed_apps', + ]); + expect(messages[2].payload.values.god).toBe(true); + expect(JSON.stringify(messages)).not.toContain('wand-secret'); + expect(JSON.stringify(messages)).not.toContain('private'); + } finally { + runtime.close(); + } }); - try { - await waitUntil(() => runtime.listening); - const messages = await connectAndCollect(port, 4); - expect(messages.map((message) => message.type)).toEqual(['hello_ack', 'trainer_meta', 'trainer_values', 'installed_apps']); - expect(messages[2].payload.values.god).toBe(true); - expect(JSON.stringify(messages)).not.toContain('wand-secret'); - expect(JSON.stringify(messages)).not.toContain('private'); - } finally { - runtime.close(); - } - }); + it('rejects a browser WebSocket from a different origin', async () => { + const bridge = require('../../dist/bridge.cjs'); + const port = await getFreePort(); + const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port }); - it('rejects a browser WebSocket from a different origin', async () => { - const bridge = require('../../dist/bridge.cjs'); - const port = await getFreePort(); - const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port }); + try { + await waitUntil(() => runtime.listening); + await expectUpgradeStatus(port, 403); + } finally { + runtime.close(); + } + }); - try { - await waitUntil(() => runtime.listening); - await expectUpgradeStatus(port, 403); - } finally { - runtime.close(); - } - }); + it('allows the local Vite panel to connect to the loopback bridge', async () => { + const bridge = require('../../dist/bridge.cjs'); + const port = await getFreePort(); + const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port }); - it('allows the local Vite panel to connect to the loopback bridge', async () => { - const bridge = require('../../dist/bridge.cjs'); - const port = await getFreePort(); - const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port }); + try { + await waitUntil(() => runtime.listening); + await expectUpgradeAccepted(port, 'http://127.0.0.1:4173'); + } finally { + runtime.close(); + } + }); - try { - await waitUntil(() => runtime.listening); - await expectUpgradeAccepted(port, 'http://127.0.0.1:4173'); - } finally { - runtime.close(); - } - }); + it('closes an oversized WebSocket frame without buffering its payload', async () => { + const bridge = require('../../dist/bridge.cjs'); + const port = await getFreePort(); + const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port }); - it('closes an oversized WebSocket frame without buffering its payload', async () => { - const bridge = require('../../dist/bridge.cjs'); - const port = await getFreePort(); - const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port }); + try { + await waitUntil(() => runtime.listening); + await expectSocketClose(port, Buffer.alloc(1024 * 1024 + 1), 1009); + await expectDeclaredHugeFrameClose(port, 1009); + } finally { + runtime.close(); + } + }); - try { - await waitUntil(() => runtime.listening); - await expectSocketClose(port, Buffer.alloc(1024 * 1024 + 1), 1009); - await expectDeclaredHugeFrameClose(port, 1009); - } finally { - runtime.close(); - } - }); + it('does not trust the HTTP Host header as a URL base', async () => { + const bridge = require('../../dist/bridge.cjs'); + const port = await getFreePort(); + const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port }); - it('does not trust the HTTP Host header as a URL base', async () => { - const bridge = require('../../dist/bridge.cjs'); - const port = await getFreePort(); - const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port }); + try { + await waitUntil(() => runtime.listening); + const malformedHost = await sendRawHttp( + port, + 'GET /remote/api/health HTTP/1.1\r\nHost: [\r\nConnection: close\r\n\r\n', + ); + expect(malformedHost).toContain('HTTP/1.1 200 OK'); - try { - await waitUntil(() => runtime.listening); - const malformedHost = await sendRawHttp(port, 'GET /remote/api/health HTTP/1.1\r\nHost: [\r\nConnection: close\r\n\r\n'); - expect(malformedHost).toContain('HTTP/1.1 200 OK'); - - const malformedTarget = await sendRawHttp(port, 'GET //[ HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'); - expect(malformedTarget).toContain('HTTP/1.1 400 Bad Request'); - } finally { - runtime.close(); - } - }); + const malformedTarget = await sendRawHttp( + port, + 'GET //[ HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n', + ); + expect(malformedTarget).toContain('HTTP/1.1 400 Bad Request'); + } finally { + runtime.close(); + } + }); }); async function getFreePort(): Promise { - return await new Promise((resolve, reject) => { - const server = createServer(); - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : 0; - server.close((error) => error ? reject(error) : resolve(port)); + return await new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + server.close((error) => (error ? reject(error) : resolve(port))); + }); }); - }); } async function connectAndCollect(port: number, count: number): Promise { - return await new Promise((resolve, reject) => { - const messages: any[] = []; - const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`); - socket.once('error', reject); - socket.once('open', () => socket.send(JSON.stringify({ - type: 'hello', - version: 1, - requestId: 'hello', - payload: { - client: 'mobile-web', - clientVersion: 'test', - capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true }, - }, - }))); - socket.on('message', (raw) => { - messages.push(JSON.parse(String(raw))); - if (messages.length === count) { - socket.close(); - resolve(messages); - } + return await new Promise((resolve, reject) => { + const messages: any[] = []; + const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`); + socket.once('error', reject); + socket.once('open', () => + socket.send( + JSON.stringify({ + type: 'hello', + version: 1, + requestId: 'hello', + payload: { + client: 'mobile-web', + clientVersion: 'test', + capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true }, + }, + }), + ), + ); + socket.on('message', (raw) => { + messages.push(JSON.parse(String(raw))); + if (messages.length === count) { + socket.close(); + resolve(messages); + } + }); }); - }); } async function expectUpgradeStatus(port: number, expectedStatus: number): Promise { - await new Promise((resolve, reject) => { - const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`, { - headers: { Origin: 'https://example.com' }, + await new Promise((resolve, reject) => { + const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`, { + headers: { Origin: 'https://example.com' }, + }); + socket.once('open', () => reject(new Error('Cross-origin WebSocket was accepted.'))); + socket.once('error', () => undefined); + socket.once('unexpected-response', (_request, response) => { + response.resume(); + if (response.statusCode === expectedStatus) { + resolve(); + } else { + reject(new Error(`Expected HTTP ${expectedStatus}, got ${response.statusCode}.`)); + } + }); }); - socket.once('open', () => reject(new Error('Cross-origin WebSocket was accepted.'))); - socket.once('error', () => undefined); - socket.once('unexpected-response', (_request, response) => { - response.resume(); - if (response.statusCode === expectedStatus) { - resolve(); - } else { - reject(new Error(`Expected HTTP ${expectedStatus}, got ${response.statusCode}.`)); - } - }); - }); } async function expectUpgradeAccepted(port: number, origin: string): Promise { - await new Promise((resolve, reject) => { - const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`, { - headers: { Origin: origin }, + await new Promise((resolve, reject) => { + const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`, { + headers: { Origin: origin }, + }); + socket.once('open', () => { + socket.close(); + resolve(); + }); + socket.once('error', reject); }); - socket.once('open', () => { - socket.close(); - resolve(); - }); - socket.once('error', reject); - }); } -async function expectSocketClose(port: number, payload: Buffer, expectedCode: number): Promise { - await new Promise((resolve, reject) => { - const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`); - socket.once('open', () => socket.send(payload)); - socket.once('close', (code) => code === expectedCode - ? resolve() - : reject(new Error(`Expected close code ${expectedCode}, got ${code}.`))); - socket.once('error', reject); - }); +async function expectSocketClose( + port: number, + payload: Buffer, + expectedCode: number, +): Promise { + await new Promise((resolve, reject) => { + const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`); + socket.once('open', () => socket.send(payload)); + socket.once('close', (code) => + code === expectedCode + ? resolve() + : reject(new Error(`Expected close code ${expectedCode}, got ${code}.`)), + ); + socket.once('error', reject); + }); } async function expectDeclaredHugeFrameClose(port: number, expectedCode: number): Promise { - await new Promise((resolve, reject) => { - const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`); - socket.once('open', () => { - const header = Buffer.alloc(10); - header[0] = 0x81; - header[1] = 0xff; - header.writeUInt32BE(1, 2); - (socket as any)._socket.write(header); + await new Promise((resolve, reject) => { + const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`); + socket.once('open', () => { + const header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 0xff; + header.writeUInt32BE(1, 2); + (socket as any)._socket.write(header); + }); + socket.once('close', (code) => + code === expectedCode + ? resolve() + : reject(new Error(`Expected close code ${expectedCode}, got ${code}.`)), + ); + socket.once('error', reject); }); - socket.once('close', (code) => code === expectedCode - ? resolve() - : reject(new Error(`Expected close code ${expectedCode}, got ${code}.`))); - socket.once('error', reject); - }); } async function sendRawHttp(port: number, request: string): Promise { - return await new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - const socket = connect(port, '127.0.0.1'); - socket.once('connect', () => socket.end(request)); - socket.on('data', (chunk) => chunks.push(chunk)); - socket.once('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); - socket.once('error', reject); - }); + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const socket = connect(port, '127.0.0.1'); + socket.once('connect', () => socket.end(request)); + socket.on('data', (chunk) => chunks.push(chunk)); + socket.once('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + socket.once('error', reject); + }); } async function waitUntil(predicate: () => boolean): Promise { - const deadline = Date.now() + 3000; - while (!predicate()) { - if (Date.now() > deadline) throw new Error('Bridge did not start listening.'); - await new Promise((resolve) => setTimeout(resolve, 10)); - } + const deadline = Date.now() + 3000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error('Bridge did not start listening.'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } } function rawTrainerSnapshot() { - return { - instanceId: 'instance', - accessToken: 'wand-secret', - trainerId: 'trainer', - trainerInfo: { gameId: 'game', displayName: 'Game' }, - metadata: { - info: { - blueprint: { - cheats: [{ - uuid: 'god', - target: 'god', - type: 'toggle', - name: 'God mode', - category: 'player', - args: {}, - }], + return { + instanceId: 'instance', + accessToken: 'wand-secret', + trainerId: 'trainer', + trainerInfo: { gameId: 'game', displayName: 'Game' }, + metadata: { + info: { + blueprint: { + cheats: [ + { + uuid: 'god', + target: 'god', + type: 'toggle', + name: 'God mode', + category: 'player', + args: {}, + }, + ], + }, + }, }, - }, - }, - values: { god: 1 }, - }; + values: { god: 1 }, + }; } diff --git a/web-panel/bridge/src/runtime.ts b/web-panel/bridge/src/runtime.ts index 536607b..a3b277f 100644 --- a/web-panel/bridge/src/runtime.ts +++ b/web-panel/bridge/src/runtime.ts @@ -1,4 +1,5 @@ const { createBridgeServer } = require('./server'); + import type { BridgeOptions } from './types'; declare global { diff --git a/web-panel/bridge/src/server-files.ts b/web-panel/bridge/src/server-files.ts index 751642d..b391e93 100644 --- a/web-panel/bridge/src/server-files.ts +++ b/web-panel/bridge/src/server-files.ts @@ -6,7 +6,8 @@ const { REMOTE_BASE_PATH } = require('./constants'); const IPV4_OCTET_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; const PHYSICAL_INTERFACE_NAME_PATTERN = /(?:ethernet|wi-?fi|wireless|wlan|lan|local area)/i; -const VIRTUAL_INTERFACE_NAME_PATTERN = /(?:bluetooth|container|docker|hamachi|hyper-v|loopback|npcap|pseudo|tap|tailscale|teredo|tunnel|tun|virtual|vmware|vbox|virtualbox|vpn|wireguard|wsl|zerotier)/i; +const VIRTUAL_INTERFACE_NAME_PATTERN = + /(?:bluetooth|container|docker|hamachi|hyper-v|loopback|npcap|pseudo|tap|tailscale|teredo|tunnel|tun|virtual|vmware|vbox|virtualbox|vpn|wireguard|wsl|zerotier)/i; const VIRTUAL_MAC_PREFIXES = new Set([ '00:05:69', '00:0c:29', @@ -18,8 +19,8 @@ const VIRTUAL_MAC_PREFIXES = new Set([ '52:54:00', ]); -import type { NetworkInterfaceInfo } from 'node:os'; import type { ServerResponse } from 'node:http'; +import type { NetworkInterfaceInfo } from 'node:os'; function contentTypeFor(filePath: string) { const extension = path.extname(filePath).toLowerCase(); @@ -41,11 +42,14 @@ function contentTypeFor(filePath: string) { } function getAdvertisedUrls(port: number) { - const candidates: { index: number, score: number, url: string }[] = []; + const candidates: { index: number; score: number; url: string }[] = []; const interfaces = os.networkInterfaces(); let index = 0; - for (const [name, entries] of Object.entries(interfaces) as [string, NetworkInterfaceInfo[] | undefined][]) { + for (const [name, entries] of Object.entries(interfaces) as [ + string, + NetworkInterfaceInfo[] | undefined, + ][]) { if (!entries) { continue; } @@ -73,7 +77,9 @@ function getAdvertisedUrls(port: number) { } function isUsableIpv4Entry(entry: NetworkInterfaceInfo) { - return Boolean(entry && !entry.internal && isIpv4Family(entry.family) && parseIpv4(entry.address)); + return Boolean( + entry && !entry.internal && isIpv4Family(entry.family) && parseIpv4(entry.address), + ); } function isIpv4Family(family: string | number) { @@ -130,11 +136,17 @@ function parseIpv4(address: unknown): number[] | null { } const octets = match.slice(1).map((part) => Number(part)); - return octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) ? octets : null; + return octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) + ? octets + : null; } function isPrivateIpv4(octets: number[]) { - return octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168); + return ( + octets[0] === 10 || + (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || + (octets[0] === 192 && octets[1] === 168) + ); } function isLinkLocalIpv4(octets: number[]) { diff --git a/web-panel/bridge/src/server.ts b/web-panel/bridge/src/server.ts index c801ac8..7a26d5f 100644 --- a/web-panel/bridge/src/server.ts +++ b/web-panel/bridge/src/server.ts @@ -14,10 +14,7 @@ const { WS_OPCODE, } = require('./constants'); const { createBridgeLogger } = require('./logger'); -const { - normalizeRemoteCommandAction, - normalizeRemoteCommandResult, -} = require('./normalizers'); +const { normalizeRemoteCommandAction, normalizeRemoteCommandResult } = require('./normalizers'); const { createBridgeState } = require('./bridge-state'); const { validateClientMessage, validateSetValueTarget } = require('./protocol-router'); const { getAdvertisedUrls, resolveInsideRoot, serveFile } = require('./server-files'); @@ -31,9 +28,10 @@ const { parseFrame, sendJson, } = require('./websocket-codec'); + import type { IncomingMessage, ServerResponse } from 'node:http'; import type { Socket } from 'node:net'; -import type { BridgeOptions, BridgeClient } from './types'; +import type { BridgeClient, BridgeOptions } from './types'; const HTTP_BAD_REQUEST = 400; const HTTP_FORBIDDEN = 403; @@ -49,8 +47,17 @@ declare global { var __wandRemoteBridgeLogFile: string | undefined; } -type SetValueHandler = (args: { trainerId: string; target: string; value: unknown; cheatId?: string }) => boolean | Promise; -type CommandHandler = (args: { action: string; gameId: string; titleId: string }) => unknown | Promise; +type SetValueHandler = (args: { + trainerId: string; + target: string; + value: unknown; + cheatId?: string; +}) => boolean | Promise; +type CommandHandler = (args: { + action: string; + gameId: string; + titleId: string; +}) => unknown | Promise; type ClientMessage = { type?: string; @@ -59,9 +66,13 @@ type ClientMessage = { }; function createBridgeServer(options: BridgeOptions = {}) { - const preferredPort = Number(options.port || process.env.WAND_REMOTE_PORT || DEFAULT_REMOTE_PORT); + const preferredPort = Number( + options.port || process.env.WAND_REMOTE_PORT || DEFAULT_REMOTE_PORT, + ); let port = isValidPort(preferredPort) ? preferredPort : DEFAULT_REMOTE_PORT; - const maxPort = Number(options.maxPort || process.env.WAND_REMOTE_MAX_PORT || port + PORT_SCAN_RANGE); + const maxPort = Number( + options.maxPort || process.env.WAND_REMOTE_MAX_PORT || port + PORT_SCAN_RANGE, + ); const host = options.host || process.env.WAND_REMOTE_HOST || DEFAULT_REMOTE_HOST; const panelRoot = options.panelRoot || path.dirname(__dirname); const clients = new Set(); @@ -84,7 +95,9 @@ function createBridgeServer(options: BridgeOptions = {}) { function setAdvertisedPort(nextPort: number) { port = nextPort; advertisedUrls = getAdvertisedUrls(port); - globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry: string) => !entry.includes('localhost')) || advertisedUrls[0]; + globalThis.__wandRemoteBridgeUrl = + advertisedUrls.find((entry: string) => !entry.includes('localhost')) || + advertisedUrls[0]; } function setHandler(handler: SetValueHandler | null) { @@ -129,7 +142,10 @@ function createBridgeServer(options: BridgeOptions = {}) { // Any file under the panel root, not just assets/: a Vite build also emits // icons and a manifest at the root, and /remote/index.html must resolve too. if (url.pathname.startsWith(REMOTE_BASE_PATH)) { - serveFile(response, resolveInsideRoot(panelRoot, url.pathname.slice(REMOTE_BASE_PATH.length))); + serveFile( + response, + resolveInsideRoot(panelRoot, url.pathname.slice(REMOTE_BASE_PATH.length)), + ); return; } @@ -143,48 +159,83 @@ function createBridgeServer(options: BridgeOptions = {}) { const titleId = toStringId(message.payload?.titleId); if (!action) { - sendJson(client, 'error', { - code: 'invalid_command', - message: 'Unknown remote command.', - }, message.requestId ?? null); + sendJson( + client, + 'error', + { + code: 'invalid_command', + message: 'Unknown remote command.', + }, + message.requestId ?? null, + ); return; } const fallback = { action, gameId, titleId }; if (action === 'launch' && !gameId) { - sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({ - ok: false, - error: { - code: 'invalid_game', - message: 'A game id is required to launch a trainer.', - }, - }, fallback), message.requestId ?? null); + sendJson( + client, + 'remote_command_result', + normalizeRemoteCommandResult( + { + ok: false, + error: { + code: 'invalid_game', + message: 'A game id is required to launch a trainer.', + }, + }, + fallback, + ), + message.requestId ?? null, + ); return; } if (!commandHandler) { - sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({ - ok: false, - error: { - code: 'bridge_not_ready', - message: 'The local bridge is not ready to execute remote game commands yet.', - }, - }, fallback), message.requestId ?? null); + sendJson( + client, + 'remote_command_result', + normalizeRemoteCommandResult( + { + ok: false, + error: { + code: 'bridge_not_ready', + message: + 'The local bridge is not ready to execute remote game commands yet.', + }, + }, + fallback, + ), + message.requestId ?? null, + ); return; } try { const result = await Promise.resolve(commandHandler({ action, gameId, titleId })); - sendJson(client, 'remote_command_result', normalizeRemoteCommandResult(result, fallback), message.requestId ?? null); + sendJson( + client, + 'remote_command_result', + normalizeRemoteCommandResult(result, fallback), + message.requestId ?? null, + ); } catch (error) { log('warn', 'Remote command handler failed.', error); - sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({ - ok: false, - error: { - code: 'command_failed', - message: 'Failed to execute the remote command.', - }, - }, fallback), message.requestId ?? null); + sendJson( + client, + 'remote_command_result', + normalizeRemoteCommandResult( + { + ok: false, + error: { + code: 'command_failed', + message: 'Failed to execute the remote command.', + }, + }, + fallback, + ), + message.requestId ?? null, + ); } } @@ -193,12 +244,13 @@ function createBridgeServer(options: BridgeOptions = {}) { const validation = validateSetValueTarget(message, currentSnapshot); const trainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId || ''; const target = validation.ok ? validation.target : safeString(message.payload?.target); - const reply = (error?: { code: string; message: string }) => sendJson( - client, - 'set_value_result', - { ok: !error, trainerId, target, error }, - message.requestId ?? null, - ); + const reply = (error?: { code: string; message: string }) => + sendJson( + client, + 'set_value_result', + { ok: !error, trainerId, target, error }, + message.requestId ?? null, + ); if (!validation.ok) { reply(validation.error); @@ -215,22 +267,31 @@ function createBridgeServer(options: BridgeOptions = {}) { let accepted = false; try { - accepted = await Promise.resolve(setValueHandler({ - trainerId, - target, - value: cloneValue(validation.value), - cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined, - })); + accepted = await Promise.resolve( + setValueHandler({ + trainerId, + target, + value: cloneValue(validation.value), + cheatId: + typeof message.payload?.cheatId === 'string' + ? message.payload.cheatId + : undefined, + }), + ); } catch (error) { log('warn', 'Set-value handler failed.', error); reply({ code: 'set_failed', message: 'Failed to set trainer value.' }); return; } - reply(accepted ? undefined : { - code: 'set_rejected', - message: 'The trainer rejected the requested value.', - }); + reply( + accepted + ? undefined + : { + code: 'set_rejected', + message: 'The trainer rejected the requested value.', + }, + ); } async function handleClientMessage(client: BridgeClient, message: ClientMessage) { @@ -242,14 +303,19 @@ function createBridgeServer(options: BridgeOptions = {}) { if (message?.type === 'hello') { client.handshaken = true; - sendJson(client, 'hello_ack', { - sessionId: `sess_${Date.now()}`, - accepted: true, - serverVersion: BRIDGE_SERVER_VERSION, - protocolVersion: BRIDGE_PROTOCOL_VERSION, - remoteUrl: globalThis.__wandRemoteBridgeUrl, - advertisedUrls, - }, message.requestId ?? null); + sendJson( + client, + 'hello_ack', + { + sessionId: `sess_${Date.now()}`, + accepted: true, + serverVersion: BRIDGE_SERVER_VERSION, + protocolVersion: BRIDGE_PROTOCOL_VERSION, + remoteUrl: globalThis.__wandRemoteBridgeUrl, + advertisedUrls, + }, + message.requestId ?? null, + ); bridgeState.sendSnapshot(client); return; } @@ -283,7 +349,11 @@ function createBridgeServer(options: BridgeOptions = {}) { client.buffer = client.buffer.subarray(frame.bytesConsumed); if (!frame.fin) { - closeClient(client, WS_CLOSE_UNSUPPORTED, 'Fragmented frames are not supported.'); + closeClient( + client, + WS_CLOSE_UNSUPPORTED, + 'Fragmented frames are not supported.', + ); return; } @@ -311,7 +381,10 @@ function createBridgeServer(options: BridgeOptions = {}) { // The frame is already consumed, so the ones behind it stay drainable. sendJson(client, 'error', { code: 'invalid_message', - message: error instanceof Error ? error.message : 'Failed to process client message.', + message: + error instanceof Error + ? error.message + : 'Failed to process client message.', }); } } @@ -385,14 +458,16 @@ function createBridgeServer(options: BridgeOptions = {}) { return; } - socket.write([ - 'HTTP/1.1 101 Switching Protocols', - 'Upgrade: websocket', - 'Connection: Upgrade', - `Sec-WebSocket-Accept: ${createAcceptKey(key)}`, - '', - '', - ].join('\r\n')); + socket.write( + [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${createAcceptKey(key)}`, + '', + '', + ].join('\r\n'), + ); bindSocket(socket); } @@ -403,7 +478,10 @@ function createBridgeServer(options: BridgeOptions = {}) { } setAdvertisedPort(port); - log('info', `Bridge starting (pid=${process.pid}, panelRoot=${panelRoot}, preferredPort=${port}, host=${host})`); + log( + 'info', + `Bridge starting (pid=${process.pid}, panelRoot=${panelRoot}, preferredPort=${port}, host=${host})`, + ); globalThis.__wandRemoteBridgeLogFile = log.file; const server = http.createServer(handleRequest); @@ -484,8 +562,10 @@ function isAllowedWebSocketOrigin(origin: string | undefined, host: string | und const sameHostname = parsed.hostname.toLowerCase() === requested.hostname.toLowerCase(); const compatibleLoopback = isLoopback(parsed.hostname) && isLoopback(requested.hostname); - return parsed.host.toLowerCase() === host.toLowerCase() - || (DEV_SERVER_PORTS.includes(parsed.port) && (sameHostname || compatibleLoopback)); + return ( + parsed.host.toLowerCase() === host.toLowerCase() || + (DEV_SERVER_PORTS.includes(parsed.port) && (sameHostname || compatibleLoopback)) + ); } catch { return false; } @@ -496,13 +576,15 @@ function isLoopback(hostname: string) { } function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) { - socket.end([ - `HTTP/1.1 ${statusCode} ${statusText}`, - 'Connection: close', - 'Content-Length: 0', - '', - '', - ].join('\r\n')); + socket.end( + [ + `HTTP/1.1 ${statusCode} ${statusText}`, + 'Connection: close', + 'Content-Length: 0', + '', + '', + ].join('\r\n'), + ); } module.exports = { diff --git a/web-panel/bridge/src/types.ts b/web-panel/bridge/src/types.ts index 9e1df68..823adc9 100644 --- a/web-panel/bridge/src/types.ts +++ b/web-panel/bridge/src/types.ts @@ -6,7 +6,13 @@ import type { Socket } from 'node:net'; * are typed `unknown` and narrowed there - not `any`. */ -export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; export type UnknownRecord = Record; @@ -66,11 +72,17 @@ export type IpcMainEventPort = { }; export type IpcMainPort = { - handle(channel: string, listener: (event: IpcMainEventPort, payload?: unknown) => unknown): void; + handle( + channel: string, + listener: (event: IpcMainEventPort, payload?: unknown) => unknown, + ): void; }; export type AppPort = { - on(event: 'web-contents-created', listener: (event: unknown, contents: WebContentsPort) => void): void; + on( + event: 'web-contents-created', + listener: (event: unknown, contents: WebContentsPort) => void, + ): void; }; export type ElectronPort = { diff --git a/web-panel/bridge/src/wand/renderer-scripts.ts b/web-panel/bridge/src/wand/renderer-scripts.ts index 2893e53..fc74b05 100644 --- a/web-panel/bridge/src/wand/renderer-scripts.ts +++ b/web-panel/bridge/src/wand/renderer-scripts.ts @@ -1,8 +1,13 @@ const fs = require('node:fs'); const path = require('node:path'); -const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('../constants'); +const { + RENDERER_INJECTION_DELAYS_MS, + RENDERER_SCRIPT_API_VERSION, + RENDERER_SCRIPTS_DIR, +} = require('../constants'); const { writeInstallLog } = require('../logger'); + import type { BridgeOptions, ElectronPort, WebContentsPort } from '../types'; declare global { @@ -15,7 +20,8 @@ function loadRendererScripts(panelRoot: string, scriptsRoot?: string) { return []; } - return fs.readdirSync(root) + return fs + .readdirSync(root) .filter((name: string) => name.endsWith('.js')) .sort((left: string, right: string) => left.localeCompare(right)) .map((name: string) => ({ @@ -39,9 +45,10 @@ function buildRendererBootstrap(remoteUrl: string, scripts: { name: string; sour console.info("[wand-remote-bridge] Renderer bootstrap (" + ${scripts.length} + " script(s))."); `; - const body = scripts.map((script) => { - const tag = JSON.stringify(`wand-enhancer-script-${script.name}`); - return ` + const body = scripts + .map((script) => { + const tag = JSON.stringify(`wand-enhancer-script-${script.name}`); + return ` ;(function (WandEnhancer) { try { ${script.source} @@ -51,18 +58,26 @@ function buildRendererBootstrap(remoteUrl: string, scripts: { name: string; sour })(globalThis.WandEnhancer); //# sourceURL=${tag.slice(1, -1)} `; - }).join('\n'); + }) + .join('\n'); return `(() => {\n${header}\n${body}\n})();`; } -function installRendererScripts(electron: ElectronPort, runtime: { remoteUrl: string }, options: BridgeOptions & { scriptsRoot?: string } = {}) { +function installRendererScripts( + electron: ElectronPort, + runtime: { remoteUrl: string }, + options: BridgeOptions & { scriptsRoot?: string } = {}, +) { if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) { return; } globalThis.__wandRemoteBridgeRendererScriptsInstalled = true; - const scripts = loadRendererScripts(options.panelRoot || path.dirname(__dirname), options.scriptsRoot); + const scripts = loadRendererScripts( + options.panelRoot || path.dirname(__dirname), + options.scriptsRoot, + ); if (scripts.length === 0) { writeInstallLog('info', 'No renderer scripts found.'); return; @@ -74,8 +89,11 @@ function installRendererScripts(electron: ElectronPort, runtime: { remoteUrl: st return; } - contents.executeJavaScript(buildRendererBootstrap(runtime.remoteUrl, scripts), true) - .catch((error: unknown) => writeInstallLog('warn', 'Failed to inject renderer scripts.', error)); + contents + .executeJavaScript(buildRendererBootstrap(runtime.remoteUrl, scripts), true) + .catch((error: unknown) => + writeInstallLog('warn', 'Failed to inject renderer scripts.', error), + ); }; contents.on('dom-ready', inject); @@ -85,7 +103,10 @@ function installRendererScripts(electron: ElectronPort, runtime: { remoteUrl: st } }); - writeInstallLog('info', `Renderer script injection installed (${scripts.map((script: { name: string }) => script.name).join(', ')}).`); + writeInstallLog( + 'info', + `Renderer script injection installed (${scripts.map((script: { name: string }) => script.name).join(', ')}).`, + ); } module.exports = { diff --git a/web-panel/bridge/src/wand/runtime.ts b/web-panel/bridge/src/wand/runtime.ts index a7159b0..af6e9c5 100644 --- a/web-panel/bridge/src/wand/runtime.ts +++ b/web-panel/bridge/src/wand/runtime.ts @@ -7,6 +7,7 @@ const { ensureBridge } = require('../runtime'); const { installRendererScripts } = require('./renderer-scripts'); const { localizeTrainerSnapshot } = require('./trainer-localization'); const { safeString } = require('../utils'); + import type { BridgeOptions, ElectronPort, IpcMainEventPort, WebContentsPort } from '../types'; type RuntimePort = { @@ -22,7 +23,9 @@ type RuntimePort = { declare global { var __wandRemoteBridgeBoundRenderers: Set | undefined; - var __wandRemoteBridgePendingCommandResponses: Map void> | undefined; + var __wandRemoteBridgePendingCommandResponses: + | Map void> + | undefined; var __wandRemoteBridgeActiveRuntime: RuntimePort | undefined; var __wandRemoteBridgeIpcInstalled: boolean | undefined; } @@ -32,12 +35,14 @@ const WEMOD_ACCESS_TOKEN_SCRIPT = function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) { const runtime = ensureBridge(options); - if (!electron || !electron.ipcMain || !electron.app) { + if (!electron?.ipcMain || !electron.app) { throw new Error('Electron main-process API is required to install Wand runtime hooks.'); } - const boundRenderers: Set = globalThis.__wandRemoteBridgeBoundRenderers || new Set(); - const pendingCommandResponses = globalThis.__wandRemoteBridgePendingCommandResponses || new Map(); + const boundRenderers: Set = + globalThis.__wandRemoteBridgeBoundRenderers || new Set(); + const pendingCommandResponses = + globalThis.__wandRemoteBridgePendingCommandResponses || new Map(); globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers; globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses; @@ -79,7 +84,12 @@ function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) return runtime; } -function installIpcHandlers(electron: ElectronPort, activeRuntime: RuntimePort, boundRenderers: Set, pendingCommandResponses: Map void>) { +function installIpcHandlers( + electron: ElectronPort, + activeRuntime: RuntimePort, + boundRenderers: Set, + pendingCommandResponses: Map void>, +) { // Handlers can only be registered once per channel, so they read the runtime through a // mutable global: a reinstall must retarget them instead of leaving them on the old one. globalThis.__wandRemoteBridgeActiveRuntime = activeRuntime; @@ -90,40 +100,57 @@ function installIpcHandlers(electron: ElectronPort, activeRuntime: RuntimePort, globalThis.__wandRemoteBridgeIpcInstalled = true; const runtime = () => globalThis.__wandRemoteBridgeActiveRuntime as RuntimePort; let trainerSnapshotRevision = 0; - electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event: IpcMainEventPort, snapshot: unknown) => { - const revision = ++trainerSnapshotRevision; - runtime().sync(snapshot); - void localizeSnapshot(event?.sender, snapshot).then((localizedSnapshot) => { - if (localizedSnapshot !== snapshot && revision === trainerSnapshotRevision) { - runtime().syncTrainerMeta(localizedSnapshot); + electron.ipcMain.handle( + IPC_CHANNEL.TRAINER_SNAPSHOT, + (event: IpcMainEventPort, snapshot: unknown) => { + const revision = ++trainerSnapshotRevision; + runtime().sync(snapshot); + void localizeSnapshot(event?.sender, snapshot) + .then((localizedSnapshot) => { + if (localizedSnapshot !== snapshot && revision === trainerSnapshotRevision) { + runtime().syncTrainerMeta(localizedSnapshot); + } + }) + .catch((error: unknown) => { + writeInstallLog('warn', 'Failed to localize trainer metadata.', error); + }); + return true; + }, + ); + electron.ipcMain.handle( + IPC_CHANNEL.INSTALLED_APPS, + (_event: IpcMainEventPort, snapshot: unknown) => { + runtime().syncInstalledApps(snapshot); + return true; + }, + ); + electron.ipcMain.handle( + IPC_CHANNEL.GAME_STATUS, + (_event: IpcMainEventPort, snapshot: unknown) => { + runtime().syncGameStatus(snapshot); + return true; + }, + ); + electron.ipcMain.handle( + IPC_CHANNEL.COMMAND_RESPONSE, + (_event: IpcMainEventPort, response: unknown) => { + const requestId = safeString((response as Record)?.requestId); + const resolvePending = requestId ? pendingCommandResponses.get(requestId) : null; + if (!resolvePending) { + return false; } - }).catch((error: unknown) => { - writeInstallLog('warn', 'Failed to localize trainer metadata.', error); - }); - return true; - }); - electron.ipcMain.handle(IPC_CHANNEL.INSTALLED_APPS, (_event: IpcMainEventPort, snapshot: unknown) => { - runtime().syncInstalledApps(snapshot); - return true; - }); - electron.ipcMain.handle(IPC_CHANNEL.GAME_STATUS, (_event: IpcMainEventPort, snapshot: unknown) => { - runtime().syncGameStatus(snapshot); - return true; - }); - electron.ipcMain.handle(IPC_CHANNEL.COMMAND_RESPONSE, (_event: IpcMainEventPort, response: unknown) => { - const requestId = safeString((response as Record)?.requestId); - const resolvePending = requestId ? pendingCommandResponses.get(requestId) : null; - if (!resolvePending) { - return false; - } - resolvePending(response); - return true; - }); - electron.ipcMain.handle(IPC_CHANNEL.VALUE_CHANGED, (_event: IpcMainEventPort, change: unknown) => { - runtime().valueChanged(change); - return true; - }); + resolvePending(response); + return true; + }, + ); + electron.ipcMain.handle( + IPC_CHANNEL.VALUE_CHANGED, + (_event: IpcMainEventPort, change: unknown) => { + runtime().valueChanged(change); + return true; + }, + ); electron.ipcMain.handle(IPC_CHANNEL.BIND_HANDLER, (event: IpcMainEventPort) => { const sender = event?.sender; if (sender) { @@ -172,7 +199,11 @@ async function readWemodAccessToken(sender: WebContentsPort | undefined) { } } -function dispatchRemoteCommandToRenderer(sender: WebContentsPort, request: unknown, pendingCommandResponses: Map void>) { +function dispatchRemoteCommandToRenderer( + sender: WebContentsPort, + request: unknown, + pendingCommandResponses: Map void>, +) { return new Promise((resolve, reject) => { const requestId = `remote_command_${typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : Date.now().toString(36)}`; const timer = setTimeout(() => { diff --git a/web-panel/bridge/src/wand/trainer-localization.test.ts b/web-panel/bridge/src/wand/trainer-localization.test.ts index 452f771..426b71b 100644 --- a/web-panel/bridge/src/wand/trainer-localization.test.ts +++ b/web-panel/bridge/src/wand/trainer-localization.test.ts @@ -1,105 +1,106 @@ -import { EventEmitter } from "node:events" -import { afterEach, describe, expect, it, vi } from "vitest" +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { localizeTrainerSnapshot } from "./trainer-localization" +import { localizeTrainerSnapshot } from './trainer-localization'; // The function takes an unknown snapshot and returns it unchanged when it cannot be // localized, so tests narrow the result to read into it. type LocalizedSnapshot = { - metadata: { info: { blueprint: { cheats: { name: string; description: string }[] } } } -} -const asLocalized = (value: unknown) => value as LocalizedSnapshot + metadata: { info: { blueprint: { cheats: { name: string; description: string }[] } } }; +}; +const asLocalized = (value: unknown) => value as LocalizedSnapshot; -afterEach(() => vi.restoreAllMocks()) +afterEach(() => vi.restoreAllMocks()); -describe("trainer localization", () => { - it("uses the bearer token only inside the bridge and returns localized metadata", async () => { - const snapshot = rawTrainerSnapshot() - const loadStrings = vi.fn(async (request) => { - expect(request).toMatchObject({ - accessToken: "wand-secret", - gameId: "game", - gameVersion: "1.0", - language: "de-DE", - }) - return { cheat_name: "Unverwundbar", cheat_description: "Kein Schaden" } - }) +describe('trainer localization', () => { + it('uses the bearer token only inside the bridge and returns localized metadata', async () => { + const snapshot = rawTrainerSnapshot(); + const loadStrings = vi.fn(async (request) => { + expect(request).toMatchObject({ + accessToken: 'wand-secret', + gameId: 'game', + gameVersion: '1.0', + language: 'de-DE', + }); + return { cheat_name: 'Unverwundbar', cheat_description: 'Kein Schaden' }; + }); - const localized = await localizeTrainerSnapshot( - snapshot, - "wand-secret", - loadStrings - ) + const localized = await localizeTrainerSnapshot(snapshot, 'wand-secret', loadStrings); - expect(localized).not.toBe(snapshot) - expect(asLocalized(localized).metadata.info.blueprint.cheats[0]).toMatchObject({ - name: "Unverwundbar", - description: "Kein Schaden", - }) - expect(JSON.stringify(localized)).not.toContain("wand-secret") - expect(snapshot.metadata.info.blueprint.cheats[0].name).toBe("cheat_name") - }) + expect(localized).not.toBe(snapshot); + expect(asLocalized(localized).metadata.info.blueprint.cheats[0]).toMatchObject({ + name: 'Unverwundbar', + description: 'Kein Schaden', + }); + expect(JSON.stringify(localized)).not.toContain('wand-secret'); + expect(snapshot.metadata.info.blueprint.cheats[0].name).toBe('cheat_name'); + }); - it("deduplicates in-flight translation requests and caches successful strings", async () => { - const https = require("node:https") - const get = vi.spyOn(https, "get").mockImplementation((_url, _options, onResponse) => { - const request = new EventEmitter() as any - request.setTimeout = vi.fn() - request.destroy = vi.fn() + it('deduplicates in-flight translation requests and caches successful strings', async () => { + const https = require('node:https'); + const get = vi.spyOn(https, 'get').mockImplementation((_url, _options, onResponse) => { + const request = new EventEmitter() as any; + request.setTimeout = vi.fn(); + request.destroy = vi.fn(); - queueMicrotask(() => { - const response = new EventEmitter() as any - response.statusCode = 200 - response.resume = vi.fn() - response.setEncoding = vi.fn() - const respond = onResponse as (response: any) => void - respond(response) - response.emit("data", JSON.stringify({ - i18n: { strings: { cheat_name: "Cached name" } }, - })) - response.emit("end") - }) + queueMicrotask(() => { + const response = new EventEmitter() as any; + response.statusCode = 200; + response.resume = vi.fn(); + response.setEncoding = vi.fn(); + const respond = onResponse as (response: any) => void; + respond(response); + response.emit( + 'data', + JSON.stringify({ + i18n: { strings: { cheat_name: 'Cached name' } }, + }), + ); + response.emit('end'); + }); - return request - }) + return request; + }); - const snapshot = { - ...rawTrainerSnapshot(), - trainerInfo: { gameId: "cache-test-game" }, - } - const pending = [ - localizeTrainerSnapshot(snapshot, "cache-test-token"), - localizeTrainerSnapshot(snapshot, "cache-test-token"), - ] - const localized = await Promise.all(pending) - const cached = await localizeTrainerSnapshot(snapshot, "cache-test-token") + const snapshot = { + ...rawTrainerSnapshot(), + trainerInfo: { gameId: 'cache-test-game' }, + }; + const pending = [ + localizeTrainerSnapshot(snapshot, 'cache-test-token'), + localizeTrainerSnapshot(snapshot, 'cache-test-token'), + ]; + const localized = await Promise.all(pending); + const cached = await localizeTrainerSnapshot(snapshot, 'cache-test-token'); - expect(get).toHaveBeenCalledTimes(1) - expect(asLocalized(localized[0]).metadata.info.blueprint.cheats[0].name).toBe("Cached name") - expect(asLocalized(cached).metadata.info.blueprint.cheats[0].name).toBe("Cached name") - }) -}) + expect(get).toHaveBeenCalledTimes(1); + expect(asLocalized(localized[0]).metadata.info.blueprint.cheats[0].name).toBe( + 'Cached name', + ); + expect(asLocalized(cached).metadata.info.blueprint.cheats[0].name).toBe('Cached name'); + }); +}); function rawTrainerSnapshot() { - return { - instanceId: "instance", - trainerId: "trainer", - trainerInfo: { gameId: "game" }, - gameVersion: "1.0", - language: "de-DE", - metadata: { - info: { - blueprint: { - cheats: [ - { - target: "god", - type: "toggle", - name: "cheat_name", - description: "cheat_description", + return { + instanceId: 'instance', + trainerId: 'trainer', + trainerInfo: { gameId: 'game' }, + gameVersion: '1.0', + language: 'de-DE', + metadata: { + info: { + blueprint: { + cheats: [ + { + target: 'god', + type: 'toggle', + name: 'cheat_name', + description: 'cheat_description', + }, + ], + }, }, - ], }, - }, - }, - } + }; } diff --git a/web-panel/bridge/src/wand/trainer-localization.ts b/web-panel/bridge/src/wand/trainer-localization.ts index 4b69bd1..8a3271d 100644 --- a/web-panel/bridge/src/wand/trainer-localization.ts +++ b/web-panel/bridge/src/wand/trainer-localization.ts @@ -1,224 +1,230 @@ -const https = require("node:https") +const https = require('node:https'); -const WEMOD_TRAINER_ENDPOINT = "https://api.wemod.com/v3/games" -const RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024 -const REQUEST_TIMEOUT_MS = 5000 -let cachedRequestKey = "" -let cachedStrings: Record | null = null -let inFlightRequestKey = "" -let inFlightRequest: Promise | null> | null = null +const WEMOD_TRAINER_ENDPOINT = 'https://api.wemod.com/v3/games'; +const RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024; +const REQUEST_TIMEOUT_MS = 5000; +let cachedRequestKey = ''; +let cachedStrings: Record | null = null; +let inFlightRequestKey = ''; +let inFlightRequest: Promise | null> | null = null; type FetchTrainerStringsRequest = { - accessToken: string; - gameId: string; - gameVersion: string; - language: string; -} + accessToken: string; + gameId: string; + gameVersion: string; + language: string; +}; type MinimalResponse = { - statusCode?: number; - resume(): void; - setEncoding(encoding: string): void; - on(event: 'data', listener: (chunk: Buffer | string) => void): MinimalResponse; - on(event: 'end', listener: () => void): MinimalResponse; - on(event: 'error', listener: (error: unknown) => void): MinimalResponse; + statusCode?: number; + resume(): void; + setEncoding(encoding: string): void; + on(event: 'data', listener: (chunk: Buffer | string) => void): MinimalResponse; + on(event: 'end', listener: () => void): MinimalResponse; + on(event: 'error', listener: (error: unknown) => void): MinimalResponse; }; type MinimalRequest = { - destroy(): void; - setTimeout(timeout: number, callback: () => void): MinimalRequest; - on(event: 'error', listener: (error: unknown) => void): MinimalRequest; + destroy(): void; + setTimeout(timeout: number, callback: () => void): MinimalRequest; + on(event: 'error', listener: (error: unknown) => void): MinimalRequest; }; export async function localizeTrainerSnapshot( - rawSnapshot: unknown, - accessToken: string | null, - loadStrings = fetchTrainerStrings + rawSnapshot: unknown, + accessToken: string | null, + loadStrings = fetchTrainerStrings, ) { - const request = buildTrainerRequest(rawSnapshot, accessToken) - if (!request) { - return rawSnapshot - } + const request = buildTrainerRequest(rawSnapshot, accessToken); + if (!request) { + return rawSnapshot; + } - let strings: Record | null = null - try { - strings = await loadStrings(request) - } catch { - return rawSnapshot - } - if (!strings) { - return rawSnapshot - } + let strings: Record | null = null; + try { + strings = await loadStrings(request); + } catch { + return rawSnapshot; + } + if (!strings) { + return rawSnapshot; + } - const snap = rawSnapshot as Record - const metadata = snap.metadata as Record | undefined - const info = metadata?.info as Record | undefined - const blueprint = info?.blueprint as Record | undefined - if (!Array.isArray(blueprint?.cheats)) { - return rawSnapshot - } + const snap = rawSnapshot as Record; + const metadata = snap.metadata as Record | undefined; + const info = metadata?.info as Record | undefined; + const blueprint = info?.blueprint as Record | undefined; + if (!Array.isArray(blueprint?.cheats)) { + return rawSnapshot; + } - return { - ...snap, - metadata: { - ...metadata, - info: { - ...info, - blueprint: { - ...blueprint, - cheats: blueprint.cheats.map((cheat: unknown) => - localizeCheat(cheat, strings as Record) - ), + return { + ...snap, + metadata: { + ...metadata, + info: { + ...info, + blueprint: { + ...blueprint, + cheats: blueprint.cheats.map((cheat: unknown) => + localizeCheat(cheat, strings as Record), + ), + }, + }, }, - }, - }, - } + }; } -function buildTrainerRequest(rawSnapshot: unknown, accessToken: string | null): FetchTrainerStringsRequest | null { - if (!accessToken || !rawSnapshot || typeof rawSnapshot !== "object") { - return null - } +function buildTrainerRequest( + rawSnapshot: unknown, + accessToken: string | null, +): FetchTrainerStringsRequest | null { + if (!accessToken || !rawSnapshot || typeof rawSnapshot !== 'object') { + return null; + } - const snap = rawSnapshot as Record - const trainerInfo = snap.trainerInfo as Record | undefined - const metadata = snap.metadata as Record | undefined - const info = metadata?.info as Record | undefined + const snap = rawSnapshot as Record; + const trainerInfo = snap.trainerInfo as Record | undefined; + const metadata = snap.metadata as Record | undefined; + const info = metadata?.info as Record | undefined; - const gameId = stringValue( - trainerInfo?.gameId || info?.gameId - ) - if (!gameId) { - return null - } + const gameId = stringValue(trainerInfo?.gameId || info?.gameId); + if (!gameId) { + return null; + } - return { + return { + accessToken, + gameId, + gameVersion: stringValue(snap.gameVersion), + language: stringValue(snap.language), + }; +} + +function fetchTrainerStrings({ accessToken, gameId, - gameVersion: stringValue(snap.gameVersion), - language: stringValue(snap.language), - } -} + gameVersion, + language, +}: FetchTrainerStringsRequest) { + const requestKey = [accessToken, gameId, gameVersion, language].join('\0'); + if (requestKey === cachedRequestKey) { + return Promise.resolve(cachedStrings); + } + if (requestKey === inFlightRequestKey && inFlightRequest) { + return inFlightRequest; + } -function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }: FetchTrainerStringsRequest) { - const requestKey = [accessToken, gameId, gameVersion, language].join("\0") - if (requestKey === cachedRequestKey) { - return Promise.resolve(cachedStrings) - } - if (requestKey === inFlightRequestKey && inFlightRequest) { - return inFlightRequest - } + const url = new URL(`${WEMOD_TRAINER_ENDPOINT}/${encodeURIComponent(gameId)}/trainer`); + if (gameVersion) url.searchParams.set('gameVersions', gameVersion); + if (language) url.searchParams.set('locale', language); - const url = new URL( - `${WEMOD_TRAINER_ENDPOINT}/${encodeURIComponent(gameId)}/trainer` - ) - if (gameVersion) url.searchParams.set("gameVersions", gameVersion) - if (language) url.searchParams.set("locale", language) + const request = requestJson(url, accessToken) + .then((payload: unknown) => + normalizeStrings((payload as { i18n?: { strings?: unknown } })?.i18n?.strings), + ) + .then((strings) => { + if (strings) { + cachedRequestKey = requestKey; + cachedStrings = strings; + } + return strings; + }) + .finally(() => { + if (inFlightRequestKey === requestKey) { + inFlightRequestKey = ''; + inFlightRequest = null; + } + }); - const request = requestJson(url, accessToken) - .then((payload: unknown) => normalizeStrings((payload as { i18n?: { strings?: unknown } })?.i18n?.strings)) - .then((strings) => { - if (strings) { - cachedRequestKey = requestKey - cachedStrings = strings - } - return strings - }) - .finally(() => { - if (inFlightRequestKey === requestKey) { - inFlightRequestKey = "" - inFlightRequest = null - } - }) - - inFlightRequestKey = requestKey - inFlightRequest = request - return request + inFlightRequestKey = requestKey; + inFlightRequest = request; + return request; } function requestJson(url: URL, accessToken: string): Promise { - return new Promise((resolve) => { - let settled = false - const finish = (value: unknown) => { - if (settled) return - settled = true - resolve(value) - } + return new Promise((resolve) => { + let settled = false; + const finish = (value: unknown) => { + if (settled) return; + settled = true; + resolve(value); + }; - const request = https.get( - url, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }, - (response: MinimalResponse) => { - if (response.statusCode !== 200) { - response.resume() - finish(null) - return - } + const request = https.get( + url, + { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }, + (response: MinimalResponse) => { + if (response.statusCode !== 200) { + response.resume(); + finish(null); + return; + } - let body = "" - let receivedBytes = 0 - response.setEncoding("utf8") - response.on("data", (chunk: Buffer | string) => { - receivedBytes += Buffer.byteLength(chunk) - if (receivedBytes > RESPONSE_LIMIT_BYTES) { - request.destroy() - finish(null) - return - } - body += chunk - }) - response.on("end", () => { - try { - finish(JSON.parse(body)) - } catch { - finish(null) - } - }) - response.on("error", () => { - finish(null) - }) - } - ) as MinimalRequest + let body = ''; + let receivedBytes = 0; + response.setEncoding('utf8'); + response.on('data', (chunk: Buffer | string) => { + receivedBytes += Buffer.byteLength(chunk); + if (receivedBytes > RESPONSE_LIMIT_BYTES) { + request.destroy(); + finish(null); + return; + } + body += chunk; + }); + response.on('end', () => { + try { + finish(JSON.parse(body)); + } catch { + finish(null); + } + }); + response.on('error', () => { + finish(null); + }); + }, + ) as MinimalRequest; - request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy()) - request.on("error", () => finish(null)) - }) + request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy()); + request.on('error', () => finish(null)); + }); } function normalizeStrings(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null - } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } - const strings = Object.fromEntries( - Object.entries(value).filter((entry) => typeof entry[1] === "string") - ) as Record - return Object.keys(strings).length > 0 ? strings : null + const strings = Object.fromEntries( + Object.entries(value).filter((entry) => typeof entry[1] === 'string'), + ) as Record; + return Object.keys(strings).length > 0 ? strings : null; } function localizeCheat(cheat: unknown, strings: Record) { - if (!cheat || typeof cheat !== "object") { - return cheat - } + if (!cheat || typeof cheat !== 'object') { + return cheat; + } - const c = cheat as Record - return { - ...c, - name: translate(c.name, strings), - description: translate(c.description, strings), - instructions: translate(c.instructions, strings), - } + const c = cheat as Record; + return { + ...c, + name: translate(c.name, strings), + description: translate(c.description, strings), + instructions: translate(c.instructions, strings), + }; } function translate(value: unknown, strings: Record) { - return typeof value === "string" ? (strings[value] ?? value) : value + return typeof value === 'string' ? (strings[value] ?? value) : value; } function stringValue(value: unknown) { - return typeof value === "string" && value ? value : "" + return typeof value === 'string' && value ? value : ''; } diff --git a/web-panel/bridge/src/websocket-codec.ts b/web-panel/bridge/src/websocket-codec.ts index 6d61e7c..98e9416 100644 --- a/web-panel/bridge/src/websocket-codec.ts +++ b/web-panel/bridge/src/websocket-codec.ts @@ -10,7 +10,9 @@ const WS_CLOSE_NORMAL = 1000; import type { BridgeClient } from './types'; function frameTooLarge() { - const error = new RangeError(`WebSocket frames are limited to ${MAX_WS_FRAME_BYTES} bytes.`) as RangeError & { code: string }; + const error = new RangeError( + `WebSocket frames are limited to ${MAX_WS_FRAME_BYTES} bytes.`, + ) as RangeError & { code: string }; error.code = FRAME_TOO_LARGE_ERROR; return error; } @@ -41,7 +43,12 @@ function makeFrame(opcode: number, payload: Buffer | string) { } if (source.length < 65536) { - const prefix = Buffer.from([header[0], 126, (source.length >> 8) & 0xff, source.length & 0xff]); + const prefix = Buffer.from([ + header[0], + 126, + (source.length >> 8) & 0xff, + source.length & 0xff, + ]); return Buffer.concat([prefix, source]); } @@ -59,7 +66,12 @@ function sendText(client: BridgeClient, text: string) { } } -function sendJson(client: BridgeClient, type: string, payload: unknown, requestId: string | number | null = null) { +function sendJson( + client: BridgeClient, + type: string, + payload: unknown, + requestId: string | number | null = null, +) { sendText(client, jsonMessage(type, payload, requestId)); } @@ -157,7 +169,10 @@ function parseFrame(buffer: Buffer) { } function createAcceptKey(key: string) { - return crypto.createHash('sha1').update(key + WS_GUID).digest('base64'); + return crypto + .createHash('sha1') + .update(key + WS_GUID) + .digest('base64'); } module.exports = { diff --git a/web-panel/bridge/tsconfig.json b/web-panel/bridge/tsconfig.json index 9e8a23b..15209cb 100644 --- a/web-panel/bridge/tsconfig.json +++ b/web-panel/bridge/tsconfig.json @@ -1,15 +1,15 @@ { - "extends": "../tsconfig.json", - "compilerOptions": { - "lib": ["ES2022"], - // Bundled by esbuild (bridge/build.mjs), not resolved by Node's ESM loader, so - // type-check under the same model the bundler uses. - "module": "ESNext", - "moduleResolution": "Bundler", - // The injected renderer scripts are plain .js by design; tests import them directly. - "allowJs": true, - "checkJs": false, - "types": ["node"] - }, - "include": ["src"] + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": ["ES2022"], + // Bundled by esbuild (bridge/build.mjs), not resolved by Node's ESM loader, so + // type-check under the same model the bundler uses. + "module": "ESNext", + "moduleResolution": "Bundler", + // The injected renderer scripts are plain .js by design; tests import them directly. + "allowJs": true, + "checkJs": false, + "types": ["node"] + }, + "include": ["src"] } diff --git a/web-panel/components.json b/web-panel/components.json index 00d175c..de06ea8 100644 --- a/web-panel/components.json +++ b/web-panel/components.json @@ -1,25 +1,25 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-mira", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/index.css", - "baseColor": "mist", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "tabler", - "rtl": false, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-mira", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "mist", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "tabler", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} } diff --git a/web-panel/eslint.config.js b/web-panel/eslint.config.js deleted file mode 100644 index 3b47422..0000000 --- a/web-panel/eslint.config.js +++ /dev/null @@ -1,34 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist', 'src/locales']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - rules: { - 'react-hooks/set-state-in-effect': 'off', - }, - }, - { - files: ['bridge/src/**/*.{ts,tsx}'], - rules: { - '@typescript-eslint/no-require-imports': 'off', - '@typescript-eslint/no-explicit-any': 'off', - 'no-empty': 'off', - }, - }, -]) diff --git a/web-panel/lingui.config.ts b/web-panel/lingui.config.ts index d266e80..9cfafea 100644 --- a/web-panel/lingui.config.ts +++ b/web-panel/lingui.config.ts @@ -2,13 +2,13 @@ import { defineConfig } from '@lingui/cli'; import { formatter } from '@lingui/format-po'; export default defineConfig({ - sourceLocale: 'en-US', - locales: ['en-US', 'ru-RU', 'de-DE', 'fr-FR', 'es-ES', 'zh-CN'], - catalogs: [ - { - path: '/src/locales/{locale}/messages', - include: ['src'], - }, - ], - format: formatter({ lineNumbers: false }), + sourceLocale: 'en-US', + locales: ['en-US', 'ru-RU', 'de-DE', 'fr-FR', 'es-ES', 'zh-CN'], + catalogs: [ + { + path: '/src/locales/{locale}/messages', + include: ['src'], + }, + ], + format: formatter({ lineNumbers: false }), }); diff --git a/web-panel/package.json b/web-panel/package.json index 1487580..6893eab 100644 --- a/web-panel/package.json +++ b/web-panel/package.json @@ -1,57 +1,52 @@ { - "name": "wand-web-panel", - "private": true, - "version": "0.1.0", - "packageManager": "pnpm@10.17.0", - "type": "module", - "scripts": { - "dev": "vite", - "dev:host": "vite --host 0.0.0.0", - "build": "pnpm run typecheck && vite build && pnpm run build:bridge && pnpm run verify:dist", - "build:bridge": "node ./bridge/build.mjs", - "verify:dist": "node ./scripts/verify-dist.mjs", - "lint": "eslint src protocol bridge/src --max-warnings=0", - "typecheck:web": "tsc --noEmit", - "typecheck:bridge": "tsc -p bridge/tsconfig.json --noEmit", - "typecheck": "pnpm typecheck:web && pnpm typecheck:bridge", - "test": "pnpm build:bridge && vitest run", - "i18n:extract": "lingui extract", - "i18n:compile": "lingui compile", - "preview": "vite preview", - "preview:host": "vite preview --host 0.0.0.0", - "bridge:demo": "node ./bridge/dev-server.mjs" - }, - "dependencies": { - "@lingui/core": "^6.3.0", - "@lingui/react": "^6.3.0", - "preact": "^10.27.2", - "ws": "^8.18.3" - }, - "devDependencies": { - "@eslint/js": "^9.39.4", - "@lingui/babel-plugin-lingui-macro": "^6.3.0", - "@lingui/cli": "^6.3.0", - "@lingui/format-po": "^6.3.0", - "@lingui/vite-plugin": "^6.3.0", - "@tailwindcss/vite": "^4.2.1", - "@testing-library/preact": "^3.2.4", - "@types/node": "^24.12.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@types/ws": "^8.18.1", - "@vitejs/plugin-react": "^5.2.0", - "esbuild": "0.27.7", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^16.5.0", - "jsdom": "^29.1.1", - "prettier": "^3.8.1", - "prettier-plugin-tailwindcss": "^0.7.2", - "tailwindcss": "^4.2.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.61.0", - "vite": "^7.3.2", - "vitest": "^4.1.8" - } + "name": "wand-web-panel", + "private": true, + "version": "0.1.0", + "packageManager": "pnpm@10.17.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:host": "vite --host 0.0.0.0", + "build": "pnpm run typecheck && vite build && pnpm run build:bridge && pnpm run verify:dist", + "build:bridge": "node ./bridge/build.mjs", + "verify:dist": "node ./scripts/verify-dist.mjs", + "lint": "biome check .", + "format": "biome format --write .", + "check": "biome check --write .", + "typecheck:web": "tsc --noEmit", + "typecheck:bridge": "tsc -p bridge/tsconfig.json --noEmit", + "typecheck": "pnpm typecheck:web && pnpm typecheck:bridge", + "test": "pnpm build:bridge && vitest run", + "i18n:extract": "lingui extract", + "i18n:compile": "lingui compile", + "preview": "vite preview", + "preview:host": "vite preview --host 0.0.0.0", + "bridge:demo": "node ./bridge/dev-server.mjs" + }, + "dependencies": { + "@lingui/core": "^6.3.0", + "@lingui/react": "^6.3.0", + "preact": "^10.27.2", + "ws": "^8.18.3" + }, + "devDependencies": { + "@biomejs/biome": "2.5.11", + "@lingui/babel-plugin-lingui-macro": "^6.3.0", + "@lingui/cli": "^6.3.0", + "@lingui/format-po": "^6.3.0", + "@lingui/vite-plugin": "^6.3.0", + "@tailwindcss/vite": "^4.2.1", + "@testing-library/preact": "^3.2.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "^5.2.0", + "esbuild": "0.27.7", + "jsdom": "^29.1.1", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.2", + "vitest": "^4.1.8" + } } diff --git a/web-panel/pnpm-lock.yaml b/web-panel/pnpm-lock.yaml index 49f5c56..7753619 100644 --- a/web-panel/pnpm-lock.yaml +++ b/web-panel/pnpm-lock.yaml @@ -21,9 +21,9 @@ importers: specifier: ^8.18.3 version: 8.20.0 devDependencies: - '@eslint/js': - specifier: ^9.39.4 - version: 9.39.4 + '@biomejs/biome': + specifier: 2.5.11 + version: 2.5.11 '@lingui/babel-plugin-lingui-macro': specifier: ^6.3.0 version: 6.3.0 @@ -60,36 +60,15 @@ importers: esbuild: specifier: 0.27.7 version: 0.27.7 - eslint: - specifier: ^9.39.4 - version: 9.39.4(jiti@2.6.1) - eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.1.1(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react-refresh: - specifier: ^0.5.2 - version: 0.5.2(eslint@9.39.4(jiti@2.6.1)) - globals: - specifier: ^16.5.0 - version: 16.5.0 jsdom: specifier: ^29.1.1 version: 29.1.1 - prettier: - specifier: ^3.8.1 - version: 3.8.3 - prettier-plugin-tailwindcss: - specifier: ^0.7.2 - version: 0.7.4(prettier@3.8.3) tailwindcss: specifier: ^4.2.1 version: 4.2.4 typescript: specifier: ^5.9.3 version: 5.9.3 - typescript-eslint: - specifier: ^8.61.0 - version: 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.3.2 version: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) @@ -201,6 +180,59 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.5.11': + resolution: {integrity: sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.11': + resolution: {integrity: sha512-6SGZxoKbXvUjMn1t6A98HqWISPnGNbYs0R/Rt2JarmXBSev+lva4QxUMWEBX9lX1Wo1XTJ78uk5xVDtG58SRZg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.11': + resolution: {integrity: sha512-nYkXY7tLBEgnGbYapDKAyKzgt44ZEyG+AKalvTXtCWKYgepI9dw327q+cVgedxm+Udi1ZzHKUyZrIusHi/KQbw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.11': + resolution: {integrity: sha512-qhyZUMyCbWYFV2bAwRNVvfMVZ+hv7WYl6mossGrxC+uiQQXhvsuWWU8zz6jYX0mChZd9MgQZbm4vozTmG/5iGw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.5.11': + resolution: {integrity: sha512-3PVLSTD9RR73rvVPt5G3T1gc+ycggWEGfTD7RvzzbtcDPD27NxgxBbAFfpm7DXJKW6VLHWE1lLMGvFt2Qxjcow==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.5.11': + resolution: {integrity: sha512-oRRlrchG5EfrEL/EmtT1qUjSNHk3/5LGeZhQqADBBAJF1b1ET6964xEKe7aGlGARzDfza8H/seEsFJl7S6Ql9w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.5.11': + resolution: {integrity: sha512-JOytptlsgM33B2MMFUg8iBrb4IKpbD5JnJrSeYiaFEeAj4vuXx0iQSQZ4qK7sqyMtfjZxxPdNdMZZVL4y/mFyA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.5.11': + resolution: {integrity: sha512-e49E6K9hzH/ohJNx8Y26mY8HaV4I4ZViIeoqhKsmoXLKHhQnMeBAVqCgsGf2Wa3lXlS7RkporDXMHHWkzvZzFw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.11': + resolution: {integrity: sha512-QSQr/KjOgXA7OzXJUWS+oguKyAZ3Q0l/lnlDGbu397eKo83atuWUjBPJrsqbKNF6CARGw8XXJLGzpHC8Ryhd4Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -557,44 +589,6 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -604,26 +598,6 @@ packages: '@noble/hashes': optional: true - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -984,9 +958,6 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} @@ -1007,65 +978,6 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.61.0': - resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.61.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.61.0': - resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.61.0': - resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.61.0': - resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.61.0': - resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.61.0': - resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/types@8.61.0': - resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.61.0': - resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.61.0': - resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.61.0': - resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1101,19 +1013,6 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1130,9 +1029,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-query@5.1.3: resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} @@ -1148,13 +1044,6 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.25: resolution: {integrity: sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==} engines: {node: '>=6.0.0'} @@ -1163,13 +1052,6 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} - braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1191,10 +1073,6 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -1245,16 +1123,9 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -1282,9 +1153,6 @@ packages: resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} engines: {node: '>= 0.4'} - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -1350,83 +1218,13 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-plugin-react-hooks@7.1.1: - resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} - engines: {node: '>=18'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - - eslint-plugin-react-refresh@0.5.2: - resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} - peerDependencies: - eslint: ^9 || ^10 - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1436,25 +1234,10 @@ packages: picomatch: optional: true - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -1486,18 +1269,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globals@16.5.0: - resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} - engines: {node: '>=18'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1528,32 +1299,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} - - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1582,18 +1331,10 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -1648,9 +1389,6 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1669,10 +1407,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -1687,31 +1421,15 @@ packages: engines: {node: '>=6'} hasBin: true - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1786,13 +1504,6 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - log-symbols@7.0.1: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} @@ -1826,13 +1537,6 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - moo@0.5.3: resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} @@ -1844,9 +1548,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} @@ -1878,37 +1579,13 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - ora@9.4.0: resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} engines: {node: '>=20'} - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1938,70 +1615,6 @@ packages: preact@10.29.1: resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier-plugin-tailwindcss@0.7.4: - resolution: {integrity: sha512-UKii4RjY05SNt/WQi6/NcOn/LsT0/ILLXsxygjbRg5/YZelsSu5jTqorYHPDGq4nZy5q5hpCu+XdGZ1xaJEQgw==} - engines: {node: '>=20.19'} - peerDependencies: - '@ianvs/prettier-plugin-sort-imports': '*' - '@prettier/plugin-hermes': '*' - '@prettier/plugin-oxc': '*' - '@prettier/plugin-pug': '*' - '@shopify/prettier-plugin-liquid': '*' - '@trivago/prettier-plugin-sort-imports': '*' - '@zackad/prettier-plugin-twig': '*' - prettier: ^3.0 - prettier-plugin-astro: '*' - prettier-plugin-css-order: '*' - prettier-plugin-jsdoc: '*' - prettier-plugin-marko: '*' - prettier-plugin-multiline-arrays: '*' - prettier-plugin-organize-attributes: '*' - prettier-plugin-organize-imports: '*' - prettier-plugin-sort-imports: '*' - prettier-plugin-svelte: '*' - peerDependenciesMeta: - '@ianvs/prettier-plugin-sort-imports': - optional: true - '@prettier/plugin-hermes': - optional: true - '@prettier/plugin-oxc': - optional: true - '@prettier/plugin-pug': - optional: true - '@shopify/prettier-plugin-liquid': - optional: true - '@trivago/prettier-plugin-sort-imports': - optional: true - '@zackad/prettier-plugin-twig': - optional: true - prettier-plugin-astro: - optional: true - prettier-plugin-css-order: - optional: true - prettier-plugin-jsdoc: - optional: true - prettier-plugin-marko: - optional: true - prettier-plugin-multiline-arrays: - optional: true - prettier-plugin-organize-attributes: - optional: true - prettier-plugin-organize-imports: - optional: true - prettier-plugin-sort-imports: - optional: true - prettier-plugin-svelte: - optional: true - - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} - engines: {node: '>=14'} - hasBin: true - pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2045,10 +1658,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -2070,11 +1679,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} - engines: {node: '>=10'} - hasBin: true - set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2083,14 +1687,6 @@ packages: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} engines: {node: '>= 0.4'} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -2152,10 +1748,6 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2208,23 +1800,6 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript-eslint@8.61.0: - resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2243,9 +1818,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - vite@7.3.2: resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2355,20 +1927,11 @@ packages: resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -2391,23 +1954,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - - zod@4.4.1: - resolution: {integrity: sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==} - snapshots: '@asamuzakjp/css-color@5.1.11': @@ -2544,6 +2094,41 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/biome@2.5.11': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.11 + '@biomejs/cli-darwin-x64': 2.5.11 + '@biomejs/cli-linux-arm64': 2.5.11 + '@biomejs/cli-linux-arm64-musl': 2.5.11 + '@biomejs/cli-linux-x64': 2.5.11 + '@biomejs/cli-linux-x64-musl': 2.5.11 + '@biomejs/cli-win32-arm64': 2.5.11 + '@biomejs/cli-win32-x64': 2.5.11 + + '@biomejs/cli-darwin-arm64@2.5.11': + optional: true + + '@biomejs/cli-darwin-x64@2.5.11': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.11': + optional: true + + '@biomejs/cli-linux-arm64@2.5.11': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.11': + optional: true + + '@biomejs/cli-linux-x64@2.5.11': + optional: true + + '@biomejs/cli-win32-arm64@2.5.11': + optional: true + + '@biomejs/cli-win32-x64@2.5.11': + optional: true + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -2731,70 +2316,8 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': - dependencies: - eslint: 9.39.4(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.2': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.5': - dependencies: - ajv: 6.15.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.4': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - '@exodus/bytes@1.15.1': {} - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.10 @@ -3125,8 +2648,6 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 - '@types/json-schema@7.0.15': {} - '@types/node@24.12.2': dependencies: undici-types: 7.16.0 @@ -3149,97 +2670,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.61.0 - eslint: 9.39.4(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.61.0 - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.61.0': - dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 - - '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.61.0': {} - - '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.4 - tinyglobby: 0.2.16 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.61.0': - dependencies: - '@typescript-eslint/types': 8.61.0 - eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@babel/core': 7.29.0 @@ -3293,19 +2723,6 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -3316,8 +2733,6 @@ snapshots: ansi-styles@5.2.0: {} - argparse@2.0.1: {} - aria-query@5.1.3: dependencies: deep-equal: 2.2.3 @@ -3333,25 +2748,12 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - balanced-match@1.0.2: {} - - balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.25: {} bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@5.0.6: - dependencies: - balanced-match: 4.0.4 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3381,8 +2783,6 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - callsites@3.1.0: {} - camelcase@6.3.0: {} caniuse-lite@1.0.30001791: {} @@ -3422,16 +2822,8 @@ snapshots: commander@14.0.3: {} - concat-map@0.0.1: {} - convert-source-map@2.0.0: {} - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -3473,8 +2865,6 @@ snapshots: which-collection: 1.0.2 which-typed-array: 1.1.22 - deep-is@0.1.4: {} - define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -3590,129 +2980,20 @@ snapshots: escalade@3.2.0: {} - escape-string-regexp@4.0.0: {} - - eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.3 - eslint: 9.39.4(jiti@2.6.1) - hermes-parser: 0.25.1 - zod: 4.4.1 - zod-validation-error: 4.0.2(zod@4.4.1) - transitivePeerDependencies: - - supports-color - - eslint-plugin-react-refresh@0.5.2(eslint@9.39.4(jiti@2.6.1)): - dependencies: - eslint: 9.39.4(jiti@2.6.1) - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@9.39.4(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.15.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 - esutils@2.0.3: {} - expect-type@1.3.0: {} - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -3746,14 +3027,6 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@14.0.0: {} - - globals@16.5.0: {} - gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -3776,29 +3049,12 @@ snapshots: dependencies: function-bind: 1.1.2 - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: - dependencies: - hermes-estree: 0.25.1 - html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 transitivePeerDependencies: - '@noble/hashes' - ignore@5.3.2: {} - - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3832,14 +3088,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-extglob@2.1.1: {} - is-fullwidth-code-point@3.0.0: {} - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - is-interactive@2.0.0: {} is-map@2.0.3: {} @@ -3888,8 +3138,6 @@ snapshots: isarray@2.0.5: {} - isexe@2.0.0: {} - jest-get-type@29.6.3: {} jest-validate@29.7.0: @@ -3907,10 +3155,6 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - jsdom@29.1.1: dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -3939,25 +3183,10 @@ snapshots: jsesc@3.1.0: {} - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - json5@2.2.3: {} - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - leven@3.1.0: {} - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - lightningcss-android-arm64@1.32.0: optional: true @@ -4009,12 +3238,6 @@ snapshots: lilconfig@3.1.3: {} - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - log-symbols@7.0.1: dependencies: is-unicode-supported: 2.1.0 @@ -4043,22 +3266,12 @@ snapshots: mimic-function@5.0.1: {} - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 - moo@0.5.3: {} ms@2.1.3: {} nanoid@3.3.12: {} - natural-compare@1.4.0: {} - node-releases@2.0.38: {} normalize-path@3.0.0: {} @@ -4087,15 +3300,6 @@ snapshots: dependencies: mimic-function: 5.0.1 - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - ora@9.4.0: dependencies: chalk: 5.6.2 @@ -4107,26 +3311,10 @@ snapshots: stdin-discarder: 0.3.2 string-width: 8.2.1 - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - parse5@8.0.1: dependencies: entities: 8.0.0 - path-exists@4.0.0: {} - - path-key@3.1.1: {} - pathe@2.0.3: {} picocolors@1.1.1: {} @@ -4147,14 +3335,6 @@ snapshots: preact@10.29.1: {} - prelude-ls@1.2.1: {} - - prettier-plugin-tailwindcss@0.7.4(prettier@3.8.3): - dependencies: - prettier: 3.8.3 - - prettier@3.8.3: {} - pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -4194,8 +3374,6 @@ snapshots: require-from-string@2.0.2: {} - resolve-from@4.0.0: {} - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -4244,8 +3422,6 @@ snapshots: semver@6.3.1: {} - semver@7.8.4: {} - set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -4262,12 +3438,6 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -4334,8 +3504,6 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-json-comments@3.1.1: {} - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -4377,25 +3545,6 @@ snapshots: dependencies: punycode: 2.3.1 - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript-eslint@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - typescript@5.9.3: {} undici-types@7.16.0: {} @@ -4408,10 +3557,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.27.7 @@ -4495,17 +3640,11 @@ snapshots: gopd: 1.2.0 has-tostringtag: 1.0.2 - which@2.0.2: - dependencies: - isexe: 2.0.0 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - word-wrap@1.2.5: {} - ws@8.20.0: {} xml-name-validator@5.0.0: {} @@ -4514,12 +3653,4 @@ snapshots: yallist@3.1.1: {} - yocto-queue@0.1.0: {} - yoctocolors@2.1.2: {} - - zod-validation-error@4.0.2(zod@4.4.1): - dependencies: - zod: 4.4.1 - - zod@4.4.1: {} diff --git a/web-panel/protocol/messages.ts b/web-panel/protocol/messages.ts index 89d7018..382b3fe 100644 --- a/web-panel/protocol/messages.ts +++ b/web-panel/protocol/messages.ts @@ -216,7 +216,10 @@ export type TrainerChangedMessage = MessageEnvelope<'trainer_changed', TrainerCh export type SetValueMessage = MessageEnvelope<'set_value', SetValuePayload>; export type SetValueResultMessage = MessageEnvelope<'set_value_result', SetValueResultPayload>; export type RemoteCommandMessage = MessageEnvelope<'remote_command', RemoteCommandPayload>; -export type RemoteCommandResultMessage = MessageEnvelope<'remote_command_result', RemoteCommandResultPayload>; +export type RemoteCommandResultMessage = MessageEnvelope< + 'remote_command_result', + RemoteCommandResultPayload +>; export type ErrorMessage = MessageEnvelope<'error', ErrorPayload>; export type IncomingMessage = diff --git a/web-panel/protocol/validation.test.ts b/web-panel/protocol/validation.test.ts index 7371617..4a97c9d 100644 --- a/web-panel/protocol/validation.test.ts +++ b/web-panel/protocol/validation.test.ts @@ -4,27 +4,33 @@ import { PROTOCOL_VERSION } from './contract'; import { isIncomingMessage, isOutgoingMessage } from './validation'; describe('web protocol validation', () => { - it('rejects messages with another envelope version', () => { - expect(isIncomingMessage({ - type: 'error', - version: PROTOCOL_VERSION + 1, - requestId: null, - payload: { code: 'bad', message: 'bad' }, - })).toBe(false); - }); + it('rejects messages with another envelope version', () => { + expect( + isIncomingMessage({ + type: 'error', + version: PROTOCOL_VERSION + 1, + requestId: null, + payload: { code: 'bad', message: 'bad' }, + }), + ).toBe(false); + }); - it('rejects incomplete required payloads', () => { - expect(isIncomingMessage({ - type: 'hello_ack', - version: PROTOCOL_VERSION, - requestId: null, - payload: { accepted: true }, - })).toBe(false); - expect(isOutgoingMessage({ - type: 'set_value', - version: PROTOCOL_VERSION, - requestId: 'set', - payload: { target: 'speed', value: 1 }, - })).toBe(false); - }); + it('rejects incomplete required payloads', () => { + expect( + isIncomingMessage({ + type: 'hello_ack', + version: PROTOCOL_VERSION, + requestId: null, + payload: { accepted: true }, + }), + ).toBe(false); + expect( + isOutgoingMessage({ + type: 'set_value', + version: PROTOCOL_VERSION, + requestId: 'set', + payload: { target: 'speed', value: 1 }, + }), + ).toBe(false); + }); }); diff --git a/web-panel/protocol/validation.ts b/web-panel/protocol/validation.ts index 0a532fc..cdd612c 100644 --- a/web-panel/protocol/validation.ts +++ b/web-panel/protocol/validation.ts @@ -2,96 +2,133 @@ import { PROTOCOL_VERSION } from './contract.js'; import type { IncomingMessage, OutgoingMessage } from './messages.js'; const INCOMING_TYPES = new Set([ - 'hello_ack', - 'trainer_meta', - 'trainer_values', - 'game_status', - 'installed_apps', - 'value_changed', - 'trainer_changed', - 'set_value_result', - 'remote_command_result', - 'error', + 'hello_ack', + 'trainer_meta', + 'trainer_values', + 'game_status', + 'installed_apps', + 'value_changed', + 'trainer_changed', + 'set_value_result', + 'remote_command_result', + 'error', ]); const OUTGOING_TYPES = new Set(['hello', 'set_value', 'remote_command']); export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null; } export function isIncomingMessage(value: unknown): value is IncomingMessage { - if (!isEnvelope(value) || !INCOMING_TYPES.has(value.type as IncomingMessage['type'])) { - return false; - } + if (!isEnvelope(value) || !INCOMING_TYPES.has(value.type as IncomingMessage['type'])) { + return false; + } - const payload = value.payload; - switch (value.type) { - case 'hello_ack': - return hasString(payload, 'sessionId') && hasBoolean(payload, 'accepted') && hasString(payload, 'serverVersion') - && hasNumber(payload, 'protocolVersion'); - case 'trainer_meta': - return isRecord(payload.session) && hasString(payload.session, 'instanceId') - && isRecord(payload.trainer) && hasString(payload.trainer, 'trainerId') - && isRecord(payload.schema) && Array.isArray(payload.schema.categories) && Array.isArray(payload.schema.cheats); - case 'trainer_values': - return hasString(payload, 'trainerId') && isRecord(payload.values); - case 'installed_apps': - return hasString(payload, 'instanceId') && hasString(payload, 'updatedAt') && Array.isArray(payload.apps); - case 'game_status': - return hasString(payload, 'instanceId') && hasString(payload, 'updatedAt') - && isRecord(payload.session) && isRecord(payload.trainer); - case 'value_changed': - return hasString(payload, 'trainerId') && hasString(payload, 'target') && 'value' in payload; - case 'trainer_changed': - return hasString(payload, 'trainerId'); - case 'set_value_result': - return hasBoolean(payload, 'ok') && hasString(payload, 'trainerId') && hasString(payload, 'target'); - case 'remote_command_result': - return hasBoolean(payload, 'ok') && (payload.action === 'launch' || payload.action === 'stop'); - case 'error': - return hasString(payload, 'code') && hasString(payload, 'message'); - default: - return false; - } + const payload = value.payload; + switch (value.type) { + case 'hello_ack': + return ( + hasString(payload, 'sessionId') && + hasBoolean(payload, 'accepted') && + hasString(payload, 'serverVersion') && + hasNumber(payload, 'protocolVersion') + ); + case 'trainer_meta': + return ( + isRecord(payload.session) && + hasString(payload.session, 'instanceId') && + isRecord(payload.trainer) && + hasString(payload.trainer, 'trainerId') && + isRecord(payload.schema) && + Array.isArray(payload.schema.categories) && + Array.isArray(payload.schema.cheats) + ); + case 'trainer_values': + return hasString(payload, 'trainerId') && isRecord(payload.values); + case 'installed_apps': + return ( + hasString(payload, 'instanceId') && + hasString(payload, 'updatedAt') && + Array.isArray(payload.apps) + ); + case 'game_status': + return ( + hasString(payload, 'instanceId') && + hasString(payload, 'updatedAt') && + isRecord(payload.session) && + isRecord(payload.trainer) + ); + case 'value_changed': + return ( + hasString(payload, 'trainerId') && + hasString(payload, 'target') && + 'value' in payload + ); + case 'trainer_changed': + return hasString(payload, 'trainerId'); + case 'set_value_result': + return ( + hasBoolean(payload, 'ok') && + hasString(payload, 'trainerId') && + hasString(payload, 'target') + ); + case 'remote_command_result': + return ( + hasBoolean(payload, 'ok') && + (payload.action === 'launch' || payload.action === 'stop') + ); + case 'error': + return hasString(payload, 'code') && hasString(payload, 'message'); + default: + return false; + } } export function isOutgoingMessage(value: unknown): value is OutgoingMessage { - if (!isEnvelope(value) || !OUTGOING_TYPES.has(value.type as OutgoingMessage['type'])) { - return false; - } + if (!isEnvelope(value) || !OUTGOING_TYPES.has(value.type as OutgoingMessage['type'])) { + return false; + } - const payload = value.payload; - if (value.type === 'hello') { - return payload.client === 'mobile-web' && hasString(payload, 'clientVersion') && isRecord(payload.capabilities); - } - if (value.type === 'set_value') { - return hasString(payload, 'trainerId') && hasString(payload, 'target') && 'value' in payload; - } - return (payload.action === 'launch' || payload.action === 'stop'); + const payload = value.payload; + if (value.type === 'hello') { + return ( + payload.client === 'mobile-web' && + hasString(payload, 'clientVersion') && + isRecord(payload.capabilities) + ); + } + if (value.type === 'set_value') { + return ( + hasString(payload, 'trainerId') && hasString(payload, 'target') && 'value' in payload + ); + } + return payload.action === 'launch' || payload.action === 'stop'; } function isEnvelope(value: unknown): value is Record & { - type: string; - version: number; - requestId: string | null; - payload: Record; + type: string; + version: number; + requestId: string | null; + payload: Record; } { - return isRecord(value) - && typeof value.type === 'string' - && value.version === PROTOCOL_VERSION - && (value.requestId === null || typeof value.requestId === 'string') - && isRecord(value.payload); + return ( + isRecord(value) && + typeof value.type === 'string' && + value.version === PROTOCOL_VERSION && + (value.requestId === null || typeof value.requestId === 'string') && + isRecord(value.payload) + ); } function hasString(value: Record, key: string): boolean { - return typeof value[key] === 'string'; + return typeof value[key] === 'string'; } function hasNumber(value: Record, key: string): boolean { - return typeof value[key] === 'number'; + return typeof value[key] === 'number'; } function hasBoolean(value: Record, key: string): boolean { - return typeof value[key] === 'boolean'; + return typeof value[key] === 'boolean'; } diff --git a/web-panel/protocol/web-contract.json b/web-panel/protocol/web-contract.json index ddba8c7..07517f7 100644 --- a/web-panel/protocol/web-contract.json +++ b/web-panel/protocol/web-contract.json @@ -1,13 +1,13 @@ { - "protocolVersion": 1, - "clientVersion": "0.2.0", - "serverVersion": "0.2.0-wand", - "defaultRemoteHost": "0.0.0.0", - "defaultRemotePort": 3223, - "devServerPorts": [4173, 5173], - "portScanRange": 30, - "basePath": "/remote/", - "assetsPath": "/remote/assets/", - "webSocketPath": "/remote/ws", - "healthPath": "/remote/api/health" + "protocolVersion": 1, + "clientVersion": "0.2.0", + "serverVersion": "0.2.0-wand", + "defaultRemoteHost": "0.0.0.0", + "defaultRemotePort": 3223, + "devServerPorts": [4173, 5173], + "portScanRange": 30, + "basePath": "/remote/", + "assetsPath": "/remote/assets/", + "webSocketPath": "/remote/ws", + "healthPath": "/remote/api/health" } diff --git a/web-panel/scripts/custom/remote-access-ping.js b/web-panel/scripts/custom/remote-access-ping.js index 1055980..3afc279 100644 --- a/web-panel/scripts/custom/remote-access-ping.js +++ b/web-panel/scripts/custom/remote-access-ping.js @@ -1,8 +1,8 @@ (() => { - if (document.documentElement.dataset.wandEnhancerCustomScript === 'loaded') { - return; - } + if (document.documentElement.dataset.wandEnhancerCustomScript === 'loaded') { + return; + } - document.documentElement.dataset.wandEnhancerCustomScript = 'loaded'; - console.info('[Wand Enhancer] Custom renderer script loaded'); -})(); \ No newline at end of file + document.documentElement.dataset.wandEnhancerCustomScript = 'loaded'; + console.info('[Wand Enhancer] Custom renderer script loaded'); +})(); diff --git a/web-panel/scripts/default/ipc-logger.js b/web-panel/scripts/default/ipc-logger.js index 8211587..73afa4a 100644 --- a/web-panel/scripts/default/ipc-logger.js +++ b/web-panel/scripts/default/ipc-logger.js @@ -1,46 +1,67 @@ (function installIpcLogger(WandEnhancer) { - if (globalThis.__wandIpcLoggerInstalled) { - return; - } - - globalThis.__wandIpcLoggerInstalled = true; - - try { - const electron = window.require ? window.require('electron') : null; - if (!electron || !electron.ipcRenderer) { - console.warn('[WandEnhancer] ipc-logger: electron.ipcRenderer not found. Node integration might be disabled or hidden behind contextBridge.'); - return; + if (globalThis.__wandIpcLoggerInstalled) { + return; } - const { ipcRenderer } = electron; - - // Backup original methods - const originalInvoke = ipcRenderer.invoke.bind(ipcRenderer); - const originalSend = ipcRenderer.send.bind(ipcRenderer); - - // Hook invoke (Promises) - ipcRenderer.invoke = async function (channel, ...args) { - const startTime = performance.now(); - try { - const result = await originalInvoke(channel, ...args); - const duration = (performance.now() - startTime).toFixed(1); - console.log(`%c[IPC INVOKE]%c ${channel} %c(${duration}ms) =>`, 'color: #00c7f2; font-weight: bold;', 'color: white;', 'color: gray;', { args, result }); - return result; - } catch (err) { - console.error(`%c[IPC INVOKE ERROR]%c ${channel} =>`, 'color: #ff0056; font-weight: bold;', 'color: white;', { args, err }); - throw err; - } - }; + globalThis.__wandIpcLoggerInstalled = true; - // Hook send (Fire & Forget) - ipcRenderer.send = function (channel, ...args) { - console.log(`%c[IPC SEND]%c ${channel} =>`, 'color: #acff35; font-weight: bold;', 'color: white;', { args }); - return originalSend(channel, ...args); - }; + try { + const electron = window.require ? window.require('electron') : null; + if (!electron?.ipcRenderer) { + console.warn( + '[WandEnhancer] ipc-logger: electron.ipcRenderer not found. Node integration might be disabled or hidden behind contextBridge.', + ); + return; + } - console.log('%c[WandEnhancer]%c IPC Logger installed successfully! Use Ctrl+Shift+D to view logs.', 'color: #1293ff; font-weight: bold;', 'color: inherit;'); + const { ipcRenderer } = electron; - } catch (err) { - console.error('[WandEnhancer] Failed to hook IPC:', err); - } + // Backup original methods + const originalInvoke = ipcRenderer.invoke.bind(ipcRenderer); + const originalSend = ipcRenderer.send.bind(ipcRenderer); + + // Hook invoke (Promises) + ipcRenderer.invoke = async (channel, ...args) => { + const startTime = performance.now(); + try { + const result = await originalInvoke(channel, ...args); + const duration = (performance.now() - startTime).toFixed(1); + console.log( + `%c[IPC INVOKE]%c ${channel} %c(${duration}ms) =>`, + 'color: #00c7f2; font-weight: bold;', + 'color: white;', + 'color: gray;', + { args, result }, + ); + return result; + } catch (err) { + console.error( + `%c[IPC INVOKE ERROR]%c ${channel} =>`, + 'color: #ff0056; font-weight: bold;', + 'color: white;', + { args, err }, + ); + throw err; + } + }; + + // Hook send (Fire & Forget) + ipcRenderer.send = (channel, ...args) => { + console.log( + `%c[IPC SEND]%c ${channel} =>`, + 'color: #acff35; font-weight: bold;', + 'color: white;', + { args }, + ); + return originalSend(channel, ...args); + }; + + console.log( + '%c[WandEnhancer]%c IPC Logger installed successfully! Use Ctrl+Shift+D to view logs.', + 'color: #1293ff; font-weight: bold;', + 'color: inherit;', + ); + } catch (err) { + console.error('[WandEnhancer] Failed to hook IPC:', err); + } })(globalThis.WandEnhancer); diff --git a/web-panel/scripts/verify-dist.mjs b/web-panel/scripts/verify-dist.mjs index 5cb1fbb..68030f0 100644 --- a/web-panel/scripts/verify-dist.mjs +++ b/web-panel/scripts/verify-dist.mjs @@ -9,86 +9,86 @@ import { fileURLToPath } from 'node:url'; const distDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist'); const REQUIRED_FILES = [ - 'bridge.cjs', - 'index.html', - path.join('renderer-scripts', 'remote-popup-cleanup.js'), + 'bridge.cjs', + 'index.html', + path.join('renderer-scripts', 'remote-popup-cleanup.js'), ]; const FORBIDDEN_SUBSTRINGS = [ - 'mock-instance', - 'Mock Adventure', - 'Debug session', - 'mock=1', - 'demo-session', - 'vite.svg', - 'tailwind-merge', - 'class-variance-authority', + 'mock-instance', + 'Mock Adventure', + 'Debug session', + 'mock=1', + 'demo-session', + 'vite.svg', + 'tailwind-merge', + 'class-variance-authority', ]; const failures = []; if (!fs.existsSync(distDir)) { - fail(`dist/ not found at ${distDir} - run the build first`); + fail(`dist/ not found at ${distDir} - run the build first`); } for (const relative of REQUIRED_FILES) { - if (!fs.existsSync(path.join(distDir, relative))) { - failures.push(`missing required artifact: ${relative}`); - } + if (!fs.existsSync(path.join(distDir, relative))) { + failures.push(`missing required artifact: ${relative}`); + } } for (const file of collectFiles(distDir)) { - const relative = path.relative(distDir, file); + const relative = path.relative(distDir, file); - if (file.endsWith('.js') || file.endsWith('.cjs')) { - try { - execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' }); - } catch (error) { - failures.push(`syntax error in ${relative}: ${firstLine(error)}`); + if (file.endsWith('.js') || file.endsWith('.cjs')) { + try { + execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' }); + } catch (error) { + failures.push(`syntax error in ${relative}: ${firstLine(error)}`); + } } - } - if (!isTextArtifact(file)) { - continue; - } + if (!isTextArtifact(file)) { + continue; + } - const content = fs.readFileSync(file, 'utf8'); - for (const needle of FORBIDDEN_SUBSTRINGS) { - if (content.includes(needle)) { - failures.push(`dev-only payload "${needle}" found in ${relative}`); + const content = fs.readFileSync(file, 'utf8'); + for (const needle of FORBIDDEN_SUBSTRINGS) { + if (content.includes(needle)) { + failures.push(`dev-only payload "${needle}" found in ${relative}`); + } } - } } if (failures.length > 0) { - fail(`dist verification failed:\n - ${failures.join('\n - ')}`); + fail(`dist verification failed:\n - ${failures.join('\n - ')}`); } console.log('dist verified: bundles parse, no dev-only payload.'); function collectFiles(dir) { - const found = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - found.push(...collectFiles(full)); - } else if (entry.isFile()) { - found.push(full); + const found = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + found.push(...collectFiles(full)); + } else if (entry.isFile()) { + found.push(full); + } } - } - return found; + return found; } function isTextArtifact(file) { - return ['.js', '.cjs', '.mjs', '.css', '.html', '.json'].includes(path.extname(file)); + return ['.js', '.cjs', '.mjs', '.css', '.html', '.json'].includes(path.extname(file)); } function firstLine(error) { - const output = String(error.stderr || error.message || ''); - return output.split('\n').find((line) => line.trim().length > 0) ?? 'unknown error'; + const output = String(error.stderr || error.message || ''); + return output.split('\n').find((line) => line.trim().length > 0) ?? 'unknown error'; } function fail(message) { - console.error(message); - process.exit(1); + console.error(message); + process.exit(1); } diff --git a/web-panel/src/app/app.tsx b/web-panel/src/app/app.tsx index 50d2263..ccc41f4 100644 --- a/web-panel/src/app/app.tsx +++ b/web-panel/src/app/app.tsx @@ -1,6 +1,6 @@ import { msg } from '@lingui/core/macro'; -import { Trans } from '@lingui/react/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { FloatingDock } from '@/app/ui/FloatingDock'; import { SessionPlaceholder } from '@/app/ui/SessionPlaceholder'; @@ -16,107 +16,147 @@ import { TrainerHeader } from '@/trainer/ui/TrainerHeader'; import { useRemotePanel } from './use-remote-panel'; export const App = () => { - const { _ } = useLingui(); - const panel = useRemotePanel(); - const { session, trainer, library, shell } = panel; + const { _ } = useLingui(); + const panel = useRemotePanel(); + const { session, trainer, library, shell } = panel; - return ( -
-
-
-
-
-
- -
- {!session.connected || !trainer.activeTrainer ? ( - - ) : ( - <> - library.currentGame && library.togglePin(library.currentGame)} /> - -
- -
- {trainer.filteredPinnedGroup ? ( - +
+
+
+
+
+ +
+ {!session.connected || !trainer.activeTrainer ? ( + + ) : ( + <> + + library.currentGame && + library.togglePin(library.currentGame) + } + /> + +
+ +
+ {trainer.filteredPinnedGroup ? ( + + ) : null} + {trainer.filteredGroups.map((group, index) => ( + + ))} + {trainer.query && trainer.totalVisibleCheats === 0 ? ( +

+ No mods match "{trainer.query}" +

+ ) : null} +
+ {trainer.query + ? _(msg`${trainer.totalVisibleCheats} matches`) + : _(msg`END · ${trainer.totalCheats} MODS`)} +
+ + )} +
+
+ +
-
- -
-
-
- ); + + ); }; diff --git a/web-panel/src/app/i18n.ts b/web-panel/src/app/i18n.ts index 8619218..9bd15f1 100644 --- a/web-panel/src/app/i18n.ts +++ b/web-panel/src/app/i18n.ts @@ -5,12 +5,12 @@ import { loadString, saveString } from '@/shared/storage'; export const DEFAULT_LOCALE = 'en-US'; export const SUPPORTED_LOCALES = [ - { code: 'en-US', label: 'English' }, - { code: 'ru-RU', label: 'Русский' }, - { code: 'de-DE', label: 'Deutsch' }, - { code: 'fr-FR', label: 'Français' }, - { code: 'es-ES', label: 'Español' }, - { code: 'zh-CN', label: '简体中文' }, + { code: 'en-US', label: 'English' }, + { code: 'ru-RU', label: 'Русский' }, + { code: 'de-DE', label: 'Deutsch' }, + { code: 'fr-FR', label: 'Français' }, + { code: 'es-ES', label: 'Español' }, + { code: 'zh-CN', label: '简体中文' }, ] as const; export type LocaleCode = (typeof SUPPORTED_LOCALES)[number]['code']; @@ -22,43 +22,46 @@ type CatalogModule = { messages: Messages }; const catalogs = import.meta.glob('../locales/*/messages.po'); export async function activateLocale(locale: LocaleCode): Promise { - const loadCatalog = catalogs[`../locales/${locale}/messages.po`]; - if (!loadCatalog) { - throw new Error(`Locale catalog not found: ${locale}`); - } + const loadCatalog = catalogs[`../locales/${locale}/messages.po`]; + if (!loadCatalog) { + throw new Error(`Locale catalog not found: ${locale}`); + } - const { messages } = await loadCatalog(); - i18n.load(locale, messages); - i18n.activate(locale); - persistLocale(locale); + const { messages } = await loadCatalog(); + i18n.load(locale, messages); + i18n.activate(locale); + persistLocale(locale); } export function detectInitialLocale(): LocaleCode { - return readStoredLocale() ?? matchBrowserLocale() ?? DEFAULT_LOCALE; + return readStoredLocale() ?? matchBrowserLocale() ?? DEFAULT_LOCALE; } function readStoredLocale(): LocaleCode | null { - const stored = loadString(LOCALE_STORAGE_KEY); - return isSupportedLocale(stored) ? stored : null; + const stored = loadString(LOCALE_STORAGE_KEY); + return isSupportedLocale(stored) ? stored : null; } function matchBrowserLocale(): LocaleCode | null { - const candidates = typeof navigator === 'undefined' ? [] : (navigator.languages ?? [navigator.language]); - for (const candidate of candidates) { - const base = candidate.split('-')[0]; - const match = SUPPORTED_LOCALES.find(({ code }) => code === candidate || code.split('-')[0] === base); - if (match) { - return match.code; + const candidates = + typeof navigator === 'undefined' ? [] : (navigator.languages ?? [navigator.language]); + for (const candidate of candidates) { + const base = candidate.split('-')[0]; + const match = SUPPORTED_LOCALES.find( + ({ code }) => code === candidate || code.split('-')[0] === base, + ); + if (match) { + return match.code; + } } - } - return null; + return null; } function persistLocale(locale: LocaleCode): void { - saveString(LOCALE_STORAGE_KEY, locale); + saveString(LOCALE_STORAGE_KEY, locale); } function isSupportedLocale(value: string | null): value is LocaleCode { - return value !== null && SUPPORTED_LOCALES.some(({ code }) => code === value); + return value !== null && SUPPORTED_LOCALES.some(({ code }) => code === value); } diff --git a/web-panel/src/app/main.tsx b/web-panel/src/app/main.tsx index 34f5480..befddb2 100644 --- a/web-panel/src/app/main.tsx +++ b/web-panel/src/app/main.tsx @@ -1,7 +1,7 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; import { i18n } from '@lingui/core'; import { I18nProvider } from '@lingui/react'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; import { applySavedAccentColor } from '@/appearance/appearance-storage'; @@ -12,21 +12,23 @@ import '../index.css'; const root = document.getElementById('root') ?? document.getElementById('app'); if (!root) { - throw new Error('App root not found.'); + throw new Error('App root not found.'); } applySavedAccentColor(); function render() { - createRoot(root!).render( - - - - - , - ); + createRoot(root!).render( + + + + + , + ); } // A failed catalog fetch (flaky LAN, cache miss) must not leave a blank page: // mount anyway and let lingui fall back to the source strings. -activateLocale(detectInitialLocale()).catch(() => undefined).then(render); +activateLocale(detectInitialLocale()) + .catch(() => undefined) + .then(render); diff --git a/web-panel/src/app/ui/FloatingDock.tsx b/web-panel/src/app/ui/FloatingDock.tsx index f3fe4be..d645792 100644 --- a/web-panel/src/app/ui/FloatingDock.tsx +++ b/web-panel/src/app/ui/FloatingDock.tsx @@ -1,59 +1,87 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; - -import { Icon, type IconName } from '@/shared/ui/Icon'; - -import { cn } from '@/shared/lib/ui'; import { EConnectionStatus } from '@/remote-session/remote-session.reducer'; +import { cn } from '@/shared/lib/ui'; +import { Icon, type IconName } from '@/shared/ui/Icon'; + type FloatingDockProps = { - status: EConnectionStatus; - runningGameTitle: string | null; - hidden: boolean; - leftHasBadge: boolean; - rightHasBadge: boolean; - onOpenSettings: () => void; - onOpenLibrary: () => void; + status: EConnectionStatus; + runningGameTitle: string | null; + hidden: boolean; + leftHasBadge: boolean; + rightHasBadge: boolean; + onOpenSettings: () => void; + onOpenLibrary: () => void; }; export const FloatingDock = ({ - status, - runningGameTitle, - hidden, - leftHasBadge, - rightHasBadge, - onOpenSettings, - onOpenLibrary, + status, + runningGameTitle, + hidden, + leftHasBadge, + rightHasBadge, + onOpenSettings, + onOpenLibrary, }: FloatingDockProps) => { - const { _ } = useLingui(); - const live = status === EConnectionStatus.Connected; + const { _ } = useLingui(); + const live = status === EConnectionStatus.Connected; - return ( - - ); + return ( + + ); }; type DockButtonProps = { - badge: boolean; - icon: IconName; - label: string; - onClick: () => void; + badge: boolean; + icon: IconName; + label: string; + onClick: () => void; }; const DockButton = ({ badge, icon, label, onClick }: DockButtonProps) => { - return ( - - ); + return ( + + ); }; diff --git a/web-panel/src/app/ui/PlaceholderState.tsx b/web-panel/src/app/ui/PlaceholderState.tsx index 0b8f99b..3c96d3c 100644 --- a/web-panel/src/app/ui/PlaceholderState.tsx +++ b/web-panel/src/app/ui/PlaceholderState.tsx @@ -1,25 +1,29 @@ import { Icon, type IconName } from '@/shared/ui/Icon'; type PlaceholderStateProps = { - icon: IconName; - title: string; - sub: string; - action: string; - onAction: () => void; + icon: IconName; + title: string; + sub: string; + action: string; + onAction: () => void; }; export const PlaceholderState = ({ icon, title, sub, action, onAction }: PlaceholderStateProps) => { - return ( -
-
- -
-

{title}

-

{sub}

- -
- ); + return ( +
+
+ +
+

{title}

+

{sub}

+ +
+ ); }; diff --git a/web-panel/src/app/ui/SessionPlaceholder.tsx b/web-panel/src/app/ui/SessionPlaceholder.tsx index e96af9e..c75f434 100644 --- a/web-panel/src/app/ui/SessionPlaceholder.tsx +++ b/web-panel/src/app/ui/SessionPlaceholder.tsx @@ -5,43 +5,45 @@ import type { TrainerSummary } from '../../../protocol/messages'; import { PlaceholderState } from './PlaceholderState'; type SessionPlaceholderProps = { - connected: boolean; - activeTrainer: TrainerSummary | null; - onOpenLibrary: () => void; - onOpenSettings: () => void; + connected: boolean; + activeTrainer: TrainerSummary | null; + onOpenLibrary: () => void; + onOpenSettings: () => void; }; export const SessionPlaceholder = ({ - connected, - activeTrainer, - onOpenLibrary, - onOpenSettings, + connected, + activeTrainer, + onOpenLibrary, + onOpenSettings, }: SessionPlaceholderProps) => { - const { _ } = useLingui(); + const { _ } = useLingui(); - if (!connected) { - return ( - - ); - } + if (!connected) { + return ( + + ); + } - if (!activeTrainer) { - return ( - - ); - } + if (!activeTrainer) { + return ( + + ); + } - return null; + return null; }; diff --git a/web-panel/src/app/ui/SettingsDrawer.tsx b/web-panel/src/app/ui/SettingsDrawer.tsx index 7dced07..3f058b6 100644 --- a/web-panel/src/app/ui/SettingsDrawer.tsx +++ b/web-panel/src/app/ui/SettingsDrawer.tsx @@ -1,231 +1,284 @@ -import { useState, type FormEvent } from 'react'; -import { msg } from '@lingui/core/macro'; import type { MessageDescriptor } from '@lingui/core'; -import { Trans } from '@lingui/react/macro'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; - -import { IconButton } from '@/shared/ui/IconButton'; -import { Icon } from '@/shared/ui/Icon'; -import { cn } from '@/shared/lib/ui'; +import { Trans } from '@lingui/react/macro'; +import { type FormEvent, useState } from 'react'; import { activateLocale, type LocaleCode, SUPPORTED_LOCALES } from '@/app/i18n'; -import { DEFAULT_ACCENT_COLOR, loadAccentColor, setAccentColor } from '@/appearance/appearance-storage'; +import { + DEFAULT_ACCENT_COLOR, + loadAccentColor, + setAccentColor, +} from '@/appearance/appearance-storage'; import type { LibraryGame } from '@/library/model/games'; import { EConnectionStatus } from '@/remote-session/remote-session.reducer'; +import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; +import { IconButton } from '@/shared/ui/IconButton'; import { WEB_CONTRACT } from '../../../protocol/contract'; import type { TrainerSummary } from '../../../protocol/messages'; import { StatusPill } from './StatusPill'; const ACCENT_OPTIONS: { value: string; label: MessageDescriptor; swatchClass: string }[] = [ - { value: '#3B82F6', label: msg`Cobalt`, swatchClass: 'bg-[#3B82F6]' }, - { value: DEFAULT_ACCENT_COLOR, label: msg`Cyan`, swatchClass: 'bg-[#00FFD5]' }, - { value: '#FF2E63', label: msg`Crimson`, swatchClass: 'bg-[#FF2E63]' }, - { value: '#A78BFA', label: msg`Violet`, swatchClass: 'bg-[#A78BFA]' }, - { value: '#7CFF5B', label: msg`Lime`, swatchClass: 'bg-[#7CFF5B]' }, - { value: '#FFB12E', label: msg`Amber`, swatchClass: 'bg-[#FFB12E]' }, - { value: '#ee00ff', label: msg`Magenta`, swatchClass: 'bg-[#ee00ff]' }, + { value: '#3B82F6', label: msg`Cobalt`, swatchClass: 'bg-[#3B82F6]' }, + { value: DEFAULT_ACCENT_COLOR, label: msg`Cyan`, swatchClass: 'bg-[#00FFD5]' }, + { value: '#FF2E63', label: msg`Crimson`, swatchClass: 'bg-[#FF2E63]' }, + { value: '#A78BFA', label: msg`Violet`, swatchClass: 'bg-[#A78BFA]' }, + { value: '#7CFF5B', label: msg`Lime`, swatchClass: 'bg-[#7CFF5B]' }, + { value: '#FFB12E', label: msg`Amber`, swatchClass: 'bg-[#FFB12E]' }, + { value: '#ee00ff', label: msg`Magenta`, swatchClass: 'bg-[#ee00ff]' }, ]; type SettingsDrawerProps = { - status: EConnectionStatus; - wsUrl: string; - currentGame: LibraryGame | null; - currentTrainer: TrainerSummary | null; - lastError: string | null; - onClose: () => void; - onConnect: () => void; - onDisconnect: () => void; - onWsUrlChange: (value: string) => void; + status: EConnectionStatus; + wsUrl: string; + currentGame: LibraryGame | null; + currentTrainer: TrainerSummary | null; + lastError: string | null; + onClose: () => void; + onConnect: () => void; + onDisconnect: () => void; + onWsUrlChange: (value: string) => void; }; export const SettingsDrawer = ({ - status, - wsUrl, - currentGame, - currentTrainer, - lastError, - onClose, - onConnect, - onDisconnect, - onWsUrlChange, + status, + wsUrl, + currentGame, + currentTrainer, + lastError, + onClose, + onConnect, + onDisconnect, + onWsUrlChange, }: SettingsDrawerProps) => { - const { _ } = useLingui(); + const { _ } = useLingui(); - return ( -
-
-
-

- Settings -

-

- wand remote · port {WEB_CONTRACT.defaultRemotePort} -

+ return ( +
+
+
+

+ Settings +

+

+ wand remote · port {WEB_CONTRACT.defaultRemotePort} +

+
+ +
+
+ + {lastError ? : null} + + + + + + + + + +
- -
-
- - {lastError ? : null} - - - - - - - - - -
-
- ); + ); }; type BridgeControlProps = { - status: EConnectionStatus; - wsUrl: string; - onConnect: () => void; - onDisconnect: () => void; - onWsUrlChange: (value: string) => void; + status: EConnectionStatus; + wsUrl: string; + onConnect: () => void; + onDisconnect: () => void; + onWsUrlChange: (value: string) => void; }; -const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }: BridgeControlProps) => { - const { _ } = useLingui(); - const live = status === EConnectionStatus.Connected; - const connecting = status === EConnectionStatus.Connecting || status === EConnectionStatus.Reconnecting; - const handleInput = (event: FormEvent) => onWsUrlChange(event.currentTarget.value); - const buttonLabel = connecting ? '...' : _(live ? msg`STOP` : msg`GO`); +const BridgeControl = ({ + status, + wsUrl, + onConnect, + onDisconnect, + onWsUrlChange, +}: BridgeControlProps) => { + const { _ } = useLingui(); + const live = status === EConnectionStatus.Connected; + const connecting = + status === EConnectionStatus.Connecting || status === EConnectionStatus.Reconnecting; + const handleInput = (event: FormEvent) => + onWsUrlChange(event.currentTarget.value); + const buttonLabel = connecting ? '...' : _(live ? msg`STOP` : msg`GO`); - return ( -
-
-

- Bridge -

- -
-
- - -
-
- ); + return ( +
+
+

+ Bridge +

+ +
+
+ + +
+
+ ); }; const ErrorPanel = ({ message }: { message: string }) => { - return ( -
- - {message} -
- ); + return ( +
+ + {message} +
+ ); }; -const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGame | null; currentTrainer: TrainerSummary | null }) => { - if (!currentGame) { +const SessionPanel = ({ + currentGame, + currentTrainer, +}: { + currentGame: LibraryGame | null; + currentTrainer: TrainerSummary | null; +}) => { + if (!currentGame) { + return ( +
+ No active game session. +
+ ); + } + + const subtitleBase = currentTrainer?.displayName ?? currentGame.platform; + const subtitleVersion = currentTrainer?.gameVersion ? ` · v${currentTrainer.gameVersion}` : ''; + const sessionSubtitle = `${subtitleBase}${subtitleVersion}`; + return ( -
- No active game session. -
+
+
+ + + Active Session + +
+

{currentGame.title}

+

+ {sessionSubtitle} +

+
); - } - - const subtitleBase = currentTrainer?.displayName ?? currentGame.platform; - const subtitleVersion = currentTrainer?.gameVersion ? ` · v${currentTrainer.gameVersion}` : ''; - const sessionSubtitle = `${subtitleBase}${subtitleVersion}`; - - return ( -
-
- - - Active Session - -
-

{currentGame.title}

-

- {sessionSubtitle} -

-
- ); }; const LanguagePicker = () => { - const { i18n } = useLingui(); + const { i18n } = useLingui(); - const handleSelect = (locale: LocaleCode) => { - void activateLocale(locale); - }; + const handleSelect = (locale: LocaleCode) => { + void activateLocale(locale); + }; - return ( -
- {SUPPORTED_LOCALES.map(({ code, label }) => { - const active = i18n.locale === code; - return ( - - ); - })} -
- ); + return ( +
+ {SUPPORTED_LOCALES.map(({ code, label }) => { + const active = i18n.locale === code; + return ( + + ); + })} +
+ ); }; const AccentPicker = () => { - const { _ } = useLingui(); - const [current, setCurrent] = useState(loadAccentColor); + const { _ } = useLingui(); + const [current, setCurrent] = useState(loadAccentColor); - const applyAccent = (value: string) => { - setCurrent(setAccentColor(value)); - }; + const applyAccent = (value: string) => { + setCurrent(setAccentColor(value)); + }; - return ( -
-
- {ACCENT_OPTIONS.map((option) => { - const active = current.toLowerCase() === option.value.toLowerCase(); - return ( - - ); - })} -
- -
- ); + return ( +
+
+ {ACCENT_OPTIONS.map((option) => { + const active = current.toLowerCase() === option.value.toLowerCase(); + return ( + + ); + })} +
+ +
+ ); }; const SectionHeader = ({ title }: { title: string }) => { - return ( -
-

{title}

-
-
- ); + return ( +
+

+ {title} +

+
+
+ ); }; diff --git a/web-panel/src/app/ui/StatusPill.tsx b/web-panel/src/app/ui/StatusPill.tsx index e626570..ba1eca5 100644 --- a/web-panel/src/app/ui/StatusPill.tsx +++ b/web-panel/src/app/ui/StatusPill.tsx @@ -1,38 +1,46 @@ -import { msg } from '@lingui/core/macro'; import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; - -import { Icon } from '@/shared/ui/Icon'; -import { cn } from '@/shared/lib/ui'; import { EConnectionStatus } from '@/remote-session/remote-session.reducer'; +import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; const STATUS_LABELS: Record = { - [EConnectionStatus.Connected]: msg`LIVE`, - [EConnectionStatus.Connecting]: msg`LINKING`, - [EConnectionStatus.Reconnecting]: msg`LINKING`, - [EConnectionStatus.Error]: msg`OFFLINE`, - [EConnectionStatus.Idle]: msg`OFFLINE`, + [EConnectionStatus.Connected]: msg`LIVE`, + [EConnectionStatus.Connecting]: msg`LINKING`, + [EConnectionStatus.Reconnecting]: msg`LINKING`, + [EConnectionStatus.Error]: msg`OFFLINE`, + [EConnectionStatus.Idle]: msg`OFFLINE`, }; const STATUS_CLASSES: Record = { - [EConnectionStatus.Connected]: 'border-[color-mix(in_oklab,var(--deck-accent)_30%,transparent)] text-(--deck-accent)', - [EConnectionStatus.Connecting]: 'border-amber-300/30 text-amber-300', - [EConnectionStatus.Reconnecting]: 'border-amber-300/30 text-amber-300', - [EConnectionStatus.Error]: 'border-white/10 text-(--deck-fg-4)', - [EConnectionStatus.Idle]: 'border-white/10 text-(--deck-fg-4)', + [EConnectionStatus.Connected]: + 'border-[color-mix(in_oklab,var(--deck-accent)_30%,transparent)] text-(--deck-accent)', + [EConnectionStatus.Connecting]: 'border-amber-300/30 text-amber-300', + [EConnectionStatus.Reconnecting]: 'border-amber-300/30 text-amber-300', + [EConnectionStatus.Error]: 'border-white/10 text-(--deck-fg-4)', + [EConnectionStatus.Idle]: 'border-white/10 text-(--deck-fg-4)', }; export const StatusPill = ({ status }: { status: EConnectionStatus }) => { - const { _ } = useLingui(); - const live = status === EConnectionStatus.Connected - || status === EConnectionStatus.Connecting - || status === EConnectionStatus.Reconnecting; + const { _ } = useLingui(); + const live = + status === EConnectionStatus.Connected || + status === EConnectionStatus.Connecting || + status === EConnectionStatus.Reconnecting; - return ( -
- {live ? : null} - {_(STATUS_LABELS[status])} - {status === EConnectionStatus.Error ? : null} -
- ); + return ( +
+ {live ? ( + + ) : null} + {_(STATUS_LABELS[status])} + {status === EConnectionStatus.Error ? : null} +
+ ); }; diff --git a/web-panel/src/app/ui/TopBar.tsx b/web-panel/src/app/ui/TopBar.tsx index 2a429d0..5953363 100644 --- a/web-panel/src/app/ui/TopBar.tsx +++ b/web-panel/src/app/ui/TopBar.tsx @@ -1,43 +1,49 @@ import { msg } from '@lingui/core/macro'; -import { Trans } from '@lingui/react/macro'; import { useLingui } from '@lingui/react'; - -import { IconButton } from '@/shared/ui/IconButton'; - +import { Trans } from '@lingui/react/macro'; import type { LibraryGame } from '@/library/model/games'; -import type { TrainerSummary } from '../../../protocol/messages'; import type { EConnectionStatus } from '@/remote-session/remote-session.reducer'; +import { IconButton } from '@/shared/ui/IconButton'; +import type { TrainerSummary } from '../../../protocol/messages'; import { StatusPill } from './StatusPill'; type TopBarProps = { - status: EConnectionStatus; - currentGame: LibraryGame | null; - runningTrainer: TrainerSummary | null; - onOpenSettings: () => void; + status: EConnectionStatus; + currentGame: LibraryGame | null; + runningTrainer: TrainerSummary | null; + onOpenSettings: () => void; }; export const TopBar = ({ status, currentGame, runningTrainer, onOpenSettings }: TopBarProps) => { - const { _ } = useLingui(); + const { _ } = useLingui(); - return ( -
-
- -
-
WAND · REMOTE DECK
-
- - {currentGame ? currentGame.title : Idle · no game} - - {currentGame && runningTrainer?.gameVersion ? ( - - v{runningTrainer.gameVersion} - - ) : null} -
-
- -
-
- ); + return ( +
+
+ +
+
+ WAND · REMOTE DECK +
+
+ + {currentGame ? currentGame.title : Idle · no game} + + {currentGame && runningTrainer?.gameVersion ? ( + + v{runningTrainer.gameVersion} + + ) : null} +
+
+ +
+
+ ); }; diff --git a/web-panel/src/app/ui/session-states.test.tsx b/web-panel/src/app/ui/session-states.test.tsx index e09350f..d5ed7ce 100644 --- a/web-panel/src/app/ui/session-states.test.tsx +++ b/web-panel/src/app/ui/session-states.test.tsx @@ -1,8 +1,8 @@ -import type { ReactNode } from 'react'; -import { fireEvent, render, screen } from '@testing-library/preact'; -import { describe, expect, it, vi } from 'vitest'; import { i18n } from '@lingui/core'; import { I18nProvider } from '@lingui/react'; +import { fireEvent, render, screen } from '@testing-library/preact'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; import type { TrainerSummary } from '../../../protocol/messages'; import { TrainerHeader } from '../../trainer/ui/TrainerHeader'; @@ -14,32 +14,53 @@ i18n.activate('en'); const renderWithI18n = (ui: ReactNode) => render({ui}); const trainer: TrainerSummary = { - trainerId: 'trainer', - gameId: 'game', - displayName: 'Test Trainer', - trainerLoading: false, - gameInstalled: true, - needsCompatibilityWarning: false, - isTimeLimitExpired: false, + trainerId: 'trainer', + gameId: 'game', + displayName: 'Test Trainer', + trainerLoading: false, + gameInstalled: true, + needsCompatibilityWarning: false, + isTimeLimitExpired: false, }; describe('session state components', () => { - it('renders the offline intent', () => { - const openSettings = vi.fn(); - renderWithI18n( undefined} onOpenSettings={openSettings} />); - fireEvent.click(screen.getByRole('button', { name: 'Open Settings' })); - expect(screen.getByText('Bridge offline')).toBeTruthy(); - expect(openSettings).toHaveBeenCalledOnce(); - }); + it('renders the offline intent', () => { + const openSettings = vi.fn(); + renderWithI18n( + undefined} + onOpenSettings={openSettings} + />, + ); + fireEvent.click(screen.getByRole('button', { name: 'Open Settings' })); + expect(screen.getByText('Bridge offline')).toBeTruthy(); + expect(openSettings).toHaveBeenCalledOnce(); + }); - it('renders the no-trainer intent', () => { - renderWithI18n( undefined} onOpenSettings={() => undefined} />); - expect(screen.getByText('Select a game')).toBeTruthy(); - }); + it('renders the no-trainer intent', () => { + renderWithI18n( + undefined} + onOpenSettings={() => undefined} + />, + ); + expect(screen.getByText('Select a game')).toBeTruthy(); + }); - it('renders an active trainer header', () => { - renderWithI18n( undefined} />); - expect(screen.getByText('Test Trainer')).toBeTruthy(); - expect(screen.getByText('Trainer Active')).toBeTruthy(); - }); + it('renders an active trainer header', () => { + renderWithI18n( + undefined} + />, + ); + expect(screen.getByText('Test Trainer')).toBeTruthy(); + expect(screen.getByText('Trainer Active')).toBeTruthy(); + }); }); diff --git a/web-panel/src/app/use-dock-auto-hide.ts b/web-panel/src/app/use-dock-auto-hide.ts index 6673037..efba9d0 100644 --- a/web-panel/src/app/use-dock-auto-hide.ts +++ b/web-panel/src/app/use-dock-auto-hide.ts @@ -1,22 +1,22 @@ -import { useCallback, useRef, useState, type UIEvent } from 'react'; +import { type UIEvent, useCallback, useRef, useState } from 'react'; const SCROLL_HIDE_THRESHOLD_PX = 60; const SCROLL_REVEAL_DEAD_ZONE_PX = 4; export function useDockAutoHide() { - const [hidden, setHidden] = useState(false); - const lastScrollRef = useRef(0); + const [hidden, setHidden] = useState(false); + const lastScrollRef = useRef(0); - const onScroll = useCallback((event: UIEvent) => { - const y = event.currentTarget.scrollTop; - if (y > lastScrollRef.current && y > SCROLL_HIDE_THRESHOLD_PX) { - setHidden(true); - } else if (y < lastScrollRef.current - SCROLL_REVEAL_DEAD_ZONE_PX) { - setHidden(false); - } + const onScroll = useCallback((event: UIEvent) => { + const y = event.currentTarget.scrollTop; + if (y > lastScrollRef.current && y > SCROLL_HIDE_THRESHOLD_PX) { + setHidden(true); + } else if (y < lastScrollRef.current - SCROLL_REVEAL_DEAD_ZONE_PX) { + setHidden(false); + } - lastScrollRef.current = y; - }, []); + lastScrollRef.current = y; + }, []); - return { hidden, onScroll }; + return { hidden, onScroll }; } diff --git a/web-panel/src/app/use-remote-panel.ts b/web-panel/src/app/use-remote-panel.ts index 0dce91d..18d6995 100644 --- a/web-panel/src/app/use-remote-panel.ts +++ b/web-panel/src/app/use-remote-panel.ts @@ -12,123 +12,140 @@ import { usePresets } from '../trainer/presets/use-presets'; import { useDockAutoHide } from './use-dock-auto-hide'; export function useRemotePanel() { - const session = useRemoteSession(); - const [cheatQuery, setCheatQuery] = useState(''); - const [gameQuery, setGameQuery] = useState(''); - const [leftOpen, setLeftOpen] = useState(false); - const [rightOpen, setRightOpen] = useState(false); - const dock = useDockAutoHide(); + const session = useRemoteSession(); + const [cheatQuery, setCheatQuery] = useState(''); + const [gameQuery, setGameQuery] = useState(''); + const [leftOpen, setLeftOpen] = useState(false); + const [rightOpen, setRightOpen] = useState(false); + const dock = useDockAutoHide(); - const activeTrainer = session.state.trainerMeta?.trainer ?? null; - const { pinnedGameIds, togglePin: toggleGamePin } = useGamePins(); - const libraryGames = useMemo( - () => buildLibraryGames(session.state.installedApps, session.state.gameStatus, activeTrainer, pinnedGameIds), - [activeTrainer, pinnedGameIds, session.state.gameStatus, session.state.installedApps], - ); - const currentGame = useMemo(() => getCurrentGame(libraryGames), [libraryGames]); + const activeTrainer = session.state.trainerMeta?.trainer ?? null; + const { pinnedGameIds, togglePin: toggleGamePin } = useGamePins(); + const libraryGames = useMemo( + () => + buildLibraryGames( + session.state.installedApps, + session.state.gameStatus, + activeTrainer, + pinnedGameIds, + ), + [activeTrainer, pinnedGameIds, session.state.gameStatus, session.state.installedApps], + ); + const currentGame = useMemo(() => getCurrentGame(libraryGames), [libraryGames]); - const pinnedStorageKey = useMemo(() => getPinnedStorageKey(activeTrainer), [activeTrainer]); - const { pinnedTargets, toggle: togglePinnedCheat } = usePinnedCheats({ pinnedStorageKey }); - const groups = useMemo(() => groupCheatsByCategory(session.state.trainerMeta), [session.state.trainerMeta]); - const pinnedGroup = useMemo( - () => buildPinnedGroup(session.state.trainerMeta, pinnedTargets), - [pinnedTargets, session.state.trainerMeta], - ); - const filteredGroups = useMemo(() => filterGroups(groups, cheatQuery), [cheatQuery, groups]); - const filteredPinnedGroup = useMemo( - () => (pinnedGroup ? filterGroups([pinnedGroup], cheatQuery)[0] ?? null : null), - [cheatQuery, pinnedGroup], - ); + const pinnedStorageKey = useMemo(() => getPinnedStorageKey(activeTrainer), [activeTrainer]); + const { pinnedTargets, toggle: togglePinnedCheat } = usePinnedCheats({ pinnedStorageKey }); + const groups = useMemo( + () => groupCheatsByCategory(session.state.trainerMeta), + [session.state.trainerMeta], + ); + const pinnedGroup = useMemo( + () => buildPinnedGroup(session.state.trainerMeta, pinnedTargets), + [pinnedTargets, session.state.trainerMeta], + ); + const filteredGroups = useMemo(() => filterGroups(groups, cheatQuery), [cheatQuery, groups]); + const filteredPinnedGroup = useMemo( + () => (pinnedGroup ? (filterGroups([pinnedGroup], cheatQuery)[0] ?? null) : null), + [cheatQuery, pinnedGroup], + ); - const presetStorageKey = useMemo(() => getPresetStorageKey(activeTrainer), [activeTrainer]); - const presets = usePresets({ - presetStorageKey, - trainerMeta: session.state.trainerMeta, - values: session.state.values, - onError: session.reportError, - }); + const presetStorageKey = useMemo(() => getPresetStorageKey(activeTrainer), [activeTrainer]); + const presets = usePresets({ + presetStorageKey, + trainerMeta: session.state.trainerMeta, + values: session.state.values, + onError: session.reportError, + }); - const { changeCheat, launchGame } = session; - const { trainerMeta, values } = session.state; + const { changeCheat, launchGame } = session; + const { trainerMeta, values } = session.state; - const panic = useCallback(() => { - if (!trainerMeta) return; - for (const cheat of trainerMeta.schema.cheats) { - if (cheat.type === ECheatType.Toggle && Boolean(values[cheat.target])) { - changeCheat(cheat, false); - } - } - }, [trainerMeta, values, changeCheat]); + const panic = useCallback(() => { + if (!trainerMeta) return; + for (const cheat of trainerMeta.schema.cheats) { + if (cheat.type === ECheatType.Toggle && values[cheat.target]) { + changeCheat(cheat, false); + } + } + }, [trainerMeta, values, changeCheat]); - const applyPreset = useCallback((preset: RemotePreset) => { - if (!trainerMeta) return; - for (const cheat of trainerMeta.schema.cheats) { - if (cheat.target in preset.values) { - changeCheat(cheat, preset.values[cheat.target]); - } - } - }, [trainerMeta, changeCheat]); + const applyPreset = useCallback( + (preset: RemotePreset) => { + if (!trainerMeta) return; + for (const cheat of trainerMeta.schema.cheats) { + if (cheat.target in preset.values) { + changeCheat(cheat, preset.values[cheat.target]); + } + } + }, + [trainerMeta, changeCheat], + ); - const playGame = useCallback((game: LibraryGame) => { - if (launchGame(game.app)) { - setRightOpen(false); - } - }, [launchGame]); + const playGame = useCallback( + (game: LibraryGame) => { + if (launchGame(game.app)) { + setRightOpen(false); + } + }, + [launchGame], + ); - const totalVisibleCheats = filteredGroups.reduce( - (count, group) => count + group.cheats.length, - filteredPinnedGroup?.cheats.length ?? 0, - ); + const totalVisibleCheats = filteredGroups.reduce( + (count, group) => count + group.cheats.length, + filteredPinnedGroup?.cheats.length ?? 0, + ); - return { - session: { - status: session.state.connectionStatus, - wsUrl: session.state.wsUrl, - lastError: session.state.lastError, - values: session.state.values, - pendingTargets: session.pendingTargets, - connected: session.connected, - connect: session.connect, - disconnect: session.disconnect, - setWsUrl: session.setWsUrl, - }, - trainer: { - activeTrainer, - query: cheatQuery, - setQuery: setCheatQuery, - filteredGroups, - filteredPinnedGroup, - pinnedTargets, - controlsDisabled: Boolean(activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired), - totalVisibleCheats, - totalCheats: session.state.trainerMeta?.schema.cheats.length ?? 0, - changeCheat: session.changeCheat, - togglePin: togglePinnedCheat, - panic, - presets: presets.presets, - addPreset: presets.addPreset, - applyPreset, - deletePreset: presets.deletePreset, - }, - library: { - games: libraryGames, - currentGame, - pinnedGameIds, - query: gameQuery, - setQuery: setGameQuery, - togglePin: toggleGamePin, - playGame, - stopPlaying: session.stopPlaying, - }, - shell: { - leftOpen, - rightOpen, - openSettings: () => setLeftOpen(true), - closeSettings: () => setLeftOpen(false), - openLibrary: () => setRightOpen(true), - closeLibrary: () => setRightOpen(false), - dockHidden: dock.hidden, - onScroll: dock.onScroll, - }, - }; + return { + session: { + status: session.state.connectionStatus, + wsUrl: session.state.wsUrl, + lastError: session.state.lastError, + values: session.state.values, + pendingTargets: session.pendingTargets, + connected: session.connected, + connect: session.connect, + disconnect: session.disconnect, + setWsUrl: session.setWsUrl, + }, + trainer: { + activeTrainer, + query: cheatQuery, + setQuery: setCheatQuery, + filteredGroups, + filteredPinnedGroup, + pinnedTargets, + controlsDisabled: Boolean( + activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired, + ), + totalVisibleCheats, + totalCheats: session.state.trainerMeta?.schema.cheats.length ?? 0, + changeCheat: session.changeCheat, + togglePin: togglePinnedCheat, + panic, + presets: presets.presets, + addPreset: presets.addPreset, + applyPreset, + deletePreset: presets.deletePreset, + }, + library: { + games: libraryGames, + currentGame, + pinnedGameIds, + query: gameQuery, + setQuery: setGameQuery, + togglePin: toggleGamePin, + playGame, + stopPlaying: session.stopPlaying, + }, + shell: { + leftOpen, + rightOpen, + openSettings: () => setLeftOpen(true), + closeSettings: () => setLeftOpen(false), + openLibrary: () => setRightOpen(true), + closeLibrary: () => setRightOpen(false), + dockHidden: dock.hidden, + onScroll: dock.onScroll, + }, + }; } diff --git a/web-panel/src/appearance/appearance-storage.ts b/web-panel/src/appearance/appearance-storage.ts index 1c7f49f..6aefcba 100644 --- a/web-panel/src/appearance/appearance-storage.ts +++ b/web-panel/src/appearance/appearance-storage.ts @@ -6,37 +6,41 @@ const ACCENT_COLOR_STORAGE_KEY = 'wand-remote.accent-color.v1'; const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; export function applySavedAccentColor(): string { - return applyAccentColor(loadAccentColor()); + return applyAccentColor(loadAccentColor()); } export function loadAccentColor(): string { - return loadJson(ACCENT_COLOR_STORAGE_KEY, reviveAccentColor, DEFAULT_ACCENT_COLOR); + return loadJson(ACCENT_COLOR_STORAGE_KEY, reviveAccentColor, DEFAULT_ACCENT_COLOR); } export function setAccentColor(value: string): string { - const nextColor = normalizeAccentColor(value) ?? DEFAULT_ACCENT_COLOR; - applyAccentColor(nextColor); - saveJson(ACCENT_COLOR_STORAGE_KEY, nextColor, (storedValue) => storedValue === DEFAULT_ACCENT_COLOR); - return nextColor; + const nextColor = normalizeAccentColor(value) ?? DEFAULT_ACCENT_COLOR; + applyAccentColor(nextColor); + saveJson( + ACCENT_COLOR_STORAGE_KEY, + nextColor, + (storedValue) => storedValue === DEFAULT_ACCENT_COLOR, + ); + return nextColor; } function applyAccentColor(value: string): string { - if (typeof document !== 'undefined') { - document.documentElement.style.setProperty('--deck-accent', value); - } + if (typeof document !== 'undefined') { + document.documentElement.style.setProperty('--deck-accent', value); + } - return value; + return value; } function reviveAccentColor(raw: unknown): string | null { - return normalizeAccentColor(raw); + return normalizeAccentColor(raw); } function normalizeAccentColor(value: unknown): string | null { - if (typeof value !== 'string') { - return null; - } + if (typeof value !== 'string') { + return null; + } - const normalizedValue = value.trim().toLowerCase(); - return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null; + const normalizedValue = value.trim().toLowerCase(); + return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null; } diff --git a/web-panel/src/index.css b/web-panel/src/index.css index ec7729f..056de1c 100644 --- a/web-panel/src/index.css +++ b/web-panel/src/index.css @@ -29,15 +29,15 @@ } :root { - --deck-bg: #07080B; + --deck-bg: #07080b; --deck-surface-0: rgba(20, 23, 31, 0.88); --deck-surface-1: rgba(28, 32, 42, 0.85); --deck-line: rgba(255, 255, 255, 0.06); - --deck-line-2: rgba(255, 255, 255, 0.10); - --deck-fg: #F2F4F8; - --deck-fg-2: #B6BCCB; - --deck-fg-3: #7F8699; - --deck-fg-4: #535A6B; + --deck-line-2: rgba(255, 255, 255, 0.1); + --deck-fg: #f2f4f8; + --deck-fg-2: #b6bccb; + --deck-fg-3: #7f8699; + --deck-fg-4: #535a6b; --deck-accent: #00ffd5; --background: oklch(0.122 0.022 255); --foreground: oklch(0.968 0.016 96); @@ -114,13 +114,17 @@ .remote-glass-header { background: linear-gradient(180deg, rgba(18, 20, 27, 0.72), rgba(10, 12, 17, 0.48)); border-color: rgba(255, 255, 255, 0.08); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 14px 34px rgba(0, 0, 0, 0.22); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 14px 34px rgba(0, 0, 0, 0.22); backdrop-filter: blur(24px) saturate(160%); } .remote-glass-drawer { - background: linear-gradient(180deg, rgba(17, 19, 26, 0.70), rgba(10, 12, 17, 0.52)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 30px 80px rgba(0, 0, 0, 0.46); + background: linear-gradient(180deg, rgba(17, 19, 26, 0.7), rgba(10, 12, 17, 0.52)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 30px 80px rgba(0, 0, 0, 0.46); backdrop-filter: blur(38px) saturate(165%); } @@ -131,8 +135,10 @@ } .remote-drawer-panel { - background: linear-gradient(180deg, rgba(17, 19, 26, 0.90), rgba(10, 12, 17, 0.82)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 30px 80px rgba(0, 0, 0, 0.46); + background: linear-gradient(180deg, rgba(17, 19, 26, 0.9), rgba(10, 12, 17, 0.82)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 30px 80px rgba(0, 0, 0, 0.46); -webkit-backdrop-filter: blur(22px) saturate(155%); backdrop-filter: blur(22px) saturate(155%); transform: translateZ(0); @@ -156,7 +162,9 @@ .remote-drawer-panel { background: linear-gradient(180deg, rgba(17, 19, 26, 0.97), rgba(10, 12, 17, 0.95)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055), 0 18px 48px rgba(0, 0, 0, 0.40); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.055), + 0 18px 48px rgba(0, 0, 0, 0.4); -webkit-backdrop-filter: none; backdrop-filter: none; } @@ -179,15 +187,16 @@ } .remote-glass-control { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.070), rgba(255, 255, 255, 0.036)); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.036)); border-color: rgba(255, 255, 255, 0.105); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055), 0 8px 24px rgba(0, 0, 0, 0.12); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.055), + 0 8px 24px rgba(0, 0, 0, 0.12); backdrop-filter: blur(18px) saturate(150%); } } @keyframes breathe { - 0%, 100% { opacity: 1; @@ -196,4 +205,4 @@ 50% { opacity: 0.4; } -} \ No newline at end of file +} diff --git a/web-panel/src/library/model/games.test.ts b/web-panel/src/library/model/games.test.ts index fbb7624..e070168 100644 --- a/web-panel/src/library/model/games.test.ts +++ b/web-panel/src/library/model/games.test.ts @@ -1,46 +1,51 @@ import { describe, expect, it } from 'vitest'; import type { InstalledAppSummary } from '../../../protocol/messages'; -import { buildLibraryGames, filterLibraryGames, getCurrentGame } from './games'; import { togglePinnedGame } from '../pinned-games/game-pin-storage'; +import { buildLibraryGames, filterLibraryGames, getCurrentGame } from './games'; const apps: InstalledAppSummary[] = [ - { - platform: 'steam', - sku: 'one', - correlationId: 'steam:one', - displayName: 'Alpha Game', - gameId: 'game-one', - }, - { - platform: 'epic', - sku: 'two', - correlationId: 'epic:two', - displayName: 'Beta Game', - gameId: 'game-two', - }, + { + platform: 'steam', + sku: 'one', + correlationId: 'steam:one', + displayName: 'Alpha Game', + gameId: 'game-one', + }, + { + platform: 'epic', + sku: 'two', + correlationId: 'epic:two', + displayName: 'Beta Game', + gameId: 'game-two', + }, ]; describe('library models', () => { - it('projects running and pinned games and filters them', () => { - const games = buildLibraryGames(apps, { - instanceId: 'status', - updatedAt: 'now', - session: { state: 'running', event: 'snapshot', gameId: 'game-two' }, - trainer: { state: 'idle', event: 'snapshot' }, - }, null, { 'game-one': true }); + it('projects running and pinned games and filters them', () => { + const games = buildLibraryGames( + apps, + { + instanceId: 'status', + updatedAt: 'now', + session: { state: 'running', event: 'snapshot', gameId: 'game-two' }, + trainer: { state: 'idle', event: 'snapshot' }, + }, + null, + { 'game-one': true }, + ); - expect(getCurrentGame(games)?.id).toBe('game-two'); - expect(games.find((game) => game.id === 'game-one')?.pinned).toBe(true); - expect(filterLibraryGames(games, 'alpha').map((game) => game.id)).toEqual(['game-one']); - }); + expect(getCurrentGame(games)?.id).toBe('game-two'); + expect(games.find((game) => game.id === 'game-one')?.pinned).toBe(true); + expect(filterLibraryGames(games, 'alpha').map((game) => game.id)).toEqual(['game-one']); + }); - it('toggles pins without mutating the current set', () => { - const game = buildLibraryGames([apps[0]], null, null, {})[0]; - const current = {}; - const next = togglePinnedGame(game, current); - expect(next).toEqual({ 'game-one': true }); - expect(current).toEqual({}); - expect(togglePinnedGame(game, next)).toEqual({}); - }); + it('toggles pins without mutating the current set', () => { + const game = buildLibraryGames([apps[0]], null, null, {})[0]; + const current = {}; + const next = togglePinnedGame(game, current); + expect(next).toEqual({ 'game-one': true }); + expect(current).toEqual({}); + expect(togglePinnedGame(game, next)).toEqual({}); + }); }); diff --git a/web-panel/src/library/model/games.ts b/web-panel/src/library/model/games.ts index 813e6fe..fed59af 100644 --- a/web-panel/src/library/model/games.ts +++ b/web-panel/src/library/model/games.ts @@ -1,5 +1,9 @@ import { formatHumanLabel } from '@/shared/lib/ui'; -import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from '../../../protocol/messages'; +import type { + GameStatusPayload, + InstalledAppSummary, + TrainerSummary, +} from '../../../protocol/messages'; export type LibraryGame = { id: string; @@ -26,24 +30,28 @@ export function buildLibraryGames( trainer: TrainerSummary | null, pinnedGameIds: Record, ): LibraryGame[] { - const activeGameId = status?.session.gameId ?? status?.trainer.gameId ?? trainer?.gameId ?? null; - const activeTitleId = status?.session.titleId ?? status?.trainer.titleId ?? trainer?.titleId ?? null; + const activeGameId = + status?.session.gameId ?? status?.trainer.gameId ?? trainer?.gameId ?? null; + const activeTitleId = + status?.session.titleId ?? status?.trainer.titleId ?? trainer?.titleId ?? null; - return apps.map((app) => { - const id = getInstalledAppId(app); - return { - id, - title: app.displayName, - platform: formatHumanLabel(app.platform), - hours: minutesToHours(app.platformTotalPlaytimeMinutes), - imageUrl: app.imageUrl ?? null, - app, - gameId: app.gameId ?? null, - titleId: app.titleId ?? null, - pinned: Boolean(pinnedGameIds[id]), - running: isActiveInstalledApp(app, activeGameId, activeTitleId), - }; - }).sort(compareLibraryGames); + return apps + .map((app) => { + const id = getInstalledAppId(app); + return { + id, + title: app.displayName, + platform: formatHumanLabel(app.platform), + hours: minutesToHours(app.platformTotalPlaytimeMinutes), + imageUrl: app.imageUrl ?? null, + app, + gameId: app.gameId ?? null, + titleId: app.titleId ?? null, + pinned: Boolean(pinnedGameIds[id]), + running: isActiveInstalledApp(app, activeGameId, activeTitleId), + }; + }) + .sort(compareLibraryGames); } export function getCurrentGame(games: LibraryGame[]): LibraryGame | null { @@ -115,7 +123,11 @@ function compareLibraryGames(left: LibraryGame, right: LibraryGame): number { return left.title.localeCompare(right.title); } -function isActiveInstalledApp(app: InstalledAppSummary, activeGameId: string | null, activeTitleId: string | null): boolean { +function isActiveInstalledApp( + app: InstalledAppSummary, + activeGameId: string | null, + activeTitleId: string | null, +): boolean { if (activeGameId && app.gameId === activeGameId) { return true; } diff --git a/web-panel/src/library/pinned-games/game-pin-storage.ts b/web-panel/src/library/pinned-games/game-pin-storage.ts index 6875412..be5291a 100644 --- a/web-panel/src/library/pinned-games/game-pin-storage.ts +++ b/web-panel/src/library/pinned-games/game-pin-storage.ts @@ -4,20 +4,23 @@ import type { LibraryGame } from '../model/games'; const STORAGE_KEY = 'wand-remote.pinned-games.v1'; export function loadPinnedGameIds(): Record { - return loadStringSet(STORAGE_KEY); + return loadStringSet(STORAGE_KEY); } export function savePinnedGameIds(pinnedGameIds: Record): void { - saveStringSet(STORAGE_KEY, pinnedGameIds); + saveStringSet(STORAGE_KEY, pinnedGameIds); } -export function togglePinnedGame(game: LibraryGame, pinnedGameIds: Record): Record { - const next = { ...pinnedGameIds }; - if (next[game.id]) { - delete next[game.id]; +export function togglePinnedGame( + game: LibraryGame, + pinnedGameIds: Record, +): Record { + const next = { ...pinnedGameIds }; + if (next[game.id]) { + delete next[game.id]; + return next; + } + + next[game.id] = true; return next; - } - - next[game.id] = true; - return next; } diff --git a/web-panel/src/library/pinned-games/use-game-pins.ts b/web-panel/src/library/pinned-games/use-game-pins.ts index a881528..6574f88 100644 --- a/web-panel/src/library/pinned-games/use-game-pins.ts +++ b/web-panel/src/library/pinned-games/use-game-pins.ts @@ -4,19 +4,19 @@ import type { LibraryGame } from '../model/games'; import { loadPinnedGameIds, savePinnedGameIds, togglePinnedGame } from './game-pin-storage'; export function useGamePins() { - const [pinnedGameIds, setPinnedGameIds] = useState>({}); + const [pinnedGameIds, setPinnedGameIds] = useState>({}); - useEffect(() => { - setPinnedGameIds(loadPinnedGameIds()); - }, []); + useEffect(() => { + setPinnedGameIds(loadPinnedGameIds()); + }, []); - const togglePin = useCallback((game: LibraryGame) => { - setPinnedGameIds((current) => { - const next = togglePinnedGame(game, current); - savePinnedGameIds(next); - return next; - }); - }, []); + const togglePin = useCallback((game: LibraryGame) => { + setPinnedGameIds((current) => { + const next = togglePinnedGame(game, current); + savePinnedGameIds(next); + return next; + }); + }, []); - return { pinnedGameIds, togglePin }; + return { pinnedGameIds, togglePin }; } diff --git a/web-panel/src/library/ui/GameCover.tsx b/web-panel/src/library/ui/GameCover.tsx index 0e315dc..967a780 100644 --- a/web-panel/src/library/ui/GameCover.tsx +++ b/web-panel/src/library/ui/GameCover.tsx @@ -3,30 +3,42 @@ import { useState } from 'react'; import { getGameCoverLabel, type LibraryGame } from '../model/games'; type GameCoverProps = { - game: LibraryGame; - size?: 'sm' | 'lg'; + game: LibraryGame; + size?: 'sm' | 'lg'; }; const SIZE_CLASSES: Record, string> = { - lg: 'size-16 rounded-[10px] text-[8px]', - sm: 'size-11 rounded-[9px] text-[7px]', + lg: 'size-16 rounded-[10px] text-[8px]', + sm: 'size-11 rounded-[9px] text-[7px]', }; export const GameCover = ({ game, size = 'sm' }: GameCoverProps) => { - const [failedUrl, setFailedUrl] = useState(null); - const sizeClass = SIZE_CLASSES[size]; - const imageUrl = game.imageUrl && game.imageUrl !== failedUrl ? game.imageUrl : null; + const [failedUrl, setFailedUrl] = useState(null); + const sizeClass = SIZE_CLASSES[size]; + const imageUrl = game.imageUrl && game.imageUrl !== failedUrl ? game.imageUrl : null; - const handleImageError = () => setFailedUrl(game.imageUrl ?? null); + const handleImageError = () => setFailedUrl(game.imageUrl ?? null); - return ( -
- {imageUrl ? : null} -
- {game.running ? : null} -
- {getGameCoverLabel(game)} -
-
- ); + return ( +
+ {imageUrl ? ( + + ) : null} +
+ {game.running ? ( + + ) : null} +
+ {getGameCoverLabel(game)} +
+
+ ); }; diff --git a/web-panel/src/library/ui/LibraryDrawer.tsx b/web-panel/src/library/ui/LibraryDrawer.tsx index e282875..87e06bc 100644 --- a/web-panel/src/library/ui/LibraryDrawer.tsx +++ b/web-panel/src/library/ui/LibraryDrawer.tsx @@ -1,150 +1,255 @@ -import { memo, useMemo, type ReactNode } from 'react'; import { msg } from '@lingui/core/macro'; -import { Plural, Trans } from '@lingui/react/macro'; import { useLingui } from '@lingui/react'; - +import { Plural, Trans } from '@lingui/react/macro'; +import { memo, type ReactNode, useMemo } from 'react'; +import { cn } from '@/shared/lib/ui'; import { Icon, type IconName } from '@/shared/ui/Icon'; import { IconButton } from '@/shared/ui/IconButton'; -import { cn } from '@/shared/lib/ui'; import { SearchInput } from '@/shared/ui/SearchInput'; -import { filterLibraryGames, formatHours, getLibrarySections, type LibraryGame } from '../model/games'; +import { + filterLibraryGames, + formatHours, + getLibrarySections, + type LibraryGame, +} from '../model/games'; import { GameCover } from './GameCover'; type LibraryDrawerProps = { - games: LibraryGame[]; - query: string; - canLaunch: boolean; - onClose: () => void; - onPin: (game: LibraryGame) => void; - onPlay: (game: LibraryGame) => void; - onStop: () => void; - onQueryChange: (query: string) => void; + games: LibraryGame[]; + query: string; + canLaunch: boolean; + onClose: () => void; + onPin: (game: LibraryGame) => void; + onPlay: (game: LibraryGame) => void; + onStop: () => void; + onQueryChange: (query: string) => void; }; -const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => { - const { _ } = useLingui(); - const filteredGames = useMemo(() => filterLibraryGames(games, query), [games, query]); - const sections = useMemo(() => getLibrarySections(filteredGames), [filteredGames]); +const LibraryDrawerBase = ({ + games, + query, + canLaunch, + onClose, + onPin, + onPlay, + onStop, + onQueryChange, +}: LibraryDrawerProps) => { + const { _ } = useLingui(); + const filteredGames = useMemo(() => filterLibraryGames(games, query), [games, query]); + const sections = useMemo(() => getLibrarySections(filteredGames), [filteredGames]); - return ( -
-
-
-

- Library -

-

- -

+ return ( +
+
+
+

+ Library +

+

+ +

+
+ +
+
+ +
+
+ {sections.running ? ( + + + + ) : null} + {sections.pinned.length > 0 ? ( + + {sections.pinned.map((game) => ( + + ))} + + ) : null} + {sections.rest.length > 0 ? ( + + {sections.rest.map((game) => ( + + ))} + + ) : null} + {filteredGames.length === 0 ? ( +

+ No games match "{query}" +

+ ) : null} +
- -
-
- -
-
- {sections.running ? ( - - - - ) : null} - {sections.pinned.length > 0 ? ( - - {sections.pinned.map((game) => )} - - ) : null} - {sections.rest.length > 0 ? ( - - {sections.rest.map((game) => )} - - ) : null} - {filteredGames.length === 0 ? ( -

- No games match "{query}" -

- ) : null} -
-
- ); + ); }; export const LibraryDrawer = memo(LibraryDrawerBase); type GameSectionProps = { - title: string; - count?: number; - icon?: IconName; - accent?: boolean; - children: ReactNode; + title: string; + count?: number; + icon?: IconName; + accent?: boolean; + children: ReactNode; }; const GameSection = ({ title, count, icon, accent = false, children }: GameSectionProps) => { - return ( -
-
- {icon ? : null} -

{title}

-
- {typeof count === 'number' ? {count} : null} -
- {children} -
- ); + return ( +
+
+ {icon ? ( + + ) : null} +

+ {title} +

+
+ {typeof count === 'number' ? ( + {count} + ) : null} +
+ {children} +
+ ); }; type GameRowProps = { - game: LibraryGame; - canLaunch: boolean; - query: string; - onPin: (game: LibraryGame) => void; - onPlay: (game: LibraryGame) => void; - onStop: () => void; + game: LibraryGame; + canLaunch: boolean; + query: string; + onPin: (game: LibraryGame) => void; + onPlay: (game: LibraryGame) => void; + onStop: () => void; }; const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps) => { - const { _ } = useLingui(); - const hours = formatHours(game.hours); - const handlePin = () => onPin(game); - const handlePlay = () => onPlay(game); + const { _ } = useLingui(); + const hours = formatHours(game.hours); + const handlePin = () => onPin(game); + const handlePlay = () => onPlay(game); - return ( -
- -
-

{highlightTitle(game.title, query)}

-
- {game.platform.toUpperCase()} - {hours ? · {hours} : null} -
-
-
- - {game.running ? ( - - ) : ( - - )} -
-
- ); + return ( +
+ +
+

+ {highlightTitle(game.title, query)} +

+
+ {game.platform.toUpperCase()} + {hours ? · {hours} : null} +
+
+
+ + {game.running ? ( + + ) : ( + + )} +
+
+ ); }; - function highlightTitle(title: string, query: string): ReactNode { - const normalized = query.trim().toLowerCase(); - if (!normalized) { - return title; - } + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return title; + } - const index = title.toLowerCase().indexOf(normalized); - if (index < 0) { - return title; - } + const index = title.toLowerCase().indexOf(normalized); + if (index < 0) { + return title; + } - const before = title.slice(0, index); - const match = title.slice(index, index + query.length); - const after = title.slice(index + query.length); - return <>{before}{match}{after}; + const before = title.slice(0, index); + const match = title.slice(index, index + query.length); + const after = title.slice(index + query.length); + return ( + <> + {before} + + {match} + + {after} + + ); } diff --git a/web-panel/src/po.d.ts b/web-panel/src/po.d.ts index c08a49d..e253829 100644 --- a/web-panel/src/po.d.ts +++ b/web-panel/src/po.d.ts @@ -1,4 +1,4 @@ declare module '*.po' { - import type { Messages } from '@lingui/core'; - export const messages: Messages; + import type { Messages } from '@lingui/core'; + export const messages: Messages; } diff --git a/web-panel/src/remote-session/remote-session.client.ts b/web-panel/src/remote-session/remote-session.client.ts index 2426e96..bc26366 100644 --- a/web-panel/src/remote-session/remote-session.client.ts +++ b/web-panel/src/remote-session/remote-session.client.ts @@ -1,133 +1,137 @@ import { WEB_CONTRACT } from '../../protocol/contract'; import { - type HelloMessage, - type IncomingMessage, - type OutgoingMessage, - PROTOCOL_VERSION, - type RemoteCommandMessage, - type SetValueMessage, + type HelloMessage, + type IncomingMessage, + type OutgoingMessage, + PROTOCOL_VERSION, + type RemoteCommandMessage, + type SetValueMessage, } from '../../protocol/messages'; import { isIncomingMessage } from '../../protocol/validation'; type SocketHandlers = { - onConnecting: () => void; - onTransportOpen: () => void; - onMessage: (message: IncomingMessage) => void; - onClose: () => void; - onError: (message: string) => void; + onConnecting: () => void; + onTransportOpen: () => void; + onMessage: (message: IncomingMessage) => void; + onClose: () => void; + onError: (message: string) => void; }; let requestSequence = 0; export class RemoteSessionClient { - private socket: WebSocket | null = null; - private intentionalDisconnect = false; + private socket: WebSocket | null = null; + private intentionalDisconnect = false; - constructor( - private readonly url: string, - private readonly handlers: SocketHandlers, - ) {} + constructor( + private readonly url: string, + private readonly handlers: SocketHandlers, + ) {} - connect(): void { - this.disconnect(); - this.intentionalDisconnect = false; - this.handlers.onConnecting(); + connect(): void { + this.disconnect(); + this.intentionalDisconnect = false; + this.handlers.onConnecting(); - const socket = new WebSocket(this.url); - this.socket = socket; + const socket = new WebSocket(this.url); + this.socket = socket; - socket.addEventListener('open', () => { - this.handlers.onTransportOpen(); - this.send(this.createHelloMessage()); - }); - socket.addEventListener('message', (event) => this.handleMessage(event)); - socket.addEventListener('close', () => { - if (this.socket === socket) { + socket.addEventListener('open', () => { + this.handlers.onTransportOpen(); + this.send(this.createHelloMessage()); + }); + socket.addEventListener('message', (event) => this.handleMessage(event)); + socket.addEventListener('close', () => { + if (this.socket === socket) { + this.socket = null; + } + if (!this.intentionalDisconnect) { + this.handlers.onClose(); + } + }); + socket.addEventListener('error', () => + this.handlers.onError('WebSocket connection failed.'), + ); + } + + disconnect(): void { + this.intentionalDisconnect = true; + this.socket?.close(); this.socket = null; - } - if (!this.intentionalDisconnect) { - this.handlers.onClose(); - } - }); - socket.addEventListener('error', () => this.handlers.onError('WebSocket connection failed.')); - } - - disconnect(): void { - this.intentionalDisconnect = true; - this.socket?.close(); - this.socket = null; - } - - isOpen(): boolean { - return Boolean(this.socket && this.socket.readyState === WebSocket.OPEN); - } - - setValue(trainerId: string, target: string, value: unknown, cheatId?: string): string | null { - const requestId = createRequestId(`set_${target}`); - const message: SetValueMessage = { - type: 'set_value', - version: PROTOCOL_VERSION, - requestId, - payload: { trainerId, target, value, cheatId }, - }; - return this.send(message) ? requestId : null; - } - - launchGame(gameId: string, titleId?: string): boolean { - return this.sendCommand('launch', gameId, titleId); - } - - stopPlaying(gameId?: string, titleId?: string): boolean { - return this.sendCommand('stop', gameId, titleId); - } - - private send(message: OutgoingMessage): boolean { - const socket = this.socket; - if (!socket || socket.readyState !== WebSocket.OPEN) { - return false; } - socket.send(JSON.stringify(message)); - return true; - } - private sendCommand(action: 'launch' | 'stop', gameId?: string, titleId?: string): boolean { - const message: RemoteCommandMessage = { - type: 'remote_command', - version: PROTOCOL_VERSION, - requestId: createRequestId(`command_${action}`), - payload: { action, gameId, titleId }, - }; - return this.send(message); - } + isOpen(): boolean { + return Boolean(this.socket && this.socket.readyState === WebSocket.OPEN); + } - private createHelloMessage(): HelloMessage { - return { - type: 'hello', - version: PROTOCOL_VERSION, - requestId: createRequestId('hello'), - payload: { - client: 'mobile-web', - clientVersion: WEB_CONTRACT.clientVersion, - capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true }, - }, - }; - } + setValue(trainerId: string, target: string, value: unknown, cheatId?: string): string | null { + const requestId = createRequestId(`set_${target}`); + const message: SetValueMessage = { + type: 'set_value', + version: PROTOCOL_VERSION, + requestId, + payload: { trainerId, target, value, cheatId }, + }; + return this.send(message) ? requestId : null; + } - private handleMessage(event: MessageEvent): void { - try { - const parsed = JSON.parse(String(event.data)) as unknown; - if (!isIncomingMessage(parsed)) { - this.handlers.onError('Received an invalid protocol message.'); - return; - } - this.handlers.onMessage(parsed); - } catch (error) { - this.handlers.onError(error instanceof Error ? error.message : 'Failed to parse websocket message.'); + launchGame(gameId: string, titleId?: string): boolean { + return this.sendCommand('launch', gameId, titleId); + } + + stopPlaying(gameId?: string, titleId?: string): boolean { + return this.sendCommand('stop', gameId, titleId); + } + + private send(message: OutgoingMessage): boolean { + const socket = this.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return false; + } + socket.send(JSON.stringify(message)); + return true; + } + + private sendCommand(action: 'launch' | 'stop', gameId?: string, titleId?: string): boolean { + const message: RemoteCommandMessage = { + type: 'remote_command', + version: PROTOCOL_VERSION, + requestId: createRequestId(`command_${action}`), + payload: { action, gameId, titleId }, + }; + return this.send(message); + } + + private createHelloMessage(): HelloMessage { + return { + type: 'hello', + version: PROTOCOL_VERSION, + requestId: createRequestId('hello'), + payload: { + client: 'mobile-web', + clientVersion: WEB_CONTRACT.clientVersion, + capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true }, + }, + }; + } + + private handleMessage(event: MessageEvent): void { + try { + const parsed = JSON.parse(String(event.data)) as unknown; + if (!isIncomingMessage(parsed)) { + this.handlers.onError('Received an invalid protocol message.'); + return; + } + this.handlers.onMessage(parsed); + } catch (error) { + this.handlers.onError( + error instanceof Error ? error.message : 'Failed to parse websocket message.', + ); + } } - } } function createRequestId(prefix: string): string { - requestSequence += 1; - return `${prefix}_${Date.now()}_${requestSequence}`; + requestSequence += 1; + return `${prefix}_${Date.now()}_${requestSequence}`; } diff --git a/web-panel/src/remote-session/remote-session.protocol.ts b/web-panel/src/remote-session/remote-session.protocol.ts index be3ba73..cb3000f 100644 --- a/web-panel/src/remote-session/remote-session.protocol.ts +++ b/web-panel/src/remote-session/remote-session.protocol.ts @@ -1,44 +1,52 @@ -import { PROTOCOL_VERSION, type IncomingMessage } from '../../protocol/messages'; +import { type IncomingMessage, PROTOCOL_VERSION } from '../../protocol/messages'; import type { RemoteSessionAction } from './remote-session.reducer'; export function protocolAction(message: IncomingMessage): RemoteSessionAction | null { - switch (message.type) { - case 'hello_ack': - if (!message.payload.accepted) { - return { type: 'error', message: 'The desktop bridge rejected the connection.' }; - } - if (message.payload.protocolVersion !== PROTOCOL_VERSION) { - return { - type: 'error', - message: `Protocol mismatch: bridge=${message.payload.protocolVersion}, panel=${PROTOCOL_VERSION}.`, - }; - } - return { type: 'connected' }; - case 'trainer_meta': - return { type: 'trainerMeta', payload: message.payload }; - case 'game_status': - return { type: 'gameStatus', payload: message.payload }; - case 'installed_apps': - return { type: 'installedApps', payload: message.payload }; - case 'trainer_values': - return { type: 'trainerValues', payload: message.payload.values }; - case 'value_changed': - return { type: 'valueChanged', target: message.payload.target, value: message.payload.value }; - case 'trainer_changed': - return { type: 'trainerChanged' }; - case 'set_value_result': - return { - type: 'writeResult', - target: message.payload.target, - requestId: message.requestId, - ok: message.payload.ok, - message: message.payload.error?.message, - }; - case 'remote_command_result': - return message.payload.ok - ? null - : { type: 'error', message: message.payload.error?.message ?? 'The remote game command was rejected.' }; - case 'error': - return { type: 'error', message: message.payload.message }; - } + switch (message.type) { + case 'hello_ack': + if (!message.payload.accepted) { + return { type: 'error', message: 'The desktop bridge rejected the connection.' }; + } + if (message.payload.protocolVersion !== PROTOCOL_VERSION) { + return { + type: 'error', + message: `Protocol mismatch: bridge=${message.payload.protocolVersion}, panel=${PROTOCOL_VERSION}.`, + }; + } + return { type: 'connected' }; + case 'trainer_meta': + return { type: 'trainerMeta', payload: message.payload }; + case 'game_status': + return { type: 'gameStatus', payload: message.payload }; + case 'installed_apps': + return { type: 'installedApps', payload: message.payload }; + case 'trainer_values': + return { type: 'trainerValues', payload: message.payload.values }; + case 'value_changed': + return { + type: 'valueChanged', + target: message.payload.target, + value: message.payload.value, + }; + case 'trainer_changed': + return { type: 'trainerChanged' }; + case 'set_value_result': + return { + type: 'writeResult', + target: message.payload.target, + requestId: message.requestId, + ok: message.payload.ok, + message: message.payload.error?.message, + }; + case 'remote_command_result': + return message.payload.ok + ? null + : { + type: 'error', + message: + message.payload.error?.message ?? 'The remote game command was rejected.', + }; + case 'error': + return { type: 'error', message: message.payload.message }; + } } diff --git a/web-panel/src/remote-session/remote-session.reducer.test.ts b/web-panel/src/remote-session/remote-session.reducer.test.ts index c546892..6e50a48 100644 --- a/web-panel/src/remote-session/remote-session.reducer.test.ts +++ b/web-panel/src/remote-session/remote-session.reducer.test.ts @@ -1,104 +1,142 @@ import { describe, expect, it } from 'vitest'; -import { PROTOCOL_VERSION, type HelloAckMessage } from '../../protocol/messages'; +import { type HelloAckMessage, PROTOCOL_VERSION } from '../../protocol/messages'; import { protocolAction } from './remote-session.protocol'; import { - createInitialRemoteSessionState, - EConnectionStatus, - remoteSessionReducer, - type RemoteSessionState, + createInitialRemoteSessionState, + EConnectionStatus, + type RemoteSessionState, + remoteSessionReducer, } from './remote-session.reducer'; describe('remote session protocol', () => { - it('connects only after an accepted compatible hello acknowledgement', () => { - const action = protocolAction(helloAck(PROTOCOL_VERSION)); - expect(action).toEqual({ type: 'connected' }); - }); + it('connects only after an accepted compatible hello acknowledgement', () => { + const action = protocolAction(helloAck(PROTOCOL_VERSION)); + expect(action).toEqual({ type: 'connected' }); + }); - it('rejects a protocol version mismatch', () => { - const action = protocolAction(helloAck(PROTOCOL_VERSION + 1)); - expect(action).toEqual({ - type: 'error', - message: `Protocol mismatch: bridge=${PROTOCOL_VERSION + 1}, panel=${PROTOCOL_VERSION}.`, + it('rejects a protocol version mismatch', () => { + const action = protocolAction(helloAck(PROTOCOL_VERSION + 1)); + expect(action).toEqual({ + type: 'error', + message: `Protocol mismatch: bridge=${PROTOCOL_VERSION + 1}, panel=${PROTOCOL_VERSION}.`, + }); }); - }); }); describe('remote session reducer', () => { - it('enters reconnecting state after an unexpected close', () => { - const state = { ...initialState(), connectionStatus: EConnectionStatus.Connected }; - const next = remoteSessionReducer(state, { type: 'connectionClosed', message: 'closed' }); + it('enters reconnecting state after an unexpected close', () => { + const state = { ...initialState(), connectionStatus: EConnectionStatus.Connected }; + const next = remoteSessionReducer(state, { type: 'connectionClosed', message: 'closed' }); - expect(next.connectionStatus).toBe(EConnectionStatus.Reconnecting); - expect(next.lastError).toBe('closed'); - }); + expect(next.connectionStatus).toBe(EConnectionStatus.Reconnecting); + expect(next.lastError).toBe('closed'); + }); - it('applies snapshots and clears trainer data on trainer switch', () => { - let state = remoteSessionReducer(initialState(), { type: 'trainerValues', payload: { speed: 2 } }); - state = remoteSessionReducer(state, { type: 'valueChanged', target: 'speed', value: 3 }); - expect(state.values.speed).toBe(3); + it('applies snapshots and clears trainer data on trainer switch', () => { + let state = remoteSessionReducer(initialState(), { + type: 'trainerValues', + payload: { speed: 2 }, + }); + state = remoteSessionReducer(state, { type: 'valueChanged', target: 'speed', value: 3 }); + expect(state.values.speed).toBe(3); - state = remoteSessionReducer(state, { type: 'trainerChanged' }); - expect(state.values).toEqual({}); - expect(state.pendingWrites).toEqual({}); - }); + state = remoteSessionReducer(state, { type: 'trainerChanged' }); + expect(state.values).toEqual({}); + expect(state.pendingWrites).toEqual({}); + }); - it('keeps a write pending until result and commits a successful result', () => { - let state = withConfirmedValue(1); - state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: 2, requestId: 'new' }); - expect(state.values.speed).toBe(2); - expect(state.pendingWrites.speed?.requestId).toBe('new'); + it('keeps a write pending until result and commits a successful result', () => { + let state = withConfirmedValue(1); + state = remoteSessionReducer(state, { + type: 'writeStarted', + target: 'speed', + value: 2, + requestId: 'new', + }); + expect(state.values.speed).toBe(2); + expect(state.pendingWrites.speed?.requestId).toBe('new'); - state = remoteSessionReducer(state, { type: 'writeResult', target: 'speed', requestId: 'new', ok: true }); - expect(state.confirmedValues.speed).toBe(2); - expect(state.pendingWrites.speed).toBeUndefined(); - }); + state = remoteSessionReducer(state, { + type: 'writeResult', + target: 'speed', + requestId: 'new', + ok: true, + }); + expect(state.confirmedValues.speed).toBe(2); + expect(state.pendingWrites.speed).toBeUndefined(); + }); - it('clears a write after a matching value delta', () => { - let state = withConfirmedValue(false); - state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: true, requestId: 'new' }); - state = remoteSessionReducer(state, { type: 'valueChanged', target: 'speed', value: true }); + it('clears a write after a matching value delta', () => { + let state = withConfirmedValue(false); + state = remoteSessionReducer(state, { + type: 'writeStarted', + target: 'speed', + value: true, + requestId: 'new', + }); + state = remoteSessionReducer(state, { type: 'valueChanged', target: 'speed', value: true }); - expect(state.pendingWrites.speed).toBeUndefined(); - expect(state.confirmedValues.speed).toBe(true); - }); + expect(state.pendingWrites.speed).toBeUndefined(); + expect(state.confirmedValues.speed).toBe(true); + }); - it('rolls back only the current rejected request', () => { - let state = withConfirmedValue(1); - state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: 2, requestId: 'old' }); - state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: 3, requestId: 'new' }); - state = remoteSessionReducer(state, { type: 'writeResult', target: 'speed', requestId: 'old', ok: false }); - expect(state.values.speed).toBe(3); - expect(state.pendingWrites.speed?.requestId).toBe('new'); + it('rolls back only the current rejected request', () => { + let state = withConfirmedValue(1); + state = remoteSessionReducer(state, { + type: 'writeStarted', + target: 'speed', + value: 2, + requestId: 'old', + }); + state = remoteSessionReducer(state, { + type: 'writeStarted', + target: 'speed', + value: 3, + requestId: 'new', + }); + state = remoteSessionReducer(state, { + type: 'writeResult', + target: 'speed', + requestId: 'old', + ok: false, + }); + expect(state.values.speed).toBe(3); + expect(state.pendingWrites.speed?.requestId).toBe('new'); - state = remoteSessionReducer(state, { type: 'writeResult', target: 'speed', requestId: 'new', ok: false }); - expect(state.values.speed).toBe(1); - expect(state.pendingWrites.speed).toBeUndefined(); - }); + state = remoteSessionReducer(state, { + type: 'writeResult', + target: 'speed', + requestId: 'new', + ok: false, + }); + expect(state.values.speed).toBe(1); + expect(state.pendingWrites.speed).toBeUndefined(); + }); }); function helloAck(protocolVersion: number): HelloAckMessage { - return { - type: 'hello_ack', - version: PROTOCOL_VERSION, - requestId: 'hello', - payload: { - sessionId: 'session', - accepted: true, - serverVersion: 'test', - protocolVersion, - }, - }; + return { + type: 'hello_ack', + version: PROTOCOL_VERSION, + requestId: 'hello', + payload: { + sessionId: 'session', + accepted: true, + serverVersion: 'test', + protocolVersion, + }, + }; } function initialState(): RemoteSessionState { - return { ...createInitialRemoteSessionState(), wsUrl: 'ws://test' }; + return { ...createInitialRemoteSessionState(), wsUrl: 'ws://test' }; } function withConfirmedValue(value: unknown): RemoteSessionState { - return { - ...initialState(), - values: { speed: value }, - confirmedValues: { speed: value }, - }; + return { + ...initialState(), + values: { speed: value }, + confirmedValues: { speed: value }, + }; } diff --git a/web-panel/src/remote-session/remote-session.reducer.ts b/web-panel/src/remote-session/remote-session.reducer.ts index eecd669..96e265a 100644 --- a/web-panel/src/remote-session/remote-session.reducer.ts +++ b/web-panel/src/remote-session/remote-session.reducer.ts @@ -1,192 +1,201 @@ import type { - GameStatusPayload, - InstalledAppSummary, - InstalledAppsPayload, - TrainerMetaPayload, + GameStatusPayload, + InstalledAppSummary, + InstalledAppsPayload, + TrainerMetaPayload, } from '../../protocol/messages'; import { readInitialWebSocketUrl } from './remote-session.urls'; export enum EConnectionStatus { - Idle = 'idle', - Connecting = 'connecting', - Reconnecting = 'reconnecting', - Connected = 'connected', - Error = 'error', + Idle = 'idle', + Connecting = 'connecting', + Reconnecting = 'reconnecting', + Connected = 'connected', + Error = 'error', } export type PendingWrite = { - requestId: string; - value: unknown; - previousConfirmedValue: unknown; + requestId: string; + value: unknown; + previousConfirmedValue: unknown; }; export type RemoteSessionState = { - connectionStatus: EConnectionStatus; - wsUrl: string; - trainerMeta: TrainerMetaPayload | null; - gameStatus: GameStatusPayload | null; - installedApps: InstalledAppSummary[]; - values: Record; - confirmedValues: Record; - pendingWrites: Record; - lastError: string | null; + connectionStatus: EConnectionStatus; + wsUrl: string; + trainerMeta: TrainerMetaPayload | null; + gameStatus: GameStatusPayload | null; + installedApps: InstalledAppSummary[]; + values: Record; + confirmedValues: Record; + pendingWrites: Record; + lastError: string | null; }; export type RemoteSessionAction = - | { type: 'setWsUrl'; wsUrl: string } - | { type: 'connecting'; reconnecting?: boolean } - | { type: 'connected' } - | { type: 'connectionClosed'; message: string } - | { type: 'disconnected' } - | { type: 'trainerMeta'; payload: TrainerMetaPayload } - | { type: 'gameStatus'; payload: GameStatusPayload } - | { type: 'installedApps'; payload: InstalledAppsPayload } - | { type: 'trainerValues'; payload: Record } - | { type: 'valueChanged'; target: string; value: unknown } - | { type: 'writeStarted'; target: string; value: unknown; requestId: string } - | { type: 'writeResult'; target: string; requestId: string | null; ok: boolean; message?: string } - | { type: 'trainerChanged' } - | { type: 'error'; message: string | null }; + | { type: 'setWsUrl'; wsUrl: string } + | { type: 'connecting'; reconnecting?: boolean } + | { type: 'connected' } + | { type: 'connectionClosed'; message: string } + | { type: 'disconnected' } + | { type: 'trainerMeta'; payload: TrainerMetaPayload } + | { type: 'gameStatus'; payload: GameStatusPayload } + | { type: 'installedApps'; payload: InstalledAppsPayload } + | { type: 'trainerValues'; payload: Record } + | { type: 'valueChanged'; target: string; value: unknown } + | { type: 'writeStarted'; target: string; value: unknown; requestId: string } + | { + type: 'writeResult'; + target: string; + requestId: string | null; + ok: boolean; + message?: string; + } + | { type: 'trainerChanged' } + | { type: 'error'; message: string | null }; export function createInitialRemoteSessionState(): RemoteSessionState { - return { - connectionStatus: EConnectionStatus.Idle, - wsUrl: readInitialWebSocketUrl(), - trainerMeta: null, - gameStatus: null, - installedApps: [], - values: {}, - confirmedValues: {}, - pendingWrites: {}, - lastError: null, - }; + return { + connectionStatus: EConnectionStatus.Idle, + wsUrl: readInitialWebSocketUrl(), + trainerMeta: null, + gameStatus: null, + installedApps: [], + values: {}, + confirmedValues: {}, + pendingWrites: {}, + lastError: null, + }; } export function remoteSessionReducer( - state: RemoteSessionState, - action: RemoteSessionAction, + state: RemoteSessionState, + action: RemoteSessionAction, ): RemoteSessionState { - switch (action.type) { - case 'setWsUrl': - return { ...state, wsUrl: action.wsUrl }; - case 'connecting': - return { - ...state, - connectionStatus: action.reconnecting ? EConnectionStatus.Reconnecting : EConnectionStatus.Connecting, - lastError: null, - }; - case 'connected': - return { ...state, connectionStatus: EConnectionStatus.Connected, lastError: null }; - case 'connectionClosed': - return { - ...state, - connectionStatus: EConnectionStatus.Reconnecting, - pendingWrites: {}, - lastError: action.message, - }; - case 'disconnected': - return { - ...state, - connectionStatus: EConnectionStatus.Idle, - trainerMeta: null, - gameStatus: null, - values: {}, - confirmedValues: {}, - pendingWrites: {}, - lastError: null, - }; - case 'trainerMeta': - return { ...state, trainerMeta: action.payload, pendingWrites: {} }; - case 'gameStatus': - return { ...state, gameStatus: action.payload }; - case 'installedApps': - return { ...state, installedApps: action.payload.apps }; - case 'trainerValues': - return { - ...state, - values: action.payload, - confirmedValues: action.payload, - pendingWrites: {}, - }; - case 'valueChanged': - return applyConfirmedValue(state, action.target, action.value); - case 'writeStarted': - return { - ...state, - values: { ...state.values, [action.target]: action.value }, - pendingWrites: { - ...state.pendingWrites, - [action.target]: { - requestId: action.requestId, - value: action.value, - previousConfirmedValue: state.confirmedValues[action.target], - }, - }, - }; - case 'writeResult': - return applyWriteResult(state, action); - case 'trainerChanged': - return { - ...state, - trainerMeta: null, - values: {}, - confirmedValues: {}, - pendingWrites: {}, - }; - case 'error': - return { - ...state, - connectionStatus: action.message && state.connectionStatus !== EConnectionStatus.Connected - ? EConnectionStatus.Error - : state.connectionStatus, - lastError: action.message, - }; - } + switch (action.type) { + case 'setWsUrl': + return { ...state, wsUrl: action.wsUrl }; + case 'connecting': + return { + ...state, + connectionStatus: action.reconnecting + ? EConnectionStatus.Reconnecting + : EConnectionStatus.Connecting, + lastError: null, + }; + case 'connected': + return { ...state, connectionStatus: EConnectionStatus.Connected, lastError: null }; + case 'connectionClosed': + return { + ...state, + connectionStatus: EConnectionStatus.Reconnecting, + pendingWrites: {}, + lastError: action.message, + }; + case 'disconnected': + return { + ...state, + connectionStatus: EConnectionStatus.Idle, + trainerMeta: null, + gameStatus: null, + values: {}, + confirmedValues: {}, + pendingWrites: {}, + lastError: null, + }; + case 'trainerMeta': + return { ...state, trainerMeta: action.payload, pendingWrites: {} }; + case 'gameStatus': + return { ...state, gameStatus: action.payload }; + case 'installedApps': + return { ...state, installedApps: action.payload.apps }; + case 'trainerValues': + return { + ...state, + values: action.payload, + confirmedValues: action.payload, + pendingWrites: {}, + }; + case 'valueChanged': + return applyConfirmedValue(state, action.target, action.value); + case 'writeStarted': + return { + ...state, + values: { ...state.values, [action.target]: action.value }, + pendingWrites: { + ...state.pendingWrites, + [action.target]: { + requestId: action.requestId, + value: action.value, + previousConfirmedValue: state.confirmedValues[action.target], + }, + }, + }; + case 'writeResult': + return applyWriteResult(state, action); + case 'trainerChanged': + return { + ...state, + trainerMeta: null, + values: {}, + confirmedValues: {}, + pendingWrites: {}, + }; + case 'error': + return { + ...state, + connectionStatus: + action.message && state.connectionStatus !== EConnectionStatus.Connected + ? EConnectionStatus.Error + : state.connectionStatus, + lastError: action.message, + }; + } } function applyConfirmedValue( - state: RemoteSessionState, - target: string, - value: unknown, + state: RemoteSessionState, + target: string, + value: unknown, ): RemoteSessionState { - const pending = state.pendingWrites[target]; - const pendingWrites = { ...state.pendingWrites }; - if (pending && Object.is(pending.value, value)) { - delete pendingWrites[target]; - } + const pending = state.pendingWrites[target]; + const pendingWrites = { ...state.pendingWrites }; + if (pending && Object.is(pending.value, value)) { + delete pendingWrites[target]; + } - return { - ...state, - values: { ...state.values, [target]: value }, - confirmedValues: { ...state.confirmedValues, [target]: value }, - pendingWrites, - }; + return { + ...state, + values: { ...state.values, [target]: value }, + confirmedValues: { ...state.confirmedValues, [target]: value }, + pendingWrites, + }; } function applyWriteResult( - state: RemoteSessionState, - action: Extract, + state: RemoteSessionState, + action: Extract, ): RemoteSessionState { - const pending = state.pendingWrites[action.target]; - if (!pending || !action.requestId || pending.requestId !== action.requestId) { - return state; - } + const pending = state.pendingWrites[action.target]; + if (!pending || !action.requestId || pending.requestId !== action.requestId) { + return state; + } - const pendingWrites = { ...state.pendingWrites }; - delete pendingWrites[action.target]; + const pendingWrites = { ...state.pendingWrites }; + delete pendingWrites[action.target]; + + if (action.ok) { + return { + ...state, + confirmedValues: { ...state.confirmedValues, [action.target]: pending.value }, + pendingWrites, + }; + } - if (action.ok) { return { - ...state, - confirmedValues: { ...state.confirmedValues, [action.target]: pending.value }, - pendingWrites, + ...state, + values: { ...state.values, [action.target]: pending.previousConfirmedValue }, + pendingWrites, + lastError: action.message ?? 'The trainer rejected the requested value.', }; - } - - return { - ...state, - values: { ...state.values, [action.target]: pending.previousConfirmedValue }, - pendingWrites, - lastError: action.message ?? 'The trainer rejected the requested value.', - }; } diff --git a/web-panel/src/remote-session/remote-session.urls.ts b/web-panel/src/remote-session/remote-session.urls.ts index a1378e1..4ef93b1 100644 --- a/web-panel/src/remote-session/remote-session.urls.ts +++ b/web-panel/src/remote-session/remote-session.urls.ts @@ -5,23 +5,26 @@ export const WS_QUERY_PARAM = 'ws'; const DEV_SERVER_PORTS = new Set(WEB_CONTRACT.devServerPorts.map(String)); function protocolForWebSocket(): 'ws' | 'wss' { - return window.location.protocol === 'https:' ? 'wss' : 'ws'; + return window.location.protocol === 'https:' ? 'wss' : 'ws'; } function isServedByRemoteBridge(): boolean { - return window.location.pathname.startsWith(WEB_CONTRACT.basePath) && !DEV_SERVER_PORTS.has(window.location.port); + return ( + window.location.pathname.startsWith(WEB_CONTRACT.basePath) && + !DEV_SERVER_PORTS.has(window.location.port) + ); } export function readInitialWebSocketUrl(): string { - const params = new URLSearchParams(window.location.search); - const explicitUrl = params.get(WS_QUERY_PARAM)?.trim(); - if (explicitUrl) { - return explicitUrl; - } + const params = new URLSearchParams(window.location.search); + const explicitUrl = params.get(WS_QUERY_PARAM)?.trim(); + if (explicitUrl) { + return explicitUrl; + } - if (isServedByRemoteBridge()) { - return `${protocolForWebSocket()}://${window.location.host}${WEB_CONTRACT.webSocketPath}`; - } + if (isServedByRemoteBridge()) { + return `${protocolForWebSocket()}://${window.location.host}${WEB_CONTRACT.webSocketPath}`; + } - return `ws://127.0.0.1:${WEB_CONTRACT.defaultRemotePort}${WEB_CONTRACT.webSocketPath}`; + return `ws://127.0.0.1:${WEB_CONTRACT.defaultRemotePort}${WEB_CONTRACT.webSocketPath}`; } diff --git a/web-panel/src/remote-session/selectors.ts b/web-panel/src/remote-session/selectors.ts index 798ad7d..8557921 100644 --- a/web-panel/src/remote-session/selectors.ts +++ b/web-panel/src/remote-session/selectors.ts @@ -1,9 +1,9 @@ import { EConnectionStatus, type RemoteSessionState } from './remote-session.reducer'; export function selectIsConnected(state: RemoteSessionState): boolean { - return state.connectionStatus === EConnectionStatus.Connected; + return state.connectionStatus === EConnectionStatus.Connected; } export function selectPendingTargets(state: RemoteSessionState): Record { - return Object.fromEntries(Object.keys(state.pendingWrites).map((target) => [target, true])); + return Object.fromEntries(Object.keys(state.pendingWrites).map((target) => [target, true])); } diff --git a/web-panel/src/remote-session/use-remote-session.ts b/web-panel/src/remote-session/use-remote-session.ts index 8656652..68d320c 100644 --- a/web-panel/src/remote-session/use-remote-session.ts +++ b/web-panel/src/remote-session/use-remote-session.ts @@ -1,202 +1,226 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; -import { type CheatSchema, type InstalledAppSummary } from '../../protocol/messages'; +import type { CheatSchema, InstalledAppSummary } from '../../protocol/messages'; import { normalizeCheatValue } from '../trainer/model/values'; import { RemoteSessionClient } from './remote-session.client'; -import { - createInitialRemoteSessionState, - EConnectionStatus, - remoteSessionReducer, - type RemoteSessionState, -} from './remote-session.reducer'; import { protocolAction } from './remote-session.protocol'; +import { + createInitialRemoteSessionState, + EConnectionStatus, + type RemoteSessionState, + remoteSessionReducer, +} from './remote-session.reducer'; import { selectIsConnected, selectPendingTargets } from './selectors'; const RECONNECT_BASE_DELAY_MS = 2000; const RECONNECT_MAX_DELAY_MS = 30000; export function useRemoteSession() { - const [state, dispatch] = useReducer(remoteSessionReducer, undefined, createInitialRemoteSessionState); - const stateRef = useRef(state); - const clientRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const reconnectAttemptRef = useRef(0); - // Set when the user disconnects on purpose, so refocus does not silently reconnect. - const userDisconnectedRef = useRef(false); - const connectRef = useRef<() => void>(() => {}); - useEffect(() => { - stateRef.current = state; - }, [state]); + const [state, dispatch] = useReducer( + remoteSessionReducer, + undefined, + createInitialRemoteSessionState, + ); + const stateRef = useRef(state); + const clientRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const reconnectAttemptRef = useRef(0); + // Set when the user disconnects on purpose, so refocus does not silently reconnect. + const userDisconnectedRef = useRef(false); + const connectRef = useRef<() => void>(() => {}); + useEffect(() => { + stateRef.current = state; + }, [state]); - const clearReconnect = useCallback(() => { - if (reconnectTimeoutRef.current !== null) { - window.clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - }, []); + const clearReconnect = useCallback(() => { + if (reconnectTimeoutRef.current !== null) { + window.clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }, []); - const scheduleReconnect = useCallback(() => { - clearReconnect(); - if (document.visibilityState !== 'visible' || userDisconnectedRef.current) { - return; - } + const scheduleReconnect = useCallback(() => { + clearReconnect(); + if (document.visibilityState !== 'visible' || userDisconnectedRef.current) { + return; + } - // Back off: the bridge is usually down because Wand is closed, and a phone - // retrying every 2s until the tab is hidden drains the battery for nothing. - const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttemptRef.current, RECONNECT_MAX_DELAY_MS); - reconnectAttemptRef.current += 1; + // Back off: the bridge is usually down because Wand is closed, and a phone + // retrying every 2s until the tab is hidden drains the battery for nothing. + const delay = Math.min( + RECONNECT_BASE_DELAY_MS * 2 ** reconnectAttemptRef.current, + RECONNECT_MAX_DELAY_MS, + ); + reconnectAttemptRef.current += 1; - reconnectTimeoutRef.current = window.setTimeout(() => { - if (document.visibilityState === 'visible' && stateRef.current.wsUrl.trim()) { - connectRef.current(); - } - }, delay); - }, [clearReconnect]); + reconnectTimeoutRef.current = window.setTimeout(() => { + if (document.visibilityState === 'visible' && stateRef.current.wsUrl.trim()) { + connectRef.current(); + } + }, delay); + }, [clearReconnect]); - const connect = useCallback(() => { - clientRef.current?.disconnect(); - clearReconnect(); - userDisconnectedRef.current = false; + const connect = useCallback(() => { + clientRef.current?.disconnect(); + clearReconnect(); + userDisconnectedRef.current = false; - const wsUrl = stateRef.current.wsUrl.trim(); - if (!wsUrl) { - dispatch({ type: 'error', message: 'Enter a WebSocket URL first.' }); - return; - } + const wsUrl = stateRef.current.wsUrl.trim(); + if (!wsUrl) { + dispatch({ type: 'error', message: 'Enter a WebSocket URL first.' }); + return; + } - const client = new RemoteSessionClient(wsUrl, { - onConnecting: () => dispatch({ - type: 'connecting', - reconnecting: stateRef.current.connectionStatus === EConnectionStatus.Reconnecting, - }), - onTransportOpen: () => { + const client = new RemoteSessionClient(wsUrl, { + onConnecting: () => + dispatch({ + type: 'connecting', + reconnecting: + stateRef.current.connectionStatus === EConnectionStatus.Reconnecting, + }), + onTransportOpen: () => { + reconnectAttemptRef.current = 0; + }, + onMessage: (message) => { + const action = protocolAction(message); + if (!action) return; + dispatch(action); + }, + onClose: () => { + dispatch({ + type: 'connectionClosed', + message: 'The WebSocket connection closed. Reconnecting...', + }); + scheduleReconnect(); + }, + onError: (message) => dispatch({ type: 'error', message }), + }); + + clientRef.current = client; + client.connect(); + }, [clearReconnect, scheduleReconnect]); + + const disconnect = useCallback(() => { + userDisconnectedRef.current = true; reconnectAttemptRef.current = 0; - }, - onMessage: (message) => { - const action = protocolAction(message); - if (!action) return; - dispatch(action); - }, - onClose: () => { - dispatch({ type: 'connectionClosed', message: 'The WebSocket connection closed. Reconnecting...' }); - scheduleReconnect(); - }, - onError: (message) => dispatch({ type: 'error', message }), - }); + clearReconnect(); + clientRef.current?.disconnect(); + clientRef.current = null; + dispatch({ type: 'disconnected' }); + }, [clearReconnect]); - clientRef.current = client; - client.connect(); - }, [clearReconnect, scheduleReconnect]); + const setWsUrl = useCallback((wsUrl: string) => dispatch({ type: 'setWsUrl', wsUrl }), []); + const reportError = useCallback( + (message: string | null) => dispatch({ type: 'error', message }), + [], + ); - const disconnect = useCallback(() => { - userDisconnectedRef.current = true; - reconnectAttemptRef.current = 0; - clearReconnect(); - clientRef.current?.disconnect(); - clientRef.current = null; - dispatch({ type: 'disconnected' }); - }, [clearReconnect]); + const changeCheat = useCallback((cheat: CheatSchema, nextValue: unknown) => { + const current = stateRef.current; + if (current.connectionStatus !== EConnectionStatus.Connected || !current.trainerMeta) { + dispatch({ type: 'error', message: 'The bridge socket is not connected.' }); + return false; + } - const setWsUrl = useCallback((wsUrl: string) => dispatch({ type: 'setWsUrl', wsUrl }), []); - const reportError = useCallback((message: string | null) => dispatch({ type: 'error', message }), []); + const value = normalizeCheatValue(cheat, nextValue); + const requestId = + clientRef.current?.setValue( + current.trainerMeta.trainer.trainerId, + cheat.target, + value, + cheat.uuid, + ) ?? null; + if (!requestId) { + dispatch({ type: 'error', message: 'The bridge socket is not open.' }); + return false; + } - const changeCheat = useCallback((cheat: CheatSchema, nextValue: unknown) => { - const current = stateRef.current; - if (current.connectionStatus !== EConnectionStatus.Connected || !current.trainerMeta) { - dispatch({ type: 'error', message: 'The bridge socket is not connected.' }); - return false; - } + dispatch({ type: 'writeStarted', target: cheat.target, value, requestId }); + return true; + }, []); - const value = normalizeCheatValue(cheat, nextValue); - const requestId = clientRef.current?.setValue( - current.trainerMeta.trainer.trainerId, - cheat.target, - value, - cheat.uuid, - ) ?? null; - if (!requestId) { - dispatch({ type: 'error', message: 'The bridge socket is not open.' }); - return false; - } + const launchGame = useCallback((app: InstalledAppSummary): boolean => { + if (!app.gameId) { + dispatch({ + type: 'error', + message: 'This My Games entry does not expose a Wand game id.', + }); + return false; + } + if (!isReadyToSend(stateRef.current, clientRef.current)) { + dispatch({ type: 'error', message: 'The bridge socket is not connected.' }); + return false; + } + if (!clientRef.current?.launchGame(app.gameId, app.titleId ?? undefined)) { + dispatch({ + type: 'error', + message: 'Failed to send the launch command to the bridge.', + }); + return false; + } + return true; + }, []); - dispatch({ type: 'writeStarted', target: cheat.target, value, requestId }); - return true; - }, []); + const stopPlaying = useCallback(() => { + const current = stateRef.current; + if (!isReadyToSend(current, clientRef.current)) { + dispatch({ type: 'error', message: 'The bridge socket is not connected.' }); + return; + } + const gameId = + current.gameStatus?.session.gameId ?? current.gameStatus?.trainer.gameId ?? undefined; + const titleId = + current.gameStatus?.session.titleId ?? current.gameStatus?.trainer.titleId ?? undefined; + if (!clientRef.current?.stopPlaying(gameId, titleId)) { + dispatch({ type: 'error', message: 'Failed to send the stop command to the bridge.' }); + } + }, []); - const launchGame = useCallback((app: InstalledAppSummary): boolean => { - if (!app.gameId) { - dispatch({ type: 'error', message: 'This My Games entry does not expose a Wand game id.' }); - return false; - } - if (!isReadyToSend(stateRef.current, clientRef.current)) { - dispatch({ type: 'error', message: 'The bridge socket is not connected.' }); - return false; - } - if (!clientRef.current?.launchGame(app.gameId, app.titleId ?? undefined)) { - dispatch({ type: 'error', message: 'Failed to send the launch command to the bridge.' }); - return false; - } - return true; - }, []); + useEffect(() => { + connectRef.current = connect; + }, [connect]); - const stopPlaying = useCallback(() => { - const current = stateRef.current; - if (!isReadyToSend(current, clientRef.current)) { - dispatch({ type: 'error', message: 'The bridge socket is not connected.' }); - return; - } - const gameId = current.gameStatus?.session.gameId ?? current.gameStatus?.trainer.gameId ?? undefined; - const titleId = current.gameStatus?.session.titleId ?? current.gameStatus?.trainer.titleId ?? undefined; - if (!clientRef.current?.stopPlaying(gameId, titleId)) { - dispatch({ type: 'error', message: 'Failed to send the stop command to the bridge.' }); - } - }, []); + useEffect(() => { + const onVisibilityChange = () => { + if (document.visibilityState !== 'visible' || userDisconnectedRef.current) { + return; + } + if (!clientRef.current?.isOpen()) { + connectRef.current(); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + return () => document.removeEventListener('visibilitychange', onVisibilityChange); + }, []); - useEffect(() => { - connectRef.current = connect; - }, [connect]); + useEffect(() => { + if (stateRef.current.wsUrl.trim()) { + connectRef.current(); + } + return () => { + clearReconnect(); + clientRef.current?.disconnect(); + clientRef.current = null; + }; + }, [clearReconnect]); - useEffect(() => { - const onVisibilityChange = () => { - if (document.visibilityState !== 'visible' || userDisconnectedRef.current) { - return; - } - if (!clientRef.current?.isOpen()) { - connectRef.current(); - } + const connected = selectIsConnected(state); + const pendingTargets = useMemo(() => selectPendingTargets(state), [state]); + + return { + state, + connected, + pendingTargets, + connect, + disconnect, + setWsUrl, + reportError, + changeCheat, + launchGame, + stopPlaying, }; - document.addEventListener('visibilitychange', onVisibilityChange); - return () => document.removeEventListener('visibilitychange', onVisibilityChange); - }, []); - - useEffect(() => { - if (stateRef.current.wsUrl.trim()) { - connectRef.current(); - } - return () => { - clearReconnect(); - clientRef.current?.disconnect(); - clientRef.current = null; - }; - }, [clearReconnect]); - - const connected = selectIsConnected(state); - const pendingTargets = useMemo(() => selectPendingTargets(state), [state]); - - return { - state, - connected, - pendingTargets, - connect, - disconnect, - setWsUrl, - reportError, - changeCheat, - launchGame, - stopPlaying, - }; } function isReadyToSend(state: RemoteSessionState, client: RemoteSessionClient | null): boolean { - return state.connectionStatus === EConnectionStatus.Connected && Boolean(client?.isOpen()); + return state.connectionStatus === EConnectionStatus.Connected && Boolean(client?.isOpen()); } diff --git a/web-panel/src/shared/lib/ui.ts b/web-panel/src/shared/lib/ui.ts index 202749f..5e08e08 100644 --- a/web-panel/src/shared/lib/ui.ts +++ b/web-panel/src/shared/lib/ui.ts @@ -1,40 +1,47 @@ -type ClassValue = string | number | false | null | undefined | ClassValue[] | Record +type ClassValue = + | string + | number + | false + | null + | undefined + | ClassValue[] + | Record; export function formatHumanLabel(value: string): string { - return value - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .replace(/\b\w/g, (letter) => letter.toUpperCase()); + return value + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\b\w/g, (letter) => letter.toUpperCase()); } export function cn(...inputs: ClassValue[]) { - const classes: string[] = [] + const classes: string[] = []; - for (const input of inputs) { - if (!input) { - continue + for (const input of inputs) { + if (!input) { + continue; + } + + if (typeof input === 'string' || typeof input === 'number') { + classes.push(String(input)); + continue; + } + + if (Array.isArray(input)) { + const value = cn(...input); + if (value) { + classes.push(value); + } + continue; + } + + for (const [key, enabled] of Object.entries(input)) { + if (enabled) { + classes.push(key); + } + } } - if (typeof input === "string" || typeof input === "number") { - classes.push(String(input)) - continue - } - - if (Array.isArray(input)) { - const value = cn(...input) - if (value) { - classes.push(value) - } - continue - } - - for (const [key, enabled] of Object.entries(input)) { - if (enabled) { - classes.push(key) - } - } - } - - return classes.join(" ") + return classes.join(' '); } diff --git a/web-panel/src/shared/storage.test.ts b/web-panel/src/shared/storage.test.ts index ecb4823..6ab1b08 100644 --- a/web-panel/src/shared/storage.test.ts +++ b/web-panel/src/shared/storage.test.ts @@ -3,35 +3,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { loadStringSet, saveStringSet } from './storage'; describe('storage revival', () => { - beforeEach(() => localStorage.clear()); - afterEach(() => vi.restoreAllMocks()); + beforeEach(() => localStorage.clear()); + afterEach(() => vi.restoreAllMocks()); - it('revives only valid string ids', () => { - localStorage.setItem('pins', JSON.stringify(['one', '', 2, 'two'])); - expect(loadStringSet('pins')).toEqual({ one: true, two: true }); - }); - - it('removes empty sets', () => { - expect(saveStringSet('pins', { one: true })).toBe(true); - expect(localStorage.getItem('pins')).toBe(JSON.stringify(['one'])); - expect(saveStringSet('pins', {})).toBe(true); - expect(localStorage.getItem('pins')).toBeNull(); - }); - - it('reports a failed browser storage write', () => { - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new DOMException('Storage disabled', 'SecurityError'); + it('revives only valid string ids', () => { + localStorage.setItem('pins', JSON.stringify(['one', '', 2, 'two'])); + expect(loadStringSet('pins')).toEqual({ one: true, two: true }); }); - expect(saveStringSet('pins', { one: true })).toBe(false); - }); - - it('handles browsers that block access to local storage', () => { - vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { - throw new DOMException('Storage disabled', 'SecurityError'); + it('removes empty sets', () => { + expect(saveStringSet('pins', { one: true })).toBe(true); + expect(localStorage.getItem('pins')).toBe(JSON.stringify(['one'])); + expect(saveStringSet('pins', {})).toBe(true); + expect(localStorage.getItem('pins')).toBeNull(); }); - expect(saveStringSet('pins', { one: true })).toBe(false); - expect(loadStringSet('pins')).toEqual({}); - }); + it('reports a failed browser storage write', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Storage disabled', 'SecurityError'); + }); + + expect(saveStringSet('pins', { one: true })).toBe(false); + }); + + it('handles browsers that block access to local storage', () => { + vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { + throw new DOMException('Storage disabled', 'SecurityError'); + }); + + expect(saveStringSet('pins', { one: true })).toBe(false); + expect(loadStringSet('pins')).toEqual({}); + }); }); diff --git a/web-panel/src/shared/storage.ts b/web-panel/src/shared/storage.ts index d9a1a2f..9237724 100644 --- a/web-panel/src/shared/storage.ts +++ b/web-panel/src/shared/storage.ts @@ -3,108 +3,112 @@ import type { TrainerSummary } from '../../protocol/messages'; type Reviver = (raw: unknown) => T | null; function getStore(): Storage | null { - try { - return typeof window === 'undefined' ? null : window.localStorage; - } catch { - return null; - } + try { + return typeof window === 'undefined' ? null : window.localStorage; + } catch { + return null; + } } export function getTrainerStorageId(trainer: TrainerSummary | null | undefined): string | null { - if (!trainer) { - return null; - } + if (!trainer) { + return null; + } - const id = trainer.gameId?.trim() || trainer.titleId?.trim() || trainer.trainerId?.trim(); - return id || null; + const id = trainer.gameId?.trim() || trainer.titleId?.trim() || trainer.trainerId?.trim(); + return id || null; } export function loadString(key: string | null): string | null { - const store = getStore(); - if (!key || !store) { - return null; - } + const store = getStore(); + if (!key || !store) { + return null; + } - try { - return store.getItem(key); - } catch { - return null; - } + try { + return store.getItem(key); + } catch { + return null; + } } export function saveString(key: string | null, value: string): boolean { - const store = getStore(); - if (!key || !store) { - return false; - } + const store = getStore(); + if (!key || !store) { + return false; + } - try { - store.setItem(key, value); - return true; - } catch { - return false; - } + try { + store.setItem(key, value); + return true; + } catch { + return false; + } } export function loadJson(key: string | null, revive: Reviver, fallback: T): T { - const store = getStore(); - if (!key || !store) { - return fallback; - } - - try { - const raw = store.getItem(key); - if (!raw) { - return fallback; + const store = getStore(); + if (!key || !store) { + return fallback; } - return revive(JSON.parse(raw) as unknown) ?? fallback; - } catch { - return fallback; - } + try { + const raw = store.getItem(key); + if (!raw) { + return fallback; + } + + return revive(JSON.parse(raw) as unknown) ?? fallback; + } catch { + return fallback; + } } -export function saveJson(key: string | null, value: unknown, isEmpty: (value: unknown) => boolean): boolean { - const store = getStore(); - if (!key || !store) { - return false; - } - - try { - if (isEmpty(value)) { - store.removeItem(key); - return true; +export function saveJson( + key: string | null, + value: unknown, + isEmpty: (value: unknown) => boolean, +): boolean { + const store = getStore(); + if (!key || !store) { + return false; } - store.setItem(key, JSON.stringify(value)); - return true; - } catch { - return false; - } + try { + if (isEmpty(value)) { + store.removeItem(key); + return true; + } + + store.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } } export function loadStringSet(key: string | null): Record { - return loadJson>( - key, - (raw) => { - if (!Array.isArray(raw)) { - return null; - } + return loadJson>( + key, + (raw) => { + if (!Array.isArray(raw)) { + return null; + } - const result: Record = {}; - for (const value of raw) { - if (typeof value === 'string' && value.length > 0) { - result[value] = true; - } - } + const result: Record = {}; + for (const value of raw) { + if (typeof value === 'string' && value.length > 0) { + result[value] = true; + } + } - return result; - }, - {}, - ); + return result; + }, + {}, + ); } export function saveStringSet(key: string | null, value: Record): boolean { - const ids = Object.keys(value); - return saveJson(key, ids, () => ids.length === 0); + const ids = Object.keys(value); + return saveJson(key, ids, () => ids.length === 0); } diff --git a/web-panel/src/shared/ui/Drawer.tsx b/web-panel/src/shared/ui/Drawer.tsx index 51995d7..ea72ab0 100644 --- a/web-panel/src/shared/ui/Drawer.tsx +++ b/web-panel/src/shared/ui/Drawer.tsx @@ -1,113 +1,125 @@ -import { useEffect, useRef, type ReactNode } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { type ReactNode, useEffect, useRef } from 'react'; import { cn } from '@/shared/lib/ui'; const DRAWER_SIDE_CLASSES = { - left: 'left-0 border-r', - right: 'right-0 border-l', + left: 'left-0 border-r', + right: 'right-0 border-l', } as const; const DRAWER_CLOSED_CLASSES = { - left: '-translate-x-full', - right: 'translate-x-full', + left: '-translate-x-full', + right: 'translate-x-full', } as const; -const DRAWER_OVERLAY_CLASS = 'remote-drawer-overlay absolute inset-0 z-30 transition-opacity duration-200 ease-out motion-reduce:transition-none'; -const DRAWER_PANEL_CLASS = 'remote-drawer-panel absolute bottom-0 top-0 z-40 flex w-[88%] max-w-90 flex-col border-white/10 transition-transform duration-300 ease-out motion-reduce:transition-none'; +const DRAWER_OVERLAY_CLASS = + 'remote-drawer-overlay absolute inset-0 z-30 transition-opacity duration-200 ease-out motion-reduce:transition-none'; +const DRAWER_PANEL_CLASS = + 'remote-drawer-panel absolute bottom-0 top-0 z-40 flex w-[88%] max-w-90 flex-col border-white/10 transition-transform duration-300 ease-out motion-reduce:transition-none'; -const FOCUSABLE_SELECTOR = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; +const FOCUSABLE_SELECTOR = + 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; type DrawerProps = { - open: boolean; - side: 'left' | 'right'; - /** Accessible name for the dialog; an icon-only drawer has no other one. */ - label: string; - children: ReactNode; - onClose: () => void; + open: boolean; + side: 'left' | 'right'; + /** Accessible name for the dialog; an icon-only drawer has no other one. */ + label: string; + children: ReactNode; + onClose: () => void; }; export const Drawer = ({ open, side, label, children, onClose }: DrawerProps) => { - const { _ } = useLingui(); - const panelRef = useRef(null); - const restoreFocusRef = useRef(null); - const sideClassName = DRAWER_SIDE_CLASSES[side]; - const closedClassName = DRAWER_CLOSED_CLASSES[side]; + const { _ } = useLingui(); + const panelRef = useRef(null); + const restoreFocusRef = useRef(null); + const sideClassName = DRAWER_SIDE_CLASSES[side]; + const closedClassName = DRAWER_CLOSED_CLASSES[side]; - useEffect(() => { - if (!open) { - return; - } + useEffect(() => { + if (!open) { + return; + } - restoreFocusRef.current = document.activeElement as HTMLElement | null; - focusFirst(panelRef.current); + restoreFocusRef.current = document.activeElement as HTMLElement | null; + focusFirst(panelRef.current); - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose(); - return; - } - if (event.key === 'Tab') { - trapTab(event, panelRef.current); - } - }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + return; + } + if (event.key === 'Tab') { + trapTab(event, panelRef.current); + } + }; - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - restoreFocusRef.current?.focus?.(); - }; - }, [open, onClose]); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + restoreFocusRef.current?.focus?.(); + }; + }, [open, onClose]); - return ( - <> - - ); + return ( + + ); }; diff --git a/web-panel/src/shared/ui/SearchInput.tsx b/web-panel/src/shared/ui/SearchInput.tsx index 1690d19..1d5d74a 100644 --- a/web-panel/src/shared/ui/SearchInput.tsx +++ b/web-panel/src/shared/ui/SearchInput.tsx @@ -1,39 +1,47 @@ -import type { FormEvent } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; - +import type { FormEvent } from 'react'; +import { cn } from '@/shared/lib/ui'; import { Icon } from '@/shared/ui/Icon'; -import { cn } from '@/shared/lib/ui'; - type SearchInputProps = { - value: string; - placeholder: string; - className?: string; - onChange: (value: string) => void; + value: string; + placeholder: string; + className?: string; + onChange: (value: string) => void; }; export const SearchInput = ({ value, placeholder, className, onChange }: SearchInputProps) => { - const { _ } = useLingui(); - const handleInput = (event: FormEvent) => onChange(event.currentTarget.value); - const handleClear = () => onChange(''); + const { _ } = useLingui(); + const handleInput = (event: FormEvent) => onChange(event.currentTarget.value); + const handleClear = () => onChange(''); - return ( -
- - - {value ? ( - - ) : null} -
- ); + return ( +
+ + + {value ? ( + + ) : null} +
+ ); }; diff --git a/web-panel/src/trainer/controls/ActionButton.tsx b/web-panel/src/trainer/controls/ActionButton.tsx index 5790498..7770f78 100644 --- a/web-panel/src/trainer/controls/ActionButton.tsx +++ b/web-panel/src/trainer/controls/ActionButton.tsx @@ -6,19 +6,19 @@ import { Icon } from '@/shared/ui/Icon'; import type { ControlInternalProps } from './shared'; export const ActionButton = ({ cheat, disabled, onChange }: ControlInternalProps) => { - const { _ } = useLingui(); - const handleClick = () => onChange(1); - const label = typeof cheat.args.button === 'string' ? cheat.args.button : _(msg`Apply`); + const { _ } = useLingui(); + const handleClick = () => onChange(1); + const label = typeof cheat.args.button === 'string' ? cheat.args.button : _(msg`Apply`); - return ( - - ); + return ( + + ); }; diff --git a/web-panel/src/trainer/controls/CheatControl.tsx b/web-panel/src/trainer/controls/CheatControl.tsx index 1b8c0b3..ad4d4fa 100644 --- a/web-panel/src/trainer/controls/CheatControl.tsx +++ b/web-panel/src/trainer/controls/CheatControl.tsx @@ -1,4 +1,4 @@ -import { type ReactElement } from 'react'; +import type { ReactElement } from 'react'; import type { CheatSchema } from '../../../protocol/messages'; import { ECheatType } from '../../../protocol/messages'; @@ -8,32 +8,34 @@ import { NumberControl } from './NumberControl'; import { ScalarControl } from './ScalarControl'; import { SelectionControl } from './SelectionControl'; import { SliderControl } from './SliderControl'; -import { ToggleControl } from './ToggleControl'; import type { ControlInternalProps } from './shared'; +import { ToggleControl } from './ToggleControl'; type CheatControlProps = { - cheat: CheatSchema; - value: unknown; - pending: boolean; - disabled: boolean; - onChange: (nextValue: unknown) => void; + cheat: CheatSchema; + value: unknown; + pending: boolean; + disabled: boolean; + onChange: (nextValue: unknown) => void; }; const CONTROL_BY_TYPE: Record ReactElement> = { - [ECheatType.Toggle]: (props) => , - [ECheatType.Slider]: (props) => , - [ECheatType.Number]: (props) => , - [ECheatType.Button]: (props) => , - [ECheatType.Selection]: (props) => , - [ECheatType.Scalar]: (props) => , - [ECheatType.Incremental]: (props) => , + [ECheatType.Toggle]: (props) => , + [ECheatType.Slider]: (props) => , + [ECheatType.Number]: (props) => , + [ECheatType.Button]: (props) => , + [ECheatType.Selection]: (props) => , + [ECheatType.Scalar]: (props) => , + [ECheatType.Incremental]: (props) => , }; export const CheatControl = ({ cheat, value, pending, disabled, onChange }: CheatControlProps) => { - const Renderer = CONTROL_BY_TYPE[cheat.type]; - if (!Renderer) { - return Unsupported: {cheat.type}; - } + const Renderer = CONTROL_BY_TYPE[cheat.type]; + if (!Renderer) { + return Unsupported: {cheat.type}; + } - return ; + return ( + + ); }; diff --git a/web-panel/src/trainer/controls/IncrementalControl.tsx b/web-panel/src/trainer/controls/IncrementalControl.tsx index 516aab5..fb8bf0e 100644 --- a/web-panel/src/trainer/controls/IncrementalControl.tsx +++ b/web-panel/src/trainer/controls/IncrementalControl.tsx @@ -4,31 +4,47 @@ import { cn } from '@/shared/lib/ui'; import { isSameOption, resolveOption } from '../model/values'; import { ActionButton } from './ActionButton'; -import { STEPPER_SHELL_CLASS, StepButton, type ControlInternalProps } from './shared'; +import { type ControlInternalProps, STEPPER_SHELL_CLASS, StepButton } from './shared'; const INCREMENTAL_STEP_GRID = 'grid-cols-[46px_minmax(0,1fr)_46px]'; export const IncrementalControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => { - const options = useMemo(() => (cheat.args.options ?? []).map(resolveOption), [cheat.args.options]); - if (options.length === 0) { - return ; - } + const options = useMemo( + () => (cheat.args.options ?? []).map(resolveOption), + [cheat.args.options], + ); + if (options.length === 0) { + return ; + } - const matchedIndex = options.findIndex((option) => isSameOption(option.value, value)); - // An unrecognised value must not strand the user: treat it as "before the first - // option" so stepping forward still walks the list. - const currentIndex = matchedIndex >= 0 ? matchedIndex : -1; - const previous = currentIndex > 0 ? options[currentIndex - 1] : null; - const next = currentIndex < options.length - 1 ? options[currentIndex + 1] : null; - const currentLabel = options[currentIndex]?.label ?? String(value ?? '--'); + const matchedIndex = options.findIndex((option) => isSameOption(option.value, value)); + // An unrecognised value must not strand the user: treat it as "before the first + // option" so stepping forward still walks the list. + const currentIndex = matchedIndex >= 0 ? matchedIndex : -1; + const previous = currentIndex > 0 ? options[currentIndex - 1] : null; + const next = currentIndex < options.length - 1 ? options[currentIndex + 1] : null; + const currentLabel = options[currentIndex]?.label ?? String(value ?? '--'); - return ( -
- previous && onChange(previous.value)} /> - - {currentLabel}{cheat.args.postfix ?? ''} - - next && onChange(next.value)} /> -
- ); + return ( +
+ previous && onChange(previous.value)} + /> + + {currentLabel} + {cheat.args.postfix ?? ''} + + next && onChange(next.value)} + /> +
+ ); }; diff --git a/web-panel/src/trainer/controls/NumberControl.tsx b/web-panel/src/trainer/controls/NumberControl.tsx index a767280..3cfb93a 100644 --- a/web-panel/src/trainer/controls/NumberControl.tsx +++ b/web-panel/src/trainer/controls/NumberControl.tsx @@ -1,48 +1,72 @@ -import { useState, type FormEvent } from 'react'; +import { type FormEvent, useState } from 'react'; import { cn } from '@/shared/lib/ui'; import { formatInputNumber, numericValue, stripNumberGrouping } from './format-number'; +import { type ControlInternalProps, STEPPER_SHELL_CLASS, StepButton } from './shared'; import { snapToStep } from './step'; -import { STEPPER_SHELL_CLASS, StepButton, type ControlInternalProps } from './shared'; const NUMBER_STEP_GRID = 'grid-cols-[48px_minmax(0,1fr)_48px]'; export const NumberControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => { - const step = cheat.args.step ?? 1; - const currentValue = numericValue(value, 0); - // While focused the raw text is authoritative, otherwise formatting would eat the - // decimal point in "0." and re-group digits under the caret as the user types. - const [draft, setDraft] = useState(null); + const step = cheat.args.step ?? 1; + const currentValue = numericValue(value, 0); + // While focused the raw text is authoritative, otherwise formatting would eat the + // decimal point in "0." and re-group digits under the caret as the user types. + const [draft, setDraft] = useState(null); - const handleInput = (event: FormEvent) => { - const raw = event.currentTarget.value; - setDraft(raw); - onChange(stripNumberGrouping(raw)); - }; + const handleInput = (event: FormEvent) => { + const raw = event.currentTarget.value; + setDraft(raw); + onChange(stripNumberGrouping(raw)); + }; - const commit = (next: number) => { - setDraft(null); - onChange(next); - }; + const commit = (next: number) => { + setDraft(null); + onChange(next); + }; - const decrement = () => commit(snapToStep(Math.max(cheat.args.min ?? Number.NEGATIVE_INFINITY, currentValue - step), step)); - const increment = () => commit(snapToStep(Math.min(cheat.args.max ?? Number.POSITIVE_INFINITY, currentValue + step), step)); + const decrement = () => + commit( + snapToStep( + Math.max(cheat.args.min ?? Number.NEGATIVE_INFINITY, currentValue - step), + step, + ), + ); + const increment = () => + commit( + snapToStep( + Math.min(cheat.args.max ?? Number.POSITIVE_INFINITY, currentValue + step), + step, + ), + ); - return ( -
- - setDraft(null)} - /> - -
- ); + return ( +
+ + setDraft(null)} + /> + +
+ ); }; diff --git a/web-panel/src/trainer/controls/ScalarControl.tsx b/web-panel/src/trainer/controls/ScalarControl.tsx index d474b5b..6a6738c 100644 --- a/web-panel/src/trainer/controls/ScalarControl.tsx +++ b/web-panel/src/trainer/controls/ScalarControl.tsx @@ -1,46 +1,65 @@ -import { useMemo, type FormEvent } from 'react'; +import { type FormEvent, useMemo } from 'react'; import type { CheatSchema } from '../../../protocol/messages'; import { resolveOption } from '../model/values'; import { numericValue } from './format-number'; -import { SliderReadout, type ControlInternalProps } from './shared'; +import { type ControlInternalProps, SliderReadout } from './shared'; export const ScalarControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => { - const numericOptions = useMemo(() => getNumericOptions(cheat.args.options ?? []), [cheat.args.options]); - const min = cheat.args.min ?? numericOptions[0] ?? 0; - const max = cheat.args.max ?? numericOptions[numericOptions.length - 1] ?? 100; - const step = cheat.args.step ?? inferStep(numericOptions) ?? 1; - const currentValue = numericValue(value, min); - const handleInput = (event: FormEvent) => onChange(Number(event.currentTarget.value)); + const numericOptions = useMemo( + () => getNumericOptions(cheat.args.options ?? []), + [cheat.args.options], + ); + const min = cheat.args.min ?? numericOptions[0] ?? 0; + const max = cheat.args.max ?? numericOptions[numericOptions.length - 1] ?? 100; + const step = cheat.args.step ?? inferStep(numericOptions) ?? 1; + const currentValue = numericValue(value, min); + const handleInput = (event: FormEvent) => + onChange(Number(event.currentTarget.value)); - return ( -
- -
- {min}{cheat.args.postfix ?? ''} - {max}{cheat.args.postfix ?? ''} -
-
- ); + return ( +
+ +
+ + {min} + {cheat.args.postfix ?? ''} + + + {max} + {cheat.args.postfix ?? ''} + +
+
+ ); }; function getNumericOptions(options: NonNullable): number[] { - return options - .map(resolveOption) - .map((option) => numericValue(option.value, Number.NaN)) - .filter((option) => Number.isFinite(option)) - .sort((left, right) => left - right); + return options + .map(resolveOption) + .map((option) => numericValue(option.value, Number.NaN)) + .filter((option) => Number.isFinite(option)) + .sort((left, right) => left - right); } function inferStep(options: number[]): number | null { - if (options.length < 2) { - return null; - } + if (options.length < 2) { + return null; + } - const steps = options - .slice(1) - .map((option, index) => Math.abs(option - options[index])) - .filter((option) => option > 0); + const steps = options + .slice(1) + .map((option, index) => Math.abs(option - options[index])) + .filter((option) => option > 0); - return steps.length > 0 ? Math.min(...steps) : null; + return steps.length > 0 ? Math.min(...steps) : null; } diff --git a/web-panel/src/trainer/controls/SelectionControl.tsx b/web-panel/src/trainer/controls/SelectionControl.tsx index d06e498..073eeb0 100644 --- a/web-panel/src/trainer/controls/SelectionControl.tsx +++ b/web-panel/src/trainer/controls/SelectionControl.tsx @@ -1,97 +1,122 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; import { Trans } from '@lingui/react/macro'; - -import { Icon } from '@/shared/ui/Icon'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; import type { CheatOption } from '../../../protocol/messages'; import { isSameOption, resolveOption } from '../model/values'; -import { type ControlInternalProps } from './shared'; +import type { ControlInternalProps } from './shared'; export const SelectionControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => { - const options = useMemo(() => (cheat.args.options ?? []).map(resolveOption), [cheat.args.options]); - const [open, setOpen] = useState(false); - const rootRef = useRef(null); + const options = useMemo( + () => (cheat.args.options ?? []).map(resolveOption), + [cheat.args.options], + ); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); - // Without these the list can only be dismissed by picking something. - useEffect(() => { - if (!open) { - return; + // Without these the list can only be dismissed by picking something. + useEffect(() => { + if (!open) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + if (options.length === 0) { + return ( + + No options + + ); } - const handlePointerDown = (event: PointerEvent) => { - if (!rootRef.current?.contains(event.target as Node)) { + const selectedOption = + options.find((option) => isSameOption(option.value, value ?? options[0].value)) ?? + options[0]; + const handleToggle = () => { + if (disabled) { + return; + } + + setOpen((current) => !current); + }; + const handleSelect = (option: CheatOption) => { + onChange(option.value); setOpen(false); - } - }; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setOpen(false); - } }; - document.addEventListener('pointerdown', handlePointerDown); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('pointerdown', handlePointerDown); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [open]); - - if (options.length === 0) { - return No options; - } - - const selectedOption = options.find((option) => isSameOption(option.value, value ?? options[0].value)) ?? options[0]; - const handleToggle = () => { - if (disabled) { - return; - } - - setOpen((current) => !current); - }; - const handleSelect = (option: CheatOption) => { - onChange(option.value); - setOpen(false); - }; - - return ( -
- - {open ? ( -
- {options.map((option) => { - const active = isSameOption(selectedOption.value, option.value); - return ( - - ); - })} + aria-expanded={open} + aria-haspopup="listbox" + disabled={disabled} + className="flex h-[38px] w-full items-center justify-between gap-3 rounded-[10px] border border-white/10 bg-white/5.5 px-3 text-left text-[13px] font-semibold text-(--deck-fg) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none backdrop-blur-xl disabled:cursor-not-allowed disabled:opacity-50" + onClick={handleToggle} + > + {selectedOption.label} + + + {open ? ( +
+ {options.map((option) => { + const active = isSameOption(selectedOption.value, option.value); + return ( + + ); + })} +
+ ) : null}
- ) : null} -
- ); + ); }; function optionKey(option: CheatOption): string { - return String(option.value); + return String(option.value); } diff --git a/web-panel/src/trainer/controls/SliderControl.tsx b/web-panel/src/trainer/controls/SliderControl.tsx index da806d0..3fb0d97 100644 --- a/web-panel/src/trainer/controls/SliderControl.tsx +++ b/web-panel/src/trainer/controls/SliderControl.tsx @@ -1,18 +1,28 @@ -import { type FormEvent } from 'react'; +import type { FormEvent } from 'react'; import { numericValue } from './format-number'; -import { SliderReadout, type ControlInternalProps } from './shared'; +import { type ControlInternalProps, SliderReadout } from './shared'; export const SliderControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => { - const min = cheat.args.min ?? 0; - const max = cheat.args.max ?? 100; - const step = cheat.args.step ?? 1; - const currentValue = numericValue(value, min); - const handleInput = (event: FormEvent) => onChange(Number(event.currentTarget.value)); + const min = cheat.args.min ?? 0; + const max = cheat.args.max ?? 100; + const step = cheat.args.step ?? 1; + const currentValue = numericValue(value, min); + const handleInput = (event: FormEvent) => + onChange(Number(event.currentTarget.value)); - return ( -
- -
- ); + return ( +
+ +
+ ); }; diff --git a/web-panel/src/trainer/controls/ToggleControl.tsx b/web-panel/src/trainer/controls/ToggleControl.tsx index ba7bcd8..4f82404 100644 --- a/web-panel/src/trainer/controls/ToggleControl.tsx +++ b/web-panel/src/trainer/controls/ToggleControl.tsx @@ -3,18 +3,30 @@ import { cn } from '@/shared/lib/ui'; import type { ControlInternalProps } from './shared'; export const ToggleControl = ({ value, disabled, onChange }: ControlInternalProps) => { - const checked = Boolean(value); - const handleClick = () => onChange(!checked); + const checked = Boolean(value); + const handleClick = () => onChange(!checked); - return ( - - ); + return ( + + ); }; diff --git a/web-panel/src/trainer/controls/format-number.ts b/web-panel/src/trainer/controls/format-number.ts index 4ebcfec..ecb528c 100644 --- a/web-panel/src/trainer/controls/format-number.ts +++ b/web-panel/src/trainer/controls/format-number.ts @@ -2,39 +2,41 @@ const NUMBER_FORMAT_LOCALE = 'en-US'; const NUMBER_MAX_FRACTION_DIGITS = 6; const NUMBER_GROUP_SEPARATOR_PATTERN = /[,\s]/g; -export const groupedNumberFormat = new Intl.NumberFormat(NUMBER_FORMAT_LOCALE, { maximumFractionDigits: NUMBER_MAX_FRACTION_DIGITS }); +export const groupedNumberFormat = new Intl.NumberFormat(NUMBER_FORMAT_LOCALE, { + maximumFractionDigits: NUMBER_MAX_FRACTION_DIGITS, +}); export function formatNumber(value: number, step: number): string { - if (step >= 1) { - return String(Math.round(value)); - } + if (step >= 1) { + return String(Math.round(value)); + } - const decimals = step.toString().split('.')[1]?.length ?? 1; - return value.toFixed(decimals); + const decimals = step.toString().split('.')[1]?.length ?? 1; + return value.toFixed(decimals); } export function formatInputNumber(value: unknown): string { - if (value === null || value === undefined || value === '') { - return ''; - } + if (value === null || value === undefined || value === '') { + return ''; + } - const numeric = numericValue(value, Number.NaN); - return Number.isFinite(numeric) ? groupedNumberFormat.format(numeric) : String(value); + const numeric = numericValue(value, Number.NaN); + return Number.isFinite(numeric) ? groupedNumberFormat.format(numeric) : String(value); } export function numericValue(value: unknown, fallback: number): number { - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } - if (typeof value !== 'string') { - return fallback; - } + if (typeof value !== 'string') { + return fallback; + } - const parsed = Number(stripNumberGrouping(value)); - return Number.isFinite(parsed) ? parsed : fallback; + const parsed = Number(stripNumberGrouping(value)); + return Number.isFinite(parsed) ? parsed : fallback; } export function stripNumberGrouping(value: string): string { - return value.replace(NUMBER_GROUP_SEPARATOR_PATTERN, ''); + return value.replace(NUMBER_GROUP_SEPARATOR_PATTERN, ''); } diff --git a/web-panel/src/trainer/controls/shared.tsx b/web-panel/src/trainer/controls/shared.tsx index d485466..b7842ee 100644 --- a/web-panel/src/trainer/controls/shared.tsx +++ b/web-panel/src/trainer/controls/shared.tsx @@ -1,95 +1,126 @@ -import { type FormEvent } from 'react'; - -import { Icon } from '@/shared/ui/Icon'; +import type { FormEvent } from 'react'; import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; import type { CheatSchema } from '../../../protocol/messages'; import { formatNumber } from './format-number'; export type ControlInternalProps = { - cheat: CheatSchema; - value: unknown; - disabled: boolean; - onChange: (nextValue: unknown) => void; + cheat: CheatSchema; + value: unknown; + disabled: boolean; + onChange: (nextValue: unknown) => void; }; export const STEPPER_SHELL_CLASS = - 'grid h-[38px] w-full items-stretch overflow-hidden rounded-[10px] border border-white/10 bg-white/[0.055] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] backdrop-blur-xl'; + 'grid h-[38px] w-full items-stretch overflow-hidden rounded-[10px] border border-white/10 bg-white/5.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] backdrop-blur-xl'; type SliderTrackProps = { - min: number; - max: number; - step: number; - value: number; - label: string; - disabled: boolean; - onInput: (event: FormEvent) => void; + min: number; + max: number; + step: number; + value: number; + label: string; + disabled: boolean; + onInput: (event: FormEvent) => void; }; -export const SliderTrack = ({ min, max, step, value, label, disabled, onInput }: SliderTrackProps) => { - const pct = max === min ? 0 : Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); +export const SliderTrack = ({ + min, + max, + step, + value, + label, + disabled, + onInput, +}: SliderTrackProps) => { + const pct = max === min ? 0 : Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); - return ( -
-
-
-
- -
- ); + return ( +
+
+
+
+ +
+ ); }; type StepButtonProps = { - icon: 'minus' | 'plus' | 'chevron-left' | 'chevron-right'; - border: 'left' | 'right'; - /** Required: the button renders an icon only, so it has no other accessible name. */ - label: string; - disabled: boolean; - onClick: () => void; + icon: 'minus' | 'plus' | 'chevron-left' | 'chevron-right'; + border: 'left' | 'right'; + /** Required: the button renders an icon only, so it has no other accessible name. */ + label: string; + disabled: boolean; + onClick: () => void; }; export const StepButton = ({ icon, border, label, disabled, onClick }: StepButtonProps) => { - return ( - - ); + return ( + + ); }; type SliderReadoutProps = { - value: number; - min: number; - max: number; - step: number; - postfix: string; - label: string; - disabled: boolean; - onInput: (event: FormEvent) => void; + value: number; + min: number; + max: number; + step: number; + postfix: string; + label: string; + disabled: boolean; + onInput: (event: FormEvent) => void; }; -export const SliderReadout = ({ value, min, max, step, postfix, label, disabled, onInput }: SliderReadoutProps) => { - return ( - <> -
- {formatNumber(value, step)}{postfix} -
- - - ); +export const SliderReadout = ({ + value, + min, + max, + step, + postfix, + label, + disabled, + onInput, +}: SliderReadoutProps) => { + return ( + <> +
+ {formatNumber(value, step)} + {postfix} +
+ + + ); }; diff --git a/web-panel/src/trainer/controls/step.ts b/web-panel/src/trainer/controls/step.ts index 11c4ed7..a90a18a 100644 --- a/web-panel/src/trainer/controls/step.ts +++ b/web-panel/src/trainer/controls/step.ts @@ -3,18 +3,18 @@ * step drifts (0.1 + 0.2 -> 0.30000000000000004) and that drift is sent on the wire. */ export function snapToStep(value: number, step: number): number { - if (!Number.isFinite(value)) { - return 0; - } + if (!Number.isFinite(value)) { + return 0; + } - const decimals = decimalPlaces(step); - return decimals === 0 ? Math.round(value) : Number(value.toFixed(decimals)); + const decimals = decimalPlaces(step); + return decimals === 0 ? Math.round(value) : Number(value.toFixed(decimals)); } export function decimalPlaces(step: number): number { - if (!Number.isFinite(step) || Number.isInteger(step)) { - return 0; - } + if (!Number.isFinite(step) || Number.isInteger(step)) { + return 0; + } - return step.toString().split('.')[1]?.length ?? 0; + return step.toString().split('.')[1]?.length ?? 0; } diff --git a/web-panel/src/trainer/model/categories.test.ts b/web-panel/src/trainer/model/categories.test.ts index ecf47a8..6df8eeb 100644 --- a/web-panel/src/trainer/model/categories.test.ts +++ b/web-panel/src/trainer/model/categories.test.ts @@ -4,29 +4,47 @@ import { ECheatType, type TrainerMetaPayload } from '../../../protocol/messages' import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from './categories'; const trainerMeta: TrainerMetaPayload = { - session: { instanceId: 'session' }, - trainer: { - trainerId: 'trainer', - gameId: 'game', - trainerLoading: false, - gameInstalled: true, - needsCompatibilityWarning: false, - isTimeLimitExpired: false, - }, - schema: { - categories: ['player', 'world'], - cheats: [ - { uuid: '1', target: 'health', type: ECheatType.Toggle, name: 'Infinite Health', category: 'player', args: {} }, - { uuid: '2', target: 'time', type: ECheatType.Slider, name: 'World Time', category: 'world', args: {} }, - ], - }, + session: { instanceId: 'session' }, + trainer: { + trainerId: 'trainer', + gameId: 'game', + trainerLoading: false, + gameInstalled: true, + needsCompatibilityWarning: false, + isTimeLimitExpired: false, + }, + schema: { + categories: ['player', 'world'], + cheats: [ + { + uuid: '1', + target: 'health', + type: ECheatType.Toggle, + name: 'Infinite Health', + category: 'player', + args: {}, + }, + { + uuid: '2', + target: 'time', + type: ECheatType.Slider, + name: 'World Time', + category: 'world', + args: {}, + }, + ], + }, }; describe('trainer categories', () => { - it('groups, filters and projects pinned cheats', () => { - const groups = groupCheatsByCategory(trainerMeta); - expect(groups.map((group) => group.id)).toEqual(['player', 'world']); - expect(filterGroups(groups, 'health')[0]?.cheats.map((cheat) => cheat.target)).toEqual(['health']); - expect(buildPinnedGroup(trainerMeta, { time: true })?.cheats.map((cheat) => cheat.target)).toEqual(['time']); - }); + it('groups, filters and projects pinned cheats', () => { + const groups = groupCheatsByCategory(trainerMeta); + expect(groups.map((group) => group.id)).toEqual(['player', 'world']); + expect(filterGroups(groups, 'health')[0]?.cheats.map((cheat) => cheat.target)).toEqual([ + 'health', + ]); + expect( + buildPinnedGroup(trainerMeta, { time: true })?.cheats.map((cheat) => cheat.target), + ).toEqual(['time']); + }); }); diff --git a/web-panel/src/trainer/model/categories.ts b/web-panel/src/trainer/model/categories.ts index 3ebdcb4..7d04ace 100644 --- a/web-panel/src/trainer/model/categories.ts +++ b/web-panel/src/trainer/model/categories.ts @@ -2,79 +2,80 @@ import { formatHumanLabel } from '@/shared/lib/ui'; import type { CheatSchema, TrainerMetaPayload, TrainerSummary } from '../../../protocol/messages'; export type CategoryGroup = { - id: string; - cheats: CheatSchema[]; + id: string; + cheats: CheatSchema[]; }; export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): CategoryGroup[] { - if (!trainerMeta) { - return []; - } + if (!trainerMeta) { + return []; + } - const grouped = new Map(); - for (const cheat of trainerMeta.schema.cheats) { - const bucket = grouped.get(cheat.category) ?? []; - bucket.push(cheat); - grouped.set(cheat.category, bucket); - } + const grouped = new Map(); + for (const cheat of trainerMeta.schema.cheats) { + const bucket = grouped.get(cheat.category) ?? []; + bucket.push(cheat); + grouped.set(cheat.category, bucket); + } - return Array.from(grouped.entries()) - .map(([id, cheats]) => ({ id, cheats })) - .sort((left, right) => formatHumanLabel(left.id).localeCompare(formatHumanLabel(right.id))); + return Array.from(grouped.entries()) + .map(([id, cheats]) => ({ id, cheats })) + .sort((left, right) => formatHumanLabel(left.id).localeCompare(formatHumanLabel(right.id))); } export const PINNED_CATEGORY_ID = 'pinned'; export function buildPinnedGroup( - trainerMeta: TrainerMetaPayload | null, - pinnedTargets: Record, + trainerMeta: TrainerMetaPayload | null, + pinnedTargets: Record, ): CategoryGroup | null { - if (!trainerMeta) { - return null; - } + if (!trainerMeta) { + return null; + } - const pinnedCheats = trainerMeta.schema.cheats.filter((cheat) => pinnedTargets[cheat.target]); - if (pinnedCheats.length === 0) { - return null; - } + const pinnedCheats = trainerMeta.schema.cheats.filter((cheat) => pinnedTargets[cheat.target]); + if (pinnedCheats.length === 0) { + return null; + } - return { - id: PINNED_CATEGORY_ID, - cheats: pinnedCheats, - }; + return { + id: PINNED_CATEGORY_ID, + cheats: pinnedCheats, + }; } export function filterGroups(groups: CategoryGroup[], query: string): CategoryGroup[] { - const normalized = query.trim().toLowerCase(); - if (!normalized) { - return groups; - } - - const result: CategoryGroup[] = []; - for (const group of groups) { - const matchesGroup = formatHumanLabel(group.id).toLowerCase().includes(normalized) || group.id.toLowerCase().includes(normalized); - const cheats = matchesGroup - ? group.cheats - : group.cheats.filter((cheat) => cheatMatchesQuery(cheat, normalized)); - - if (cheats.length > 0) { - result.push({ ...group, cheats }); + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return groups; } - } - return result; + const result: CategoryGroup[] = []; + for (const group of groups) { + const matchesGroup = + formatHumanLabel(group.id).toLowerCase().includes(normalized) || + group.id.toLowerCase().includes(normalized); + const cheats = matchesGroup + ? group.cheats + : group.cheats.filter((cheat) => cheatMatchesQuery(cheat, normalized)); + + if (cheats.length > 0) { + result.push({ ...group, cheats }); + } + } + + return result; } function cheatMatchesQuery(cheat: CheatSchema, query: string): boolean { - if (cheat.name?.toLowerCase().includes(query)) return true; - if (cheat.target?.toLowerCase().includes(query)) return true; - if (cheat.category?.toLowerCase().includes(query)) return true; - if (cheat.description?.toLowerCase().includes(query)) return true; - if (cheat.type?.toLowerCase().includes(query)) return true; - return false; + if (cheat.name?.toLowerCase().includes(query)) return true; + if (cheat.target?.toLowerCase().includes(query)) return true; + if (cheat.category?.toLowerCase().includes(query)) return true; + if (cheat.description?.toLowerCase().includes(query)) return true; + if (cheat.type?.toLowerCase().includes(query)) return true; + return false; } export function getTrainerDisplayName(trainer: TrainerSummary): string { - return trainer.displayName?.trim() || trainer.gameId || trainer.titleId || trainer.trainerId; + return trainer.displayName?.trim() || trainer.gameId || trainer.titleId || trainer.trainerId; } - diff --git a/web-panel/src/trainer/model/values.ts b/web-panel/src/trainer/model/values.ts index bca5352..0ba6a05 100644 --- a/web-panel/src/trainer/model/values.ts +++ b/web-panel/src/trainer/model/values.ts @@ -1,35 +1,40 @@ -import { ECheatType, type CheatOption, type CheatOptionLike, type CheatSchema } from '../../../protocol/messages'; +import { + type CheatOption, + type CheatOptionLike, + type CheatSchema, + ECheatType, +} from '../../../protocol/messages'; import { stripNumberGrouping } from '../controls/format-number'; // Wand sends option values as either strings or numbers for the same cheat, so identity // is compared by string form. export function isSameOption(left: unknown, right: unknown): boolean { - return String(left) === String(right); + return String(left) === String(right); } export function resolveOption(option: CheatOptionLike): CheatOption { - if (typeof option === 'string' || typeof option === 'number') { - return { label: String(option), value: option }; - } + if (typeof option === 'string' || typeof option === 'number') { + return { label: String(option), value: option }; + } - return { - label: option.label ?? String(option.value), - value: option.value, - }; + return { + label: option.label ?? String(option.value), + value: option.value, + }; } export function normalizeCheatValue(cheat: CheatSchema, value: unknown): unknown { - if (cheat.type === ECheatType.Toggle) { - return Boolean(value); - } + if (cheat.type === ECheatType.Toggle) { + return Boolean(value); + } - if (cheat.type !== ECheatType.Slider && cheat.type !== ECheatType.Number) { - return value; - } + if (cheat.type !== ECheatType.Slider && cheat.type !== ECheatType.Number) { + return value; + } - if (typeof value !== 'string' || !value.trim()) { - return value; - } + if (typeof value !== 'string' || !value.trim()) { + return value; + } - return Number(stripNumberGrouping(value.trim())); + return Number(stripNumberGrouping(value.trim())); } diff --git a/web-panel/src/trainer/pinned-cheats/use-pinned-cheats.ts b/web-panel/src/trainer/pinned-cheats/use-pinned-cheats.ts index f265008..aec48ac 100644 --- a/web-panel/src/trainer/pinned-cheats/use-pinned-cheats.ts +++ b/web-panel/src/trainer/pinned-cheats/use-pinned-cheats.ts @@ -4,28 +4,28 @@ import type { CheatSchema } from '../../../protocol/messages'; import { loadStringSet, saveStringSet } from '../../shared/storage'; type PinnedTargetsParams = { - pinnedStorageKey: string | null; + pinnedStorageKey: string | null; }; export function usePinnedCheats({ pinnedStorageKey }: PinnedTargetsParams) { - const [pinnedTargets, setPinnedTargets] = useState>({}); + const [pinnedTargets, setPinnedTargets] = useState>({}); - useEffect(() => { - setPinnedTargets(loadStringSet(pinnedStorageKey)); - }, [pinnedStorageKey]); + useEffect(() => { + setPinnedTargets(loadStringSet(pinnedStorageKey)); + }, [pinnedStorageKey]); - const toggle = useCallback( - (cheat: CheatSchema) => { - setPinnedTargets((current) => { - const next = { ...current }; - if (next[cheat.target]) delete next[cheat.target]; - else next[cheat.target] = true; - saveStringSet(pinnedStorageKey, next); - return next; - }); - }, - [pinnedStorageKey], - ); + const toggle = useCallback( + (cheat: CheatSchema) => { + setPinnedTargets((current) => { + const next = { ...current }; + if (next[cheat.target]) delete next[cheat.target]; + else next[cheat.target] = true; + saveStringSet(pinnedStorageKey, next); + return next; + }); + }, + [pinnedStorageKey], + ); - return { pinnedTargets, toggle }; + return { pinnedTargets, toggle }; } diff --git a/web-panel/src/trainer/presets/preset-storage.test.ts b/web-panel/src/trainer/presets/preset-storage.test.ts index bf5b696..c981c3e 100644 --- a/web-panel/src/trainer/presets/preset-storage.test.ts +++ b/web-panel/src/trainer/presets/preset-storage.test.ts @@ -1,31 +1,48 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { ECheatType, type CheatSchema } from '../../../protocol/messages'; +import { type CheatSchema, ECheatType } from '../../../protocol/messages'; import { capturePresetValues, loadPresets, savePresets } from './preset-storage'; const cheats: CheatSchema[] = [ - { uuid: 'toggle', target: 'god', type: ECheatType.Toggle, name: 'God', category: 'player', args: {} }, - { uuid: 'button', target: 'apply', type: ECheatType.Button, name: 'Apply', category: 'player', args: {} }, + { + uuid: 'toggle', + target: 'god', + type: ECheatType.Toggle, + name: 'God', + category: 'player', + args: {}, + }, + { + uuid: 'button', + target: 'apply', + type: ECheatType.Button, + name: 'Apply', + category: 'player', + args: {}, + }, ]; describe('preset storage', () => { - beforeEach(() => localStorage.clear()); + beforeEach(() => localStorage.clear()); - it('captures persistent values and excludes one-shot actions', () => { - expect(capturePresetValues(cheats, { god: true, apply: 1 })).toEqual({ god: true }); - }); + it('captures persistent values and excludes one-shot actions', () => { + expect(capturePresetValues(cheats, { god: true, apply: 1 })).toEqual({ god: true }); + }); - it('revives valid presets and ignores malformed entries', () => { - localStorage.setItem('presets', JSON.stringify([ - { id: 'valid', name: 'Valid', createdAt: 'now', values: { god: true } }, - { id: 'invalid', values: {} }, - ])); + it('revives valid presets and ignores malformed entries', () => { + localStorage.setItem( + 'presets', + JSON.stringify([ + { id: 'valid', name: 'Valid', createdAt: 'now', values: { god: true } }, + { id: 'invalid', values: {} }, + ]), + ); - expect(loadPresets('presets')).toEqual([ - { id: 'valid', name: 'Valid', createdAt: 'now', values: { god: true } }, - ]); + expect(loadPresets('presets')).toEqual([ + { id: 'valid', name: 'Valid', createdAt: 'now', values: { god: true } }, + ]); - savePresets('presets', []); - expect(localStorage.getItem('presets')).toBeNull(); - }); + savePresets('presets', []); + expect(localStorage.getItem('presets')).toBeNull(); + }); }); diff --git a/web-panel/src/trainer/presets/preset-storage.ts b/web-panel/src/trainer/presets/preset-storage.ts index 583a448..a0930ee 100644 --- a/web-panel/src/trainer/presets/preset-storage.ts +++ b/web-panel/src/trainer/presets/preset-storage.ts @@ -1,92 +1,100 @@ -import { ECheatType, type CheatSchema, type TrainerSummary } from '../../../protocol/messages'; +import { type CheatSchema, ECheatType, type TrainerSummary } from '../../../protocol/messages'; import { getTrainerStorageId, loadJson, saveJson } from '../../shared/storage'; export type RemotePreset = { - id: string; - name: string; - values: Record; - createdAt: string; + id: string; + name: string; + values: Record; + createdAt: string; }; const STORAGE_KEY_PREFIX = 'wand-remote.presets.v1:'; const PRESET_COMPATIBLE_TYPES = new Set([ - ECheatType.Toggle, - ECheatType.Slider, - ECheatType.Number, - ECheatType.Selection, - ECheatType.Scalar, - ECheatType.Incremental, + ECheatType.Toggle, + ECheatType.Slider, + ECheatType.Number, + ECheatType.Selection, + ECheatType.Scalar, + ECheatType.Incremental, ]); export function getPresetStorageKey(trainer: TrainerSummary | null): string { - return `${STORAGE_KEY_PREFIX}${getTrainerStorageId(trainer) ?? 'global'}`; + return `${STORAGE_KEY_PREFIX}${getTrainerStorageId(trainer) ?? 'global'}`; } export function loadPresets(storageKey: string): RemotePreset[] { - return loadJson( - storageKey, - (raw) => (Array.isArray(raw) ? raw.map(normalizePreset).filter((preset): preset is RemotePreset => Boolean(preset)) : null), - [], - ); + return loadJson( + storageKey, + (raw) => + Array.isArray(raw) + ? raw + .map(normalizePreset) + .filter((preset): preset is RemotePreset => Boolean(preset)) + : null, + [], + ); } export function savePresets(storageKey: string, presets: RemotePreset[]): boolean { - return saveJson(storageKey, presets, (value) => Array.isArray(value) && value.length === 0); + return saveJson(storageKey, presets, (value) => Array.isArray(value) && value.length === 0); } export function createPreset(name: string, values: Record): RemotePreset { - return { - id: createPresetId(), - name: name.trim(), - values, - createdAt: new Date().toISOString(), - }; + return { + id: createPresetId(), + name: name.trim(), + values, + createdAt: new Date().toISOString(), + }; } -export function capturePresetValues(cheats: CheatSchema[], currentValues: Record): Record { - const values: Record = {}; - for (const cheat of cheats) { - if (!PRESET_COMPATIBLE_TYPES.has(cheat.type)) { - continue; +export function capturePresetValues( + cheats: CheatSchema[], + currentValues: Record, +): Record { + const values: Record = {}; + for (const cheat of cheats) { + if (!PRESET_COMPATIBLE_TYPES.has(cheat.type)) { + continue; + } + + if (!(cheat.target in currentValues)) { + continue; + } + + values[cheat.target] = currentValues[cheat.target]; } - if (!(cheat.target in currentValues)) { - continue; - } - - values[cheat.target] = currentValues[cheat.target]; - } - - return values; + return values; } function normalizePreset(value: unknown): RemotePreset | null { - if (!isRecord(value) || !isRecord(value.values)) { - return null; - } + if (!isRecord(value) || !isRecord(value.values)) { + return null; + } - const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : createPresetId(); - const name = typeof value.name === 'string' && value.name.trim() ? value.name.trim() : null; - if (!name) { - return null; - } + const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : createPresetId(); + const name = typeof value.name === 'string' && value.name.trim() ? value.name.trim() : null; + if (!name) { + return null; + } - return { - id, - name, - values: value.values, - createdAt: typeof value.createdAt === 'string' ? value.createdAt : new Date().toISOString(), - }; + return { + id, + name, + values: value.values, + createdAt: typeof value.createdAt === 'string' ? value.createdAt : new Date().toISOString(), + }; } function createPresetId(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); - } + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } - return `preset_${Date.now().toString(36)}`; + return `preset_${Date.now().toString(36)}`; } function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null; } diff --git a/web-panel/src/trainer/presets/use-presets.ts b/web-panel/src/trainer/presets/use-presets.ts index 39d3877..2b1f260 100644 --- a/web-panel/src/trainer/presets/use-presets.ts +++ b/web-panel/src/trainer/presets/use-presets.ts @@ -1,57 +1,67 @@ import { useCallback, useEffect, useState } from 'react'; import type { TrainerMetaPayload } from '../../../protocol/messages'; -import { capturePresetValues, createPreset, loadPresets, savePresets, type RemotePreset } from './preset-storage'; +import { + capturePresetValues, + createPreset, + loadPresets, + type RemotePreset, + savePresets, +} from './preset-storage'; type PresetsParams = { - presetStorageKey: string; - trainerMeta: TrainerMetaPayload | null; - values: Record; - onError: (message: string) => void; + presetStorageKey: string; + trainerMeta: TrainerMetaPayload | null; + values: Record; + onError: (message: string) => void; }; export function usePresets({ presetStorageKey, trainerMeta, values, onError }: PresetsParams) { - const [presets, setPresets] = useState([]); + const [presets, setPresets] = useState([]); - useEffect(() => { - setPresets(loadPresets(presetStorageKey)); - }, [presetStorageKey]); + useEffect(() => { + setPresets(loadPresets(presetStorageKey)); + }, [presetStorageKey]); - const addPreset = useCallback( - (name: string): boolean => { - if (!trainerMeta) { - onError('No active trainer to save as a preset.'); - return false; - } + const addPreset = useCallback( + (name: string): boolean => { + if (!trainerMeta) { + onError('No active trainer to save as a preset.'); + return false; + } - const captured = capturePresetValues(trainerMeta.schema.cheats, values); - if (Object.keys(captured).length === 0) { - onError('There are no mod values to save yet.'); - return false; - } + const captured = capturePresetValues(trainerMeta.schema.cheats, values); + if (Object.keys(captured).length === 0) { + onError('There are no mod values to save yet.'); + return false; + } - const next = [...presets, createPreset(name, captured)]; - if (!savePresets(presetStorageKey, next)) { - onError('Could not save the preset in this browser. Check site storage permissions and available space.'); - return false; - } - setPresets(next); - return true; - }, - [onError, presets, presetStorageKey, trainerMeta, values], - ); + const next = [...presets, createPreset(name, captured)]; + if (!savePresets(presetStorageKey, next)) { + onError( + 'Could not save the preset in this browser. Check site storage permissions and available space.', + ); + return false; + } + setPresets(next); + return true; + }, + [onError, presets, presetStorageKey, trainerMeta, values], + ); - const deletePreset = useCallback( - (presetId: string) => { - const next = presets.filter((preset) => preset.id !== presetId); - if (!savePresets(presetStorageKey, next)) { - onError('Could not update presets in this browser. Check site storage permissions and available space.'); - return; - } - setPresets(next); - }, - [onError, presets, presetStorageKey], - ); + const deletePreset = useCallback( + (presetId: string) => { + const next = presets.filter((preset) => preset.id !== presetId); + if (!savePresets(presetStorageKey, next)) { + onError( + 'Could not update presets in this browser. Check site storage permissions and available space.', + ); + return; + } + setPresets(next); + }, + [onError, presets, presetStorageKey], + ); - return { presets, addPreset, deletePreset }; + return { presets, addPreset, deletePreset }; } diff --git a/web-panel/src/trainer/ui/CategoryIcon.tsx b/web-panel/src/trainer/ui/CategoryIcon.tsx index 482d1a2..78ceac8 100644 --- a/web-panel/src/trainer/ui/CategoryIcon.tsx +++ b/web-panel/src/trainer/ui/CategoryIcon.tsx @@ -1,25 +1,31 @@ import { Icon, type IconName } from '@/shared/ui/Icon'; const CATEGORY_ICONS: Record = { - challenge: 'flame', - character: 'user', - cheats: 'sparkles', - crafting: 'hammer', - enemies: 'heart-broken', - game: 'gamepad', - inventory: 'backpack', - items: 'package', - physics: 'atom', - pinned: 'bolt', - player: 'user', - resources: 'box', - stats: 'chart', - teleport: 'map-pin', - vehicles: 'car', - weapons: 'swords', - world: 'world', + challenge: 'flame', + character: 'user', + cheats: 'sparkles', + crafting: 'hammer', + enemies: 'heart-broken', + game: 'gamepad', + inventory: 'backpack', + items: 'package', + physics: 'atom', + pinned: 'bolt', + player: 'user', + resources: 'box', + stats: 'chart', + teleport: 'map-pin', + vehicles: 'car', + weapons: 'swords', + world: 'world', }; export function CategoryIcon({ category, className }: { category: string; className?: string }) { - return ; + return ( + + ); } diff --git a/web-panel/src/trainer/ui/CategorySection.tsx b/web-panel/src/trainer/ui/CategorySection.tsx index b300216..100f036 100644 --- a/web-panel/src/trainer/ui/CategorySection.tsx +++ b/web-panel/src/trainer/ui/CategorySection.tsx @@ -1,130 +1,154 @@ -import { memo, useEffect, useMemo, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; - -import { Icon } from '@/shared/ui/Icon'; +import { memo, useEffect, useMemo, useState } from 'react'; import { cn, formatHumanLabel } from '@/shared/lib/ui'; - -import type { CategoryGroup } from '../model/categories'; -import { CategoryIcon } from './CategoryIcon'; -import { CATEGORY_LABELS } from './category-labels'; +import { Icon } from '@/shared/ui/Icon'; import type { CheatSchema } from '../../../protocol/messages'; import { ECheatType } from '../../../protocol/messages'; +import type { CategoryGroup } from '../model/categories'; +import { CategoryIcon } from './CategoryIcon'; import { CheatTile } from './CheatTile'; +import { CATEGORY_LABELS } from './category-labels'; type CategorySectionProps = { - group: CategoryGroup; - values: Record; - pendingTargets: Record; - pinnedTargets: Record; - disabled: boolean; - openByDefault?: boolean; - forceOpen?: boolean; - onCheatChange: (cheat: CheatSchema, nextValue: unknown) => void; - onTogglePin: (cheat: CheatSchema) => void; + group: CategoryGroup; + values: Record; + pendingTargets: Record; + pinnedTargets: Record; + disabled: boolean; + openByDefault?: boolean; + forceOpen?: boolean; + onCheatChange: (cheat: CheatSchema, nextValue: unknown) => void; + onTogglePin: (cheat: CheatSchema) => void; }; const CategorySectionBase = ({ - group, - values, - pendingTargets, - pinnedTargets, - disabled, - openByDefault = true, - forceOpen = false, - onCheatChange, - onTogglePin, + group, + values, + pendingTargets, + pinnedTargets, + disabled, + openByDefault = true, + forceOpen = false, + onCheatChange, + onTogglePin, }: CategorySectionProps) => { - const { _ } = useLingui(); - const [open, setOpen] = useState(openByDefault); - const panelId = `category-panel-${group.id}`; - const { toggleCount, enabledCount } = useMemo(() => countToggles(group.cheats, values), [group.cheats, values]); - const handleToggle = () => setOpen((current) => !current); + const { _ } = useLingui(); + const [open, setOpen] = useState(openByDefault); + const panelId = `category-panel-${group.id}`; + const { toggleCount, enabledCount } = useMemo( + () => countToggles(group.cheats, values), + [group.cheats, values], + ); + const handleToggle = () => setOpen((current) => !current); - const cheatCount = group.cheats.length; - const descriptor = CATEGORY_LABELS[group.id.toLowerCase()]; - const label = descriptor ? _(descriptor) : formatHumanLabel(group.id); - const summary = - toggleCount > 0 ? _(msg`${cheatCount} mods · ${enabledCount}/${toggleCount} on`) : _(msg`${cheatCount} mods`); + const cheatCount = group.cheats.length; + const descriptor = CATEGORY_LABELS[group.id.toLowerCase()]; + const label = descriptor ? _(descriptor) : formatHumanLabel(group.id); + const summary = + toggleCount > 0 + ? _(msg`${cheatCount} mods · ${enabledCount}/${toggleCount} on`) + : _(msg`${cheatCount} mods`); - const cheatHandlers = useMemo( - () => - group.cheats.map((cheat) => ({ - onChange: (nextValue: unknown) => onCheatChange(cheat, nextValue), - onTogglePin: () => onTogglePin(cheat), - })), - [group.cheats, onCheatChange, onTogglePin], - ); + const cheatHandlers = useMemo( + () => + group.cheats.map((cheat) => ({ + onChange: (nextValue: unknown) => onCheatChange(cheat, nextValue), + onTogglePin: () => onTogglePin(cheat), + })), + [group.cheats, onCheatChange, onTogglePin], + ); - useEffect(() => { - if (forceOpen) { - setOpen(true); - } - }, [forceOpen]); + useEffect(() => { + if (forceOpen) { + setOpen(true); + } + }, [forceOpen]); - return ( -
- -
-
- {group.cheats.map((cheat, index) => ( - - ))} -
-
-
- ); + return ( +
+ +
+
+ {group.cheats.map((cheat, index) => ( + + ))} +
+
+
+ ); }; export const CategorySection = memo(CategorySectionBase, (prev, next) => { - if (prev.group !== next.group) return false; - if (prev.disabled !== next.disabled) return false; - if (prev.forceOpen !== next.forceOpen) return false; - // Skipping these would keep stale handlers alive whenever the parent stops - // memoising them - a correctness guarantee should not rest on that assumption. - if (prev.onCheatChange !== next.onCheatChange) return false; - if (prev.onTogglePin !== next.onTogglePin) return false; - for (const cheat of next.group.cheats) { - if (prev.values[cheat.target] !== next.values[cheat.target]) return false; - if (prev.pendingTargets[cheat.target] !== next.pendingTargets[cheat.target]) return false; - if (prev.pinnedTargets[cheat.target] !== next.pinnedTargets[cheat.target]) return false; - } - return true; + if (prev.group !== next.group) return false; + if (prev.disabled !== next.disabled) return false; + if (prev.forceOpen !== next.forceOpen) return false; + // Skipping these would keep stale handlers alive whenever the parent stops + // memoising them - a correctness guarantee should not rest on that assumption. + if (prev.onCheatChange !== next.onCheatChange) return false; + if (prev.onTogglePin !== next.onTogglePin) return false; + for (const cheat of next.group.cheats) { + if (prev.values[cheat.target] !== next.values[cheat.target]) return false; + if (prev.pendingTargets[cheat.target] !== next.pendingTargets[cheat.target]) return false; + if (prev.pinnedTargets[cheat.target] !== next.pinnedTargets[cheat.target]) return false; + } + return true; }); -function countToggles(cheats: CheatSchema[], values: Record): { toggleCount: number; enabledCount: number } { - let toggleCount = 0; - let enabledCount = 0; - for (const cheat of cheats) { - if (cheat.type !== ECheatType.Toggle) continue; - toggleCount += 1; - if (values[cheat.target]) enabledCount += 1; - } - return { toggleCount, enabledCount }; +function countToggles( + cheats: CheatSchema[], + values: Record, +): { toggleCount: number; enabledCount: number } { + let toggleCount = 0; + let enabledCount = 0; + for (const cheat of cheats) { + if (cheat.type !== ECheatType.Toggle) continue; + toggleCount += 1; + if (values[cheat.target]) enabledCount += 1; + } + return { toggleCount, enabledCount }; } diff --git a/web-panel/src/trainer/ui/CheatTile.tsx b/web-panel/src/trainer/ui/CheatTile.tsx index fa7d86b..7cc40fa 100644 --- a/web-panel/src/trainer/ui/CheatTile.tsx +++ b/web-panel/src/trainer/ui/CheatTile.tsx @@ -1,24 +1,23 @@ -import { memo, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; - +import { memo, type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react'; +import { cn } from '@/shared/lib/ui'; import { Icon } from '@/shared/ui/Icon'; import { IconButton } from '@/shared/ui/IconButton'; -import { cn } from '@/shared/lib/ui'; import type { CheatSchema } from '../../../protocol/messages'; import { ECheatType } from '../../../protocol/messages'; import { CheatControl } from '../controls/CheatControl'; type CheatTileProps = { - cheat: CheatSchema; - value: unknown; - pending: boolean; - disabled: boolean; - pinned: boolean; - first: boolean; - onChange: (nextValue: unknown) => void; - onTogglePin: () => void; + cheat: CheatSchema; + value: unknown; + pending: boolean; + disabled: boolean; + pinned: boolean; + first: boolean; + onChange: (nextValue: unknown) => void; + onTogglePin: () => void; }; const SWIPE_REVEAL = 80; @@ -26,171 +25,232 @@ const SWIPE_TRIGGER = 56; const SWIPE_DEAD_ZONE = 8; const SWIPE_ANIMATION_MS = 200; -const CheatTileBase = ({ cheat, value, pending, disabled, pinned, first, onChange, onTogglePin }: CheatTileProps) => { - const { _ } = useLingui(); - const [offset, setOffset] = useState(0); - const [animating, setAnimating] = useState(false); - const [armed, setArmed] = useState(false); - const dragRef = useRef<{ id: number; startX: number; startY: number; locked: boolean | null } | null>(null); - const settleTimerRef = useRef(null); +const CheatTileBase = ({ + cheat, + value, + pending, + disabled, + pinned, + first, + onChange, + onTogglePin, +}: CheatTileProps) => { + const { _ } = useLingui(); + const [offset, setOffset] = useState(0); + const [animating, setAnimating] = useState(false); + const [armed, setArmed] = useState(false); + const dragRef = useRef<{ + id: number; + startX: number; + startY: number; + locked: boolean | null; + } | null>(null); + const settleTimerRef = useRef(null); - useEffect(() => { - setOffset(0); - setArmed(false); - }, [pinned]); + useEffect(() => { + setOffset(0); + setArmed(false); + }, [pinned]); - // Cleared on unmount: the tile unmounts on category change while the timer is pending. - useEffect(() => () => { - if (settleTimerRef.current !== null) { - window.clearTimeout(settleTimerRef.current); - } - }, []); + // Cleared on unmount: the tile unmounts on category change while the timer is pending. + useEffect( + () => () => { + if (settleTimerRef.current !== null) { + window.clearTimeout(settleTimerRef.current); + } + }, + [], + ); - const settle = (target: number) => { - setAnimating(true); - setOffset(target); - if (settleTimerRef.current !== null) { - window.clearTimeout(settleTimerRef.current); - } - settleTimerRef.current = window.setTimeout(() => { - settleTimerRef.current = null; - setAnimating(false); - }, SWIPE_ANIMATION_MS); - }; + const settle = (target: number) => { + setAnimating(true); + setOffset(target); + if (settleTimerRef.current !== null) { + window.clearTimeout(settleTimerRef.current); + } + settleTimerRef.current = window.setTimeout(() => { + settleTimerRef.current = null; + setAnimating(false); + }, SWIPE_ANIMATION_MS); + }; - const handlePointerDown = (event: ReactPointerEvent) => { - if (event.pointerType === 'mouse' && event.button !== 0) return; - if (isInteractiveTarget(event.target)) return; - dragRef.current = { id: event.pointerId, startX: event.clientX, startY: event.clientY, locked: null }; - setAnimating(false); - }; + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + if (isInteractiveTarget(event.target)) return; + dragRef.current = { + id: event.pointerId, + startX: event.clientX, + startY: event.clientY, + locked: null, + }; + setAnimating(false); + }; - const handlePointerMove = (event: ReactPointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.id !== event.pointerId) return; + const handlePointerMove = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.id !== event.pointerId) return; - const dx = event.clientX - drag.startX; - const dy = event.clientY - drag.startY; + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; - if (drag.locked === null) { - if (Math.abs(dx) < SWIPE_DEAD_ZONE && Math.abs(dy) < SWIPE_DEAD_ZONE) return; - drag.locked = Math.abs(dx) > Math.abs(dy) && dx < 0; - if (!drag.locked) { + if (drag.locked === null) { + if (Math.abs(dx) < SWIPE_DEAD_ZONE && Math.abs(dy) < SWIPE_DEAD_ZONE) return; + drag.locked = Math.abs(dx) > Math.abs(dy) && dx < 0; + if (!drag.locked) { + dragRef.current = null; + return; + } + event.currentTarget.setPointerCapture(event.pointerId); + } + + const next = clamp(dx, -SWIPE_REVEAL * 1.2, 0); + setOffset(next); + setArmed(-next >= SWIPE_TRIGGER); + }; + + const handlePointerEnd = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.id !== event.pointerId) return; dragRef.current = null; - return; - } - event.currentTarget.setPointerCapture(event.pointerId); - } - const next = clamp(dx, -SWIPE_REVEAL * 1.2, 0); - setOffset(next); - setArmed(-next >= SWIPE_TRIGGER); - }; + if (drag.locked !== true) { + return; + } - const handlePointerEnd = (event: ReactPointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.id !== event.pointerId) return; - dragRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } - if (drag.locked !== true) { - return; - } + const triggered = -offset >= SWIPE_TRIGGER; + settle(0); + setArmed(false); + if (triggered) { + onTogglePin(); + } + }; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } + const stacked = isStackedControl(cheat); + const showReveal = offset < 0; - const triggered = -offset >= SWIPE_TRIGGER; - settle(0); - setArmed(false); - if (triggered) { - onTogglePin(); - } - }; - - const stacked = isStackedControl(cheat); - const showReveal = offset < 0; - - return ( -
- {showReveal ? : null} -
-
-
-
-
-

{cheat.name}

- {pending ? : null} - {/* The swipe gesture is pointer-only; this is the keyboard and screen-reader path. */} - -
- {cheat.description ?

{cheat.description}

: null} + return ( +
+ {showReveal ? : null} +
+
+
+
+
+

+ {cheat.name} +

+ {pending ? ( + + ) : null} + {/* The swipe gesture is pointer-only; this is the keyboard and screen-reader path. */} + +
+ {cheat.description ? ( +

+ {cheat.description} +

+ ) : null} +
+ +
+ {cheat.instructions ? ( +
+ + {cheat.instructions} +
+ ) : null} +
- -
- {cheat.instructions ? ( -
- - {cheat.instructions} -
- ) : null}
-
-
- ); + ); }; export const CheatTile = memo(CheatTileBase); const PinReveal = ({ pinned, armed }: { pinned: boolean; armed: boolean }) => { - return ( -
- - - -
- ); + return ( +
+ + + +
+ ); }; function isStackedControl(cheat: CheatSchema): boolean { - if ( - cheat.type === ECheatType.Slider || - cheat.type === ECheatType.Scalar || - cheat.type === ECheatType.Number || - cheat.type === ECheatType.Incremental || - cheat.type === ECheatType.Button - ) { - return true; - } + if ( + cheat.type === ECheatType.Slider || + cheat.type === ECheatType.Scalar || + cheat.type === ECheatType.Number || + cheat.type === ECheatType.Incremental || + cheat.type === ECheatType.Button + ) { + return true; + } - if (cheat.type === ECheatType.Selection) { - const optionCount = cheat.args.options?.length ?? 0; - return optionCount > 0; - } + if (cheat.type === ECheatType.Selection) { + const optionCount = cheat.args.options?.length ?? 0; + return optionCount > 0; + } - return false; + return false; } function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max); + return Math.min(Math.max(value, min), max); } function isInteractiveTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) return false; - return Boolean(target.closest('input, button, select, textarea, a, [role="slider"], [role="button"]')); + if (!(target instanceof Element)) return false; + return Boolean( + target.closest('input, button, select, textarea, a, [role="slider"], [role="button"]'), + ); } diff --git a/web-panel/src/trainer/ui/QuickActions.tsx b/web-panel/src/trainer/ui/QuickActions.tsx index 0c264ed..54c5087 100644 --- a/web-panel/src/trainer/ui/QuickActions.tsx +++ b/web-panel/src/trainer/ui/QuickActions.tsx @@ -1,176 +1,223 @@ -import { useEffect, useRef, useState, type FormEvent } from 'react'; -import { createPortal } from 'react-dom'; import { msg } from '@lingui/core/macro'; -import { Trans } from '@lingui/react/macro'; import { useLingui } from '@lingui/react'; - -import { IconButton } from '@/shared/ui/IconButton'; -import { Icon } from '@/shared/ui/Icon'; +import { Trans } from '@lingui/react/macro'; +import { type FormEvent, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; +import { IconButton } from '@/shared/ui/IconButton'; import type { RemotePreset } from '../presets/preset-storage'; type QuickActionsProps = { - presets: RemotePreset[]; - onPanic: () => void; - onAddPreset: (name: string) => boolean; - onApplyPreset: (preset: RemotePreset) => void; - onDeletePreset: (presetId: string) => void; + presets: RemotePreset[]; + onPanic: () => void; + onAddPreset: (name: string) => boolean; + onApplyPreset: (preset: RemotePreset) => void; + onDeletePreset: (presetId: string) => void; }; -export const QuickActions = ({ presets, onPanic, onAddPreset, onApplyPreset, onDeletePreset }: QuickActionsProps) => { - const { _ } = useLingui(); - const [modalOpen, setModalOpen] = useState(false); - const [draftName, setDraftName] = useState(''); +export const QuickActions = ({ + presets, + onPanic, + onAddPreset, + onApplyPreset, + onDeletePreset, +}: QuickActionsProps) => { + const { _ } = useLingui(); + const [modalOpen, setModalOpen] = useState(false); + const [draftName, setDraftName] = useState(''); - const handleOpenModal = () => { - setDraftName(''); - setModalOpen(true); - }; + const handleOpenModal = () => { + setDraftName(''); + setModalOpen(true); + }; - const handleCloseModal = () => setModalOpen(false); + const handleCloseModal = () => setModalOpen(false); - const handleSubmitPreset = (name: string): boolean => { - const saved = onAddPreset(name); - if (!saved) { - return false; - } + const handleSubmitPreset = (name: string): boolean => { + const saved = onAddPreset(name); + if (!saved) { + return false; + } - setModalOpen(false); - return true; - }; + setModalOpen(false); + return true; + }; - return ( - <> -
- - {presets.map((preset) => ( - - ))} - -
- {modalOpen - ? createPortal( - , - document.body, - ) - : null} - - ); + return ( + <> +
+ + {presets.map((preset) => ( + + ))} + +
+ {modalOpen + ? createPortal( + , + document.body, + ) + : null} + + ); }; type ChipProps = { - icon: 'bolt' | 'plus'; - label: string; - variant: 'add' | 'danger'; - onClick: () => void; + icon: 'bolt' | 'plus'; + label: string; + variant: 'add' | 'danger'; + onClick: () => void; }; const Chip = ({ icon, label, variant, onClick }: ChipProps) => { - return ( - - ); + return ( + + ); }; type PresetChipProps = { - preset: RemotePreset; - onApply: (preset: RemotePreset) => void; - onDelete: (presetId: string) => void; + preset: RemotePreset; + onApply: (preset: RemotePreset) => void; + onDelete: (presetId: string) => void; }; const PresetChip = ({ preset, onApply, onDelete }: PresetChipProps) => { - const { _ } = useLingui(); - const handleApply = () => onApply(preset); - const handleDelete = () => onDelete(preset.id); + const { _ } = useLingui(); + const handleApply = () => onApply(preset); + const handleDelete = () => onDelete(preset.id); - return ( - - - - - ); + return ( + + + + + ); }; type PresetModalProps = { - draftName: string; - onClose: () => void; - onDraftNameChange: (name: string) => void; - onSubmit: (name: string) => boolean; + draftName: string; + onClose: () => void; + onDraftNameChange: (name: string) => void; + onSubmit: (name: string) => boolean; }; const PresetModal = ({ draftName, onClose, onDraftNameChange, onSubmit }: PresetModalProps) => { - const { _ } = useLingui(); - const inputRef = useRef(null); - const trimmedName = draftName.trim(); + const { _ } = useLingui(); + const inputRef = useRef(null); + const trimmedName = draftName.trim(); - useEffect(() => { - inputRef.current?.focus(); - }, []); + useEffect(() => { + inputRef.current?.focus(); + }, []); - const handleInput = (event: FormEvent) => onDraftNameChange(event.currentTarget.value); + const handleInput = (event: FormEvent) => + onDraftNameChange(event.currentTarget.value); - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - const nextName = trimmedName || _(msg`New preset`); - onSubmit(nextName); - }; + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const nextName = trimmedName || _(msg`New preset`); + onSubmit(nextName); + }; - return ( -
- + +
+
- -
- - -
- -
- ); + ); }; function getChipVariantClass(variant: ChipProps['variant']): string { - if (variant === 'danger') { - return 'border-red-400/30 bg-red-500/10 text-red-300 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]'; - } + if (variant === 'danger') { + return 'border-red-400/30 bg-red-500/10 text-red-300 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]'; + } - return 'border-white/10 bg-white/[0.055] text-(--deck-fg-2) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]'; + return 'border-white/10 bg-white/5.5 text-(--deck-fg-2) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]'; } diff --git a/web-panel/src/trainer/ui/TrainerHeader.tsx b/web-panel/src/trainer/ui/TrainerHeader.tsx index a2cfb50..d7ef4ce 100644 --- a/web-panel/src/trainer/ui/TrainerHeader.tsx +++ b/web-panel/src/trainer/ui/TrainerHeader.tsx @@ -1,56 +1,74 @@ import { msg } from '@lingui/core/macro'; -import { Trans } from '@lingui/react/macro'; import { useLingui } from '@lingui/react'; - -import { Icon } from '@/shared/ui/Icon'; -import { cn } from '@/shared/lib/ui'; - +import { Trans } from '@lingui/react/macro'; import type { LibraryGame } from '@/library/model/games'; import { GameCover } from '@/library/ui/GameCover'; - -import { getTrainerDisplayName } from '../model/categories'; +import { cn } from '@/shared/lib/ui'; +import { Icon } from '@/shared/ui/Icon'; import type { TrainerSummary } from '../../../protocol/messages'; +import { getTrainerDisplayName } from '../model/categories'; type TrainerHeaderProps = { - trainer: TrainerSummary; - game: LibraryGame | null; - isPinned: boolean; - onPin: () => void; + trainer: TrainerSummary; + game: LibraryGame | null; + isPinned: boolean; + onPin: () => void; }; export const TrainerHeader = ({ trainer, game, isPinned, onPin }: TrainerHeaderProps) => { - const { _ } = useLingui(); + const { _ } = useLingui(); - return ( -
-
-
- {game ? : } -
-
- - Trainer Active -
-

{getTrainerDisplayName(trainer)}

-
- {game?.platform ?? 'Wand'} - {trainer.gameVersion ? · v{trainer.gameVersion} : null} - · #{trainer.trainerId} -
-
- -
-
- ); + return ( +
+
+
+ {game ? : } +
+
+ + + Trainer Active + +
+

+ {getTrainerDisplayName(trainer)} +

+
+ {game?.platform ?? 'Wand'} + {trainer.gameVersion ? ( + · v{trainer.gameVersion} + ) : null} + · #{trainer.trainerId} +
+
+ +
+
+ ); }; const FallbackCover = () => { - return ( -
-
-
WAND
-
- ); + return ( +
+
+
+ WAND +
+
+ ); }; diff --git a/web-panel/src/trainer/ui/category-labels.ts b/web-panel/src/trainer/ui/category-labels.ts index 6e71752..900e92c 100644 --- a/web-panel/src/trainer/ui/category-labels.ts +++ b/web-panel/src/trainer/ui/category-labels.ts @@ -2,21 +2,21 @@ import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; export const CATEGORY_LABELS: Record = { - challenge: msg`Challenge`, - character: msg`Character`, - cheats: msg`Cheats`, - crafting: msg`Crafting`, - enemies: msg`Enemies`, - game: msg`Game`, - inventory: msg`Inventory`, - items: msg`Items`, - physics: msg`Physics`, - pinned: msg`Pinned`, - player: msg`Player`, - resources: msg`Resources`, - stats: msg`Stats`, - teleport: msg`Teleport`, - vehicles: msg`Vehicles`, - weapons: msg`Weapons`, - world: msg`World`, + challenge: msg`Challenge`, + character: msg`Character`, + cheats: msg`Cheats`, + crafting: msg`Crafting`, + enemies: msg`Enemies`, + game: msg`Game`, + inventory: msg`Inventory`, + items: msg`Items`, + physics: msg`Physics`, + pinned: msg`Pinned`, + player: msg`Player`, + resources: msg`Resources`, + stats: msg`Stats`, + teleport: msg`Teleport`, + vehicles: msg`Vehicles`, + weapons: msg`Weapons`, + world: msg`World`, }; diff --git a/web-panel/tsconfig.app.json b/web-panel/tsconfig.app.json index 2aa77aa..490d9d1 100644 --- a/web-panel/tsconfig.app.json +++ b/web-panel/tsconfig.app.json @@ -3,15 +3,9 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2022", "useDefineForClassFields": true, - "lib": [ - "ES2022", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": [ - "vite/client" - ], + "types": ["vite/client"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", @@ -28,14 +22,9 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, - "baseUrl": ".", "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, - "include": [ - "src" - ] -} \ No newline at end of file + "include": ["src"] +} diff --git a/web-panel/tsconfig.json b/web-panel/tsconfig.json index efe44b1..7f43c52 100644 --- a/web-panel/tsconfig.json +++ b/web-panel/tsconfig.json @@ -2,11 +2,7 @@ "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, - "lib": [ - "DOM", - "DOM.Iterable", - "ES2022" - ], + "lib": ["DOM", "DOM.Iterable", "ES2022"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, @@ -19,21 +15,10 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "types": [ - "vite/client", - "node" - ], - "baseUrl": ".", + "types": ["vite/client", "node"], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, - "include": [ - "src", - "protocol", - "vitest.config.ts", - "vite.config.ts" - ] + "include": ["src", "protocol", "vitest.config.ts", "vite.config.ts"] } diff --git a/web-panel/tsconfig.node.json b/web-panel/tsconfig.node.json index 8a67f62..0cc4c35 100644 --- a/web-panel/tsconfig.node.json +++ b/web-panel/tsconfig.node.json @@ -1,26 +1,26 @@ { - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023"], - "module": "ESNext", - "types": ["node"], - "skipLibCheck": true, + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] } diff --git a/web-panel/vite.config.ts b/web-panel/vite.config.ts index 21dae8c..0a6cdeb 100644 --- a/web-panel/vite.config.ts +++ b/web-panel/vite.config.ts @@ -1,40 +1,40 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite'; -import { lingui } from '@lingui/vite-plugin'; import { fileURLToPath, URL } from 'node:url'; +import { lingui } from '@lingui/vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [ - react({ babel: { plugins: ['@lingui/babel-plugin-lingui-macro'] } }), - lingui(), - tailwindcss(), - ], - base: './', - resolve: { - alias: [ - { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, - { find: 'react-dom/client', replacement: 'preact/compat/client' }, - { find: 'react-dom', replacement: 'preact/compat' }, - { find: 'react/jsx-runtime', replacement: 'preact/jsx-runtime' }, - { find: 'react/jsx-dev-runtime', replacement: 'preact/jsx-dev-runtime' }, - { find: 'react', replacement: 'preact/compat' }, + plugins: [ + react({ babel: { plugins: ['@lingui/babel-plugin-lingui-macro'] } }), + lingui(), + tailwindcss(), ], - }, - server: { - host: '127.0.0.1', - port: 4173, - strictPort: true, - }, - preview: { - host: '127.0.0.1', - port: 4173, - strictPort: true, - }, - build: { - outDir: 'dist', - assetsDir: 'assets', - target: 'es2020', - sourcemap: false, - }, -}); \ No newline at end of file + base: './', + resolve: { + alias: [ + { find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }, + { find: 'react-dom/client', replacement: 'preact/compat/client' }, + { find: 'react-dom', replacement: 'preact/compat' }, + { find: 'react/jsx-runtime', replacement: 'preact/jsx-runtime' }, + { find: 'react/jsx-dev-runtime', replacement: 'preact/jsx-dev-runtime' }, + { find: 'react', replacement: 'preact/compat' }, + ], + }, + server: { + host: '127.0.0.1', + port: 4173, + strictPort: true, + }, + preview: { + host: '127.0.0.1', + port: 4173, + strictPort: true, + }, + build: { + outDir: 'dist', + assetsDir: 'assets', + target: 'es2020', + sourcemap: false, + }, +}); diff --git a/web-panel/vitest.config.ts b/web-panel/vitest.config.ts index a123f28..96cd729 100644 --- a/web-panel/vitest.config.ts +++ b/web-panel/vitest.config.ts @@ -3,17 +3,17 @@ import { defineConfig, mergeConfig } from 'vitest/config'; import viteConfig from './vite.config'; export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'jsdom', - restoreMocks: true, - alias: { - 'react-dom/test-utils': 'preact/test-utils', - }, - // Inline @lingui/react so its bare `react` import resolves to preact/compat - // via the alias above instead of pulling in a second (real) React copy. - server: { deps: { inline: [/@lingui\/react/] } }, - }, - }), + viteConfig, + defineConfig({ + test: { + environment: 'jsdom', + restoreMocks: true, + alias: { + 'react-dom/test-utils': 'preact/test-utils', + }, + // Inline @lingui/react so its bare `react` import resolves to preact/compat + // via the alias above instead of pulling in a second (real) React copy. + server: { deps: { inline: [/@lingui\/react/] } }, + }, + }), );