From 5d1aa69a97f7548b9ada6be70e7a160bde7e1d0c Mon Sep 17 00:00:00 2001 From: kitbyte Date: Sat, 29 Aug 2026 16:56:45 +0300 Subject: [PATCH] refactor(bridge): type the bridge and harden the websocket and file server Type every module under bridge/src against a shared types.ts vocabulary and the protocol payload types; narrow Wand IPC input as unknown instead of any. Keep the Electron port types out of the CommonJS runtime files, where an export statement makes esbuild treat the module as ESM and drop module.exports. Reject unmasked client frames, enforce control-frame length and fragmentation limits, and echo the peer close code. Resolve static requests inside the panel root and serve the whole root, not only assets/. Ignore value-changed events that name a different trainer. Validate envelopes through the shared protocol validator. Bind the renderer destroyed listener once per sender. Handle https response errors while fetching trainer strings. Compare installed-app snapshots structurally so no field is missed. --- web-panel/bridge/src/bridge-state.ts | 71 ++-- web-panel/bridge/src/constants.ts | 8 +- web-panel/bridge/src/index.ts | 3 +- web-panel/bridge/src/logger.ts | 13 +- .../bridge/src/normalizers/command-results.ts | 25 +- .../bridge/src/normalizers/game-status.ts | 15 +- web-panel/bridge/src/normalizers/index.ts | 214 ++++++------ web-panel/bridge/src/normalizers/trainer.ts | 22 +- web-panel/bridge/src/protocol-router.test.ts | 3 +- web-panel/bridge/src/protocol-router.ts | 57 ++-- web-panel/bridge/src/runtime.ts | 7 +- web-panel/bridge/src/server-files.ts | 64 +++- web-panel/bridge/src/server.ts | 314 ++++++++++-------- web-panel/bridge/src/types.ts | 87 ++++- web-panel/bridge/src/utils.ts | 27 +- web-panel/bridge/src/wand/renderer-scripts.ts | 24 +- web-panel/bridge/src/wand/runtime.ts | 158 +++++---- .../src/wand/trainer-localization.test.ts | 13 +- .../bridge/src/wand/trainer-localization.ts | 91 +++-- web-panel/bridge/src/websocket-codec.ts | 38 ++- web-panel/protocol/messages.ts | 2 +- web-panel/protocol/validation.ts | 4 +- 22 files changed, 758 insertions(+), 502 deletions(-) diff --git a/web-panel/bridge/src/bridge-state.ts b/web-panel/bridge/src/bridge-state.ts index 89c24a2..27fa757 100644 --- a/web-panel/bridge/src/bridge-state.ts +++ b/web-panel/bridge/src/bridge-state.ts @@ -1,3 +1,6 @@ +import type { BridgeClient, LogFn, ServerInfo } from './types'; +import type { GameStatusPayload, InstalledAppsPayload, TrainerMetaPayload, TrainerValuesPayload, IncomingMessage } from '../../protocol/messages'; + const { gameStatusSignature, installedAppsSignature, @@ -6,18 +9,40 @@ const { normalizeSnapshot, normalizeTrainerValue, summarizeInstalledAppsSource, -} = require('./normalizers'); -const { cloneValue, isRecord, safeString } = require('./utils'); -const { sendJson } = require('./websocket-codec'); +} = require('./normalizers') as { + gameStatusSignature: (snapshot: GameStatusPayload) => string; + installedAppsSignature: (snapshot: InstalledAppsPayload) => string; + normalizeGameStatusSnapshot: (snapshot: unknown) => GameStatusPayload | null; + normalizeInstalledAppsSnapshot: (snapshot: unknown) => InstalledAppsPayload | null; + normalizeSnapshot: (snapshot: unknown) => BridgeStateSnapshot | null; + normalizeTrainerValue: (snapshot: BridgeStateSnapshot, target: string, value: unknown) => unknown; + summarizeInstalledAppsSource: (snapshot: unknown) => string; +}; +const { cloneValue, isRecord, safeString } = require('./utils') as { + cloneValue: (value: unknown) => unknown; + isRecord: (value: unknown) => value is Record; + safeString: (value: unknown, fallback?: string) => string; +}; +const { sendJson } = require('./websocket-codec') as { + sendJson: (client: BridgeClient, type: string, payload: unknown, requestId?: string | number | null) => void; +}; -function createBridgeState({ clients, log, getServerInfo }) { - let currentSnapshot: any = null; - let currentInstalledApps: any = null; +type BridgeStateSnapshot = { trainerMeta: TrainerMetaPayload, trainerValues: TrainerValuesPayload }; + +type BridgeStateOptions = { + clients: Iterable; + log: LogFn; + getServerInfo: () => ServerInfo & { listening: boolean; remoteUrl: string | null }; +}; + +function createBridgeState({ clients, log, getServerInfo }: BridgeStateOptions) { + let currentSnapshot: BridgeStateSnapshot | null = null; + let currentInstalledApps: InstalledAppsPayload | null = null; let currentInstalledAppsSignature: string | null = null; - let currentGameStatus: any = null; + let currentGameStatus: GameStatusPayload | null = null; let currentGameStatusSignature: string | null = null; - function broadcast(type, payload, requestId = 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); @@ -25,7 +50,7 @@ function createBridgeState({ clients, log, getServerInfo }) { } } - function sendSnapshot(client) { + function sendSnapshot(client: BridgeClient) { if (!currentSnapshot) { sendJson(client, 'trainer_changed', { previousTrainerId: null, trainerId: '' }); } else { @@ -36,7 +61,7 @@ function createBridgeState({ clients, log, getServerInfo }) { if (currentInstalledApps) sendJson(client, 'installed_apps', currentInstalledApps); } - function sync(rawSnapshot) { + function sync(rawSnapshot: unknown) { const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null; const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null; const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null; @@ -51,10 +76,9 @@ function createBridgeState({ clients, log, getServerInfo }) { } } - function syncTrainerMeta(rawSnapshot) { + function syncTrainerMeta(rawSnapshot: unknown) { const localizedSnapshot = normalizeSnapshot(rawSnapshot); - const activeTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId; - if (!localizedSnapshot || localizedSnapshot.trainerMeta.trainer.trainerId !== activeTrainerId) { + if (!currentSnapshot || !localizedSnapshot || localizedSnapshot.trainerMeta.trainer.trainerId !== currentSnapshot.trainerMeta.trainer.trainerId) { return; } @@ -62,15 +86,18 @@ function createBridgeState({ clients, log, getServerInfo }) { broadcast('trainer_meta', currentSnapshot.trainerMeta); } - function valueChanged(change) { - if (!currentSnapshot || !isRecord(change)) return; + function valueChanged(change: unknown) { + const snapshot = currentSnapshot; + 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(currentSnapshot, target, change.value); - currentSnapshot.trainerValues.values[target] = value; + const value = normalizeTrainerValue(snapshot, target, change.value); + snapshot.trainerValues.values[target] = value; broadcast('value_changed', { - trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId), + trainerId: snapshot.trainerMeta.trainer.trainerId, target, value, oldValue: cloneValue(change.oldValue), @@ -79,7 +106,7 @@ function createBridgeState({ clients, log, getServerInfo }) { }); } - function syncInstalledApps(rawInstalledApps) { + function syncInstalledApps(rawInstalledApps: unknown) { const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps); const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps); if (!nextInstalledApps) { @@ -90,11 +117,11 @@ function createBridgeState({ clients, log, getServerInfo }) { if (nextSignature === currentInstalledAppsSignature) return; currentInstalledApps = nextInstalledApps; currentInstalledAppsSignature = nextSignature; - log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`); + log('info', `Installed apps snapshot accepted (${nextInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`); broadcast('installed_apps', currentInstalledApps); } - function syncGameStatus(rawGameStatus) { + function syncGameStatus(rawGameStatus: unknown) { const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus); if (!nextGameStatus) { log('warn', 'Ignored invalid game status snapshot.'); @@ -104,7 +131,7 @@ function createBridgeState({ clients, log, getServerInfo }) { if (nextSignature === currentGameStatusSignature) return; currentGameStatus = nextGameStatus; currentGameStatusSignature = nextSignature; - log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`); + log('info', `Game status snapshot accepted (${nextGameStatus.session.state}/${nextGameStatus.session.event}).`); broadcast('game_status', currentGameStatus); } diff --git a/web-panel/bridge/src/constants.ts b/web-panel/bridge/src/constants.ts index 89a5c4b..7cb5fca 100644 --- a/web-panel/bridge/src/constants.ts +++ b/web-panel/bridge/src/constants.ts @@ -1,6 +1,8 @@ -const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']); +const { ECheatType } = require('../../protocol/messages'); const WEB_CONTRACT = require('../../protocol/web-contract.json'); +const KNOWN_CHEAT_TYPES = new Set(Object.values(ECheatType)); + const WS_OPCODE = Object.freeze({ TEXT: 1, BINARY: 2, @@ -34,12 +36,8 @@ module.exports = { PORT_SCAN_RANGE: WEB_CONTRACT.portScanRange, REMOTE_ASSETS_PREFIX: WEB_CONTRACT.assetsPath, REMOTE_BASE_PATH: WEB_CONTRACT.basePath, - REMOTE_COMMAND_REQUEST_CHANNEL: IPC_CHANNEL.COMMAND_REQUEST, - REMOTE_COMMAND_RESPONSE_CHANNEL: IPC_CHANNEL.COMMAND_RESPONSE, REMOTE_COMMAND_RESPONSE_TIMEOUT_MS: 15000, - REMOTE_GAME_STATUS_CHANNEL: IPC_CHANNEL.GAME_STATUS, REMOTE_HEALTH_PATH: WEB_CONTRACT.healthPath, - REMOTE_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS, REMOTE_WS_PATH: WEB_CONTRACT.webSocketPath, RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]), RENDERER_SCRIPT_API_VERSION: 1, diff --git a/web-panel/bridge/src/index.ts b/web-panel/bridge/src/index.ts index 1986f71..55029ad 100644 --- a/web-panel/bridge/src/index.ts +++ b/web-panel/bridge/src/index.ts @@ -1,6 +1,7 @@ const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./runtime'); const { installWandRuntime: installRuntime } = require('./wand/runtime'); -import type { BridgeOptions, ElectronPort } from './types'; +import type { BridgeOptions } from './types'; +import type { 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 21aebd7..ebcd2cf 100644 --- a/web-panel/bridge/src/logger.ts +++ b/web-panel/bridge/src/logger.ts @@ -3,30 +3,31 @@ const os = require('node:os'); const path = require('node:path'); const { BRIDGE_LOG_FILE_NAME } = require('./constants'); -import type { BridgeOptions } from './types'; +import type { BridgeLogger, BridgeOptions, LogLevel } from './types'; -function writeLogLine(logFile, level, message, error) { +function writeLogLine(logFile: string, level: LogLevel, message: string, error?: unknown) { const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info'; const tag = `[wand-remote-bridge] ${message}`; + // Logging runs inside Wand's own process; a failure here must never take the app down. try { console[method](tag, error || ''); } catch { } try { - const detail = error ? ` :: ${error && error.stack ? error.stack : String(error)}` : ''; + 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 = {}) { +function createBridgeLogger(options: BridgeOptions = {}): BridgeLogger { const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME); - const log = (level, message, error) => writeLogLine(logFile, level, message, error); + const log = ((level: LogLevel, message: string, error?: unknown) => writeLogLine(logFile, level, message, error)) as BridgeLogger; log.file = logFile; return log; } -function writeInstallLog(level, message, error) { +function writeInstallLog(level: LogLevel, message: string, error?: unknown) { writeLogLine(path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME), level, message, error); } diff --git a/web-panel/bridge/src/normalizers/command-results.ts b/web-panel/bridge/src/normalizers/command-results.ts index 8702d74..9e30f22 100644 --- a/web-panel/bridge/src/normalizers/command-results.ts +++ b/web-panel/bridge/src/normalizers/command-results.ts @@ -1,17 +1,22 @@ +import type { RemoteCommandAction, RemoteCommandResultPayload } from '../../../protocol/messages'; +import type { UnknownRecord } from '../types'; const { isRecord, safeString, toStringId } = require('../utils'); -function normalizeRemoteCommandAction(value) { - return value === 'launch' || value === 'stop' ? value : null; +function normalizeRemoteCommandAction(value: unknown): RemoteCommandAction | null { + return value === 'launch' || value === 'stop' ? (value as RemoteCommandAction) : null; } -function normalizeRemoteCommandResult(rawResult, fallback) { - const action = normalizeRemoteCommandAction(isRecord(rawResult) ? rawResult.action : null) || fallback.action; - const gameId = isRecord(rawResult) ? toStringId(rawResult.gameId) || fallback.gameId || null : fallback.gameId || null; - const titleId = isRecord(rawResult) ? toStringId(rawResult.titleId) || fallback.titleId || null : fallback.titleId || null; - const ok = rawResult === true || Boolean(isRecord(rawResult) && rawResult.ok === true); +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 ok = rawResult === true || Boolean(raw && raw.ok === true); const payload = { ok, action, gameId, titleId }; if (ok) return payload; - if (!isRecord(rawResult) || !isRecord(rawResult.error)) { + + 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.' }, @@ -20,8 +25,8 @@ function normalizeRemoteCommandResult(rawResult, fallback) { return { ...payload, error: { - code: safeString(rawResult.error.code, 'command_rejected'), - message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'), + code: safeString(errorRaw.code, 'command_rejected'), + message: safeString(errorRaw.message, 'The renderer rejected the remote command.'), }, }; } diff --git a/web-panel/bridge/src/normalizers/game-status.ts b/web-panel/bridge/src/normalizers/game-status.ts index cc0b56c..86b9910 100644 --- a/web-panel/bridge/src/normalizers/game-status.ts +++ b/web-panel/bridge/src/normalizers/game-status.ts @@ -1,12 +1,15 @@ +import type { GameStatusPayload } from '../../../protocol/messages'; +import type { UnknownRecord } from '../types'; const { isRecord, safeString, toStringId } = require('../utils'); -function normalizeGameStatusSnapshot(rawSnapshot) { +function normalizeGameStatusSnapshot(rawSnapshot: unknown): GameStatusPayload | null { if (!isRecord(rawSnapshot)) return null; - const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {}; - const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {}; + const snap = rawSnapshot as UnknownRecord; + const rawSession = isRecord(snap.session) ? (snap.session as UnknownRecord) : {}; + const rawTrainer = isRecord(snap.trainer) ? (snap.trainer as UnknownRecord) : {}; return { - instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'), - updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(), + instanceId: safeString(snap.instanceId, 'wand-game-status'), + updatedAt: typeof snap.updatedAt === 'string' ? snap.updatedAt : new Date().toISOString(), session: { state: rawSession.state === 'running' ? 'running' : 'idle', event: safeString(rawSession.event, 'snapshot'), @@ -29,7 +32,7 @@ function normalizeGameStatusSnapshot(rawSnapshot) { }; } -function gameStatusSignature(snapshot) { +function gameStatusSignature(snapshot: GameStatusPayload): string { return [ snapshot.session.state, snapshot.session.event, diff --git a/web-panel/bridge/src/normalizers/index.ts b/web-panel/bridge/src/normalizers/index.ts index 3ee8eca..82e085f 100644 --- a/web-panel/bridge/src/normalizers/index.ts +++ b/web-panel/bridge/src/normalizers/index.ts @@ -1,10 +1,12 @@ +import type { CheatArgs, CheatOption, CheatSchema, InstalledAppsPayload, InstalledAppSummary, 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'); const { gameStatusSignature, normalizeGameStatusSnapshot } = require('./game-status'); const { normalizeTrainerValue } = require('./trainer'); -function normalizeOption(option) { +function normalizeOption(option: unknown): CheatOption | null { if (typeof option === 'string' || typeof option === 'number') { return { label: String(option), @@ -16,77 +18,80 @@ function normalizeOption(option) { return null; } - const value = option.value; + const opt = option as UnknownRecord; + const value = opt.value; if (typeof value !== 'string' && typeof value !== 'number') { return null; } return { - label: safeString(option.label, String(value)), + label: safeString(opt.label, String(value)), value, }; } -function normalizeArgs(args) { +function normalizeArgs(args: unknown): CheatArgs { if (!isRecord(args)) { return {}; } - const next: Record = {}; - if (typeof args.min === 'number') next.min = args.min; - if (typeof args.max === 'number') next.max = args.max; - if (typeof args.step === 'number') next.step = args.step; - if (typeof args.postfix === 'string') next.postfix = args.postfix; - if (typeof args.default === 'string' || typeof args.default === 'number' || typeof args.default === 'boolean') { - next.default = args.default; + const a = args as UnknownRecord; + const next: CheatArgs = {}; + if (typeof a.min === 'number') next.min = a.min; + 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') { + next.default = a.default; } - if (Array.isArray(args.options)) { - next.options = args.options.map(normalizeOption).filter(Boolean); + if (Array.isArray(a.options)) { + next.options = a.options.map(normalizeOption).filter(Boolean) as CheatOption[]; } - if (typeof args.button === 'string' || typeof args.button === 'boolean') { - next.button = args.button; + if (typeof a.button === 'string' || typeof a.button === 'boolean') { + next.button = a.button; } return next; } -function normalizeCheat(cheat, index) { +function normalizeCheat(cheat: unknown, index: number): CheatSchema | null { if (!isRecord(cheat)) { return null; } - const target = safeString(cheat.target); - const type = safeString(cheat.type); + const c = cheat as UnknownRecord; + const target = safeString(c.target); + const type = safeString(c.type) as CheatSchema['type']; if (!target || !KNOWN_CHEAT_TYPES.has(type)) { return null; } - const normalized: Record = { - uuid: safeString(cheat.uuid, `${target}-${index}`), + const normalized: CheatSchema = { + uuid: safeString(c.uuid, `${target}-${index}`), target, type, - name: safeString(cheat.name, target), - description: typeof cheat.description === 'string' ? cheat.description : null, - instructions: typeof cheat.instructions === 'string' ? cheat.instructions : null, - category: safeString(cheat.category, 'general'), - parent: typeof cheat.parent === 'string' ? cheat.parent : null, - args: normalizeArgs(cheat.args), + name: safeString(c.name, target), + description: typeof c.description === 'string' ? c.description : null, + instructions: typeof c.instructions === 'string' ? c.instructions : null, + category: safeString(c.category, 'general'), + parent: typeof c.parent === 'string' ? c.parent : null, + args: normalizeArgs(c.args), }; - if (typeof cheat.flags === 'number') { - normalized.flags = cheat.flags; + if (typeof c.flags === 'number') { + normalized.flags = c.flags; } - if (Array.isArray(cheat.hotkeys)) { - normalized.hotkeys = cheat.hotkeys.filter(Array.isArray).map((group) => group.map((item) => String(item))); + if (Array.isArray(c.hotkeys)) { + normalized.hotkeys = c.hotkeys.filter(Array.isArray).map((group: unknown[]) => group.map((item: unknown) => String(item))); } return normalized; } -function normalizeImageUrl(...values) { +function normalizeImageUrl(...values: unknown[]): string | null { const value = firstString(...values); if (!value) { return null; @@ -100,76 +105,78 @@ function normalizeImageUrl(...values) { } } -function getRawInstalledApps(rawSnapshot) { +function getRawInstalledApps(rawSnapshot: unknown): unknown[] | null { if (Array.isArray(rawSnapshot)) { return rawSnapshot; } - if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.apps)) { - return rawSnapshot.apps; - } - - if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.installedApps)) { - return rawSnapshot.installedApps; + if (isRecord(rawSnapshot)) { + const snap = rawSnapshot as UnknownRecord; + if (Array.isArray(snap.apps)) return snap.apps; + if (Array.isArray(snap.installedApps)) return snap.installedApps; } return null; } -function normalizeInstalledApp(app) { +function normalizeInstalledApp(app: unknown): InstalledAppSummary | null { if (!isRecord(app)) { return null; } + const a = app as UnknownRecord; - const platform = safeString(app.platform); - const sku = safeString(app.sku); + const platform = safeString(a.platform); + const sku = safeString(a.sku); if (!platform || !sku) { return null; } - const location = typeof app.location === 'string' ? app.location : ''; + const location = typeof a.location === 'string' ? a.location : ''; return { platform, sku, correlationId: `${platform}:${sku}`, displayName: firstString( - app.displayName, - app.titleName, - app.gameName, - app.name, + a.displayName, + a.titleName, + a.gameName, + a.name, location.replaceAll('\\', '/').split('/').filter(Boolean).pop() || '', `${platform}:${sku}` ), - gameId: toStringId(app.gameId), - titleId: toStringId(app.titleId), - imageUrl: normalizeImageUrl(app.imageUrl, app.iconUrl, app.coverUrl, app.thumbnailUrl, app.logoUrl, app.headerImageUrl), - platformLastPlayedTimestamp: typeof app.platformLastPlayedTimestamp === 'number' ? app.platformLastPlayedTimestamp : null, - platformTotalPlaytimeMinutes: typeof app.platformTotalPlaytimeMinutes === 'number' ? app.platformTotalPlaytimeMinutes : null, + 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, }; } -function normalizeInstalledAppsSnapshot(rawSnapshot) { +function normalizeInstalledAppsSnapshot(rawSnapshot: unknown): InstalledAppsPayload | null { const rawApps = getRawInstalledApps(rawSnapshot); if (!rawApps) { return null; } - const apps = rawApps.map(normalizeInstalledApp).filter(Boolean).sort(compareInstalledApps); + const apps = rawApps.map(normalizeInstalledApp).filter(Boolean) as InstalledAppSummary[]; + apps.sort(compareInstalledApps); + const snap = isRecord(rawSnapshot) ? (rawSnapshot as UnknownRecord) : null; return { - instanceId: isRecord(rawSnapshot) ? safeString(rawSnapshot.instanceId, 'wand-installed-apps') : 'wand-installed-apps', - updatedAt: isRecord(rawSnapshot) && typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.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, }; } -function summarizeInstalledAppsSource(rawSnapshot) { - if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) { - return ''; - } +function summarizeInstalledAppsSource(rawSnapshot: unknown): string { + if (!isRecord(rawSnapshot)) return ''; + const snap = rawSnapshot as UnknownRecord; + if (!isRecord(snap.diagnostics)) return ''; + const diag = snap.diagnostics as UnknownRecord; const parts: string[] = []; for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) { - const value = rawSnapshot.diagnostics[key]; + const value = diag[key]; if (typeof value === 'number') { parts.push(`${key}=${value}`); } @@ -178,69 +185,68 @@ function summarizeInstalledAppsSource(rawSnapshot) { return parts.join(', '); } -function installedAppsSignature(snapshot) { - return snapshot.apps - .map((app) => [ - app.platform, - app.sku, - app.displayName, - app.gameId || '', - app.titleId || '', - app.imageUrl || '', - app.platformLastPlayedTimestamp || '', - app.platformTotalPlaytimeMinutes || '', - ].join('|')) - .join('\n'); +/** + * Structural, not field-by-field: an explicit field list silently stops detecting + * whatever it forgets. The apps are already normalized here, so key order is stable. + */ +function installedAppsSignature(snapshot: InstalledAppsPayload): string { + return JSON.stringify(snapshot.apps); } -function normalizeSnapshot(rawSnapshot) { - if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.metadata) || !isRecord(rawSnapshot.metadata.info)) { - return 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; + const meta = snap.metadata as UnknownRecord; + if (!isRecord(meta.info)) return null; + const info = meta.info as UnknownRecord; - const info = rawSnapshot.metadata.info; - const blueprint = isRecord(info.blueprint) ? info.blueprint : {}; + const blueprint = isRecord(info.blueprint) ? (info.blueprint as UnknownRecord) : {}; const rawCheats = Array.isArray(blueprint.cheats) ? blueprint.cheats : []; - const cheats = rawCheats.map(normalizeCheat).filter(Boolean); + const cheats = rawCheats.map(normalizeCheat).filter(Boolean) as CheatSchema[]; const categories = Array.from(new Set(cheats.map((entry) => entry.category))); - const trainerId = safeString(rawSnapshot.trainerId || rawSnapshot.trainerInfo?.trainerId); + + 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( - rawSnapshot.trainerInfo?.displayName, - rawSnapshot.trainerInfo?.gameName, - rawSnapshot.trainerInfo?.titleName, - rawSnapshot.trainerInfo?.title, - rawSnapshot.trainerInfo?.name, + trainerInfo?.displayName, + trainerInfo?.gameName, + trainerInfo?.titleName, + trainerInfo?.title, + trainerInfo?.name, info.displayName, info.gameName, info.titleName, info.title, info.name, - info.game?.displayName, - info.game?.name, - info.game?.title + infoGame?.displayName, + infoGame?.name, + infoGame?.title ); if (!trainerId) { return null; } - const trainerMeta = { + const trainerMeta: TrainerMetaPayload = { session: { - instanceId: safeString(rawSnapshot.instanceId, 'wand-session'), + instanceId: safeString(snap.instanceId, 'wand-session'), }, trainer: { trainerId, - gameId: safeString(rawSnapshot.trainerInfo?.gameId || info.gameId), - displayName: displayName || safeString(rawSnapshot.trainerInfo?.gameId || info.gameId, trainerId), + gameId: safeString(trainerInfo?.gameId || info.gameId), + displayName: displayName || safeString(trainerInfo?.gameId || info.gameId, trainerId), titleId: typeof info.titleId === 'string' ? info.titleId : null, - gameVersion: typeof rawSnapshot.gameVersion === 'string' ? rawSnapshot.gameVersion : null, - trainerLoading: rawSnapshot.trainerLoading === true, - gameInstalled: rawSnapshot.gameInstalled !== false, - needsCompatibilityWarning: rawSnapshot.needsCompatibilityWarning === true, - language: safeString(rawSnapshot.language, 'en-US'), - themeId: safeString(rawSnapshot.themeId, 'default'), - isTimeLimitExpired: rawSnapshot.isTimeLimitExpired === true, - notesReadHash: typeof rawSnapshot.notesReadHash === 'string' ? rawSnapshot.notesReadHash : null, + gameVersion: typeof snap.gameVersion === 'string' ? snap.gameVersion : null, + trainerLoading: snap.trainerLoading === true, + gameInstalled: snap.gameInstalled !== false, + needsCompatibilityWarning: snap.needsCompatibilityWarning === true, + language: safeString(snap.language, 'en-US'), + themeId: safeString(snap.themeId, 'default'), + isTimeLimitExpired: snap.isTimeLimitExpired === true, + notesReadHash: typeof snap.notesReadHash === 'string' ? snap.notesReadHash : null, }, schema: { categories, @@ -248,9 +254,9 @@ function normalizeSnapshot(rawSnapshot) { }, }; - const trainerValues = { + const trainerValues: TrainerValuesPayload = { trainerId, - values: isRecord(rawSnapshot.values) ? cloneValue(rawSnapshot.values) : {}, + values: isRecord(snap.values) ? (cloneValue(snap.values) as Record) : {}, }; for (const cheat of cheats) { if (cheat.target in trainerValues.values) { @@ -264,7 +270,7 @@ function normalizeSnapshot(rawSnapshot) { }; } -function compareInstalledApps(left, right) { +function compareInstalledApps(left: InstalledAppSummary, right: InstalledAppSummary): number { const displayNameDiff = left.displayName.localeCompare(right.displayName); if (displayNameDiff !== 0) { return displayNameDiff; diff --git a/web-panel/bridge/src/normalizers/trainer.ts b/web-panel/bridge/src/normalizers/trainer.ts index 9a7a4dd..ad8a1cc 100644 --- a/web-panel/bridge/src/normalizers/trainer.ts +++ b/web-panel/bridge/src/normalizers/trainer.ts @@ -1,10 +1,18 @@ -export function normalizeTrainerValue(snapshot, target, value) { +import { cloneValue } from '../utils'; + +type SnapshotShape = { + trainerMeta?: { + schema?: { + cheats?: Array<{ target: string; type?: string }>; + }; + }; +}; + +export function normalizeTrainerValue( + snapshot: SnapshotShape | null | undefined, + target: string, + value: unknown +): unknown { const cheat = snapshot?.trainerMeta?.schema?.cheats?.find((entry) => entry.target === target); return cheat?.type === 'toggle' ? Boolean(value) : cloneValue(value); } - -function cloneValue(value) { - if (Array.isArray(value)) return value.map(cloneValue); - if (typeof value !== 'object' || value === null) return value; - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)])); -} diff --git a/web-panel/bridge/src/protocol-router.test.ts b/web-panel/bridge/src/protocol-router.test.ts index 5174c4f..d3c8524 100644 --- a/web-panel/bridge/src/protocol-router.test.ts +++ b/web-panel/bridge/src/protocol-router.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest'; +import { ECheatType } from '../../protocol/messages'; import { validateClientMessage, validateSetValueTarget } from './protocol-router'; const snapshot = { trainerMeta: { trainer: { trainerId: 'active' }, - schema: { cheats: [{ target: 'god', type: 'toggle' }] }, + schema: { cheats: [{ target: 'god', type: ECheatType.Toggle }] }, }, trainerValues: { values: { god: false } }, }; diff --git a/web-panel/bridge/src/protocol-router.ts b/web-panel/bridge/src/protocol-router.ts index e8995e3..07e6ccb 100644 --- a/web-panel/bridge/src/protocol-router.ts +++ b/web-panel/bridge/src/protocol-router.ts @@ -1,8 +1,11 @@ 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; -export function validateClientMessage(message, handshaken) { +export function validateClientMessage(message: unknown, handshaken: boolean) { if (!isRecord(message) || typeof message.type !== 'string' || !isRecord(message.payload)) { return invalid('invalid_message', 'Expected a protocol envelope with an object payload.'); } @@ -15,35 +18,37 @@ export function validateClientMessage(message, handshaken) { return invalid('invalid_request_id', 'requestId must be a string or null.'); } - if (message.type === 'hello') { - if (message.payload.client !== 'mobile-web' || typeof message.payload.clientVersion !== 'string' || !isRecord(message.payload.capabilities)) { + if (!isOutgoingMessage(message)) { + const type = (message as Record).type; + if (type === 'hello') { return invalid('invalid_hello', 'The hello payload is incomplete.'); } - return { ok: true }; + if (type === 'set_value') { + return invalid('invalid_set_value', 'trainerId, target and value are required.'); + } + if (type === 'remote_command') { + return invalid('invalid_command', 'Unknown remote command.'); + } + return invalid('unknown_message', 'Unknown protocol message type.'); } - if (!handshaken) { + if (message.type !== 'hello' && !handshaken) { return invalid('handshake_required', 'Send a compatible hello message before commands.'); } - if (message.type === 'set_value') { - if (!safeString(message.payload.trainerId) || !safeString(message.payload.target) || !('value' in message.payload)) { - return invalid('invalid_set_value', 'trainerId, target and value are required.'); - } - return { ok: true }; - } - - if (message.type === 'remote_command') { - if (message.payload.action !== 'launch' && message.payload.action !== 'stop') { - return invalid('invalid_command', 'Unknown remote command.'); - } - return { ok: true }; - } - - return invalid('unknown_message', 'Unknown protocol message type.'); + return { ok: true }; } -export function validateSetValueTarget(message, snapshot) { +/** Only the parts this validator actually reads, so callers need not build a full payload. */ +type ValidationSnapshot = { + trainerMeta: { + trainer: Pick; + schema: { cheats: Pick[] }; + }; + trainerValues: Pick; +}; + +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 || ''; @@ -65,14 +70,6 @@ export function validateSetValueTarget(message, snapshot) { }; } -function invalid(code, message) { +function invalid(code: string, message: string) { return { ok: false, error: { code, message } }; } - -function isRecord(value) { - return typeof value === 'object' && value !== null; -} - -function safeString(value) { - return typeof value === 'string' && value.length > 0 ? value : ''; -} diff --git a/web-panel/bridge/src/runtime.ts b/web-panel/bridge/src/runtime.ts index 7345dc7..536607b 100644 --- a/web-panel/bridge/src/runtime.ts +++ b/web-panel/bridge/src/runtime.ts @@ -1,12 +1,17 @@ const { createBridgeServer } = require('./server'); import type { BridgeOptions } from './types'; +declare global { + var __wandRemoteBridgeRuntime: ReturnType | undefined; +} + function createBridgeRuntime(options: BridgeOptions = {}) { return createBridgeServer(options); } function ensureBridge(options: BridgeOptions = {}) { - if (!globalThis.__wandRemoteBridgeRuntime) { + // A closed instance must not be handed out again: its server is gone and its state cleared. + if (!globalThis.__wandRemoteBridgeRuntime || globalThis.__wandRemoteBridgeRuntime.closed) { globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options); } diff --git a/web-panel/bridge/src/server-files.ts b/web-panel/bridge/src/server-files.ts index b1e490b..751642d 100644 --- a/web-panel/bridge/src/server-files.ts +++ b/web-panel/bridge/src/server-files.ts @@ -18,7 +18,10 @@ const VIRTUAL_MAC_PREFIXES = new Set([ '52:54:00', ]); -function contentTypeFor(filePath) { +import type { NetworkInterfaceInfo } from 'node:os'; +import type { ServerResponse } from 'node:http'; + +function contentTypeFor(filePath: string) { const extension = path.extname(filePath).toLowerCase(); switch (extension) { case '.html': @@ -37,12 +40,12 @@ function contentTypeFor(filePath) { } } -function getAdvertisedUrls(port) { - const candidates: any[] = []; +function getAdvertisedUrls(port: number) { + 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, any[] | undefined][]) { + for (const [name, entries] of Object.entries(interfaces) as [string, NetworkInterfaceInfo[] | undefined][]) { if (!entries) { continue; } @@ -69,15 +72,15 @@ function getAdvertisedUrls(port) { return Array.from(new Set(urls)); } -function isUsableIpv4Entry(entry) { +function isUsableIpv4Entry(entry: NetworkInterfaceInfo) { return Boolean(entry && !entry.internal && isIpv4Family(entry.family) && parseIpv4(entry.address)); } -function isIpv4Family(family) { +function isIpv4Family(family: string | number) { return family === 'IPv4' || family === 4; } -function scoreIpv4Entry(name, entry) { +function scoreIpv4Entry(name: string, entry: NetworkInterfaceInfo) { const octets = parseIpv4(entry.address) as number[]; let score = 0; @@ -116,7 +119,7 @@ function scoreIpv4Entry(name, entry) { return score; } -function parseIpv4(address): number[] | null { +function parseIpv4(address: unknown): number[] | null { if (typeof address !== 'string') { return null; } @@ -130,15 +133,15 @@ function parseIpv4(address): number[] | null { return octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) ? octets : null; } -function isPrivateIpv4(octets) { +function isPrivateIpv4(octets: number[]) { return octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168); } -function isLinkLocalIpv4(octets) { +function isLinkLocalIpv4(octets: number[]) { return octets[0] === 169 && octets[1] === 254; } -function isVirtualMac(mac) { +function isVirtualMac(mac: unknown) { if (typeof mac !== 'string') { return false; } @@ -146,9 +149,43 @@ function isVirtualMac(mac) { return VIRTUAL_MAC_PREFIXES.has(mac.toLowerCase().slice(0, 8)); } -function serveFile(response, filePath) { +// Async on purpose: this runs on the same event loop as every live WebSocket client, +// so a blocking read would stall trainer updates for everyone. +/** + * Resolves a request path inside `root`, or null when it escapes. + * Today's routing happens to be safe only because pathnames are never percent-decoded; + * decoding without this check would turn `%2e%2e%2f` into a real traversal. + */ +function resolveInsideRoot(root: string, relativePath: string): string | null { + const decoded = safeDecode(relativePath); + if (decoded === null || decoded.indexOf('\0') >= 0) { + return null; + } + + const resolvedRoot = path.resolve(root); + const candidate = path.resolve(resolvedRoot, `.${path.sep}${decoded}`); + const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep; + + return candidate === resolvedRoot || candidate.startsWith(prefix) ? candidate : null; +} + +function safeDecode(value: string): string | null { try { - const content = fs.readFileSync(filePath); + return decodeURIComponent(value); + } catch { + return null; + } +} + +async function serveFile(response: ServerResponse, filePath: string | null) { + if (filePath === null) { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Not found'); + return; + } + + try { + const content = await fs.promises.readFile(filePath); response.writeHead(200, { 'Content-Type': contentTypeFor(filePath), 'Cache-Control': 'no-store', @@ -162,5 +199,6 @@ function serveFile(response, filePath) { module.exports = { getAdvertisedUrls, + resolveInsideRoot, serveFile, }; diff --git a/web-panel/bridge/src/server.ts b/web-panel/bridge/src/server.ts index 7f3ddb1..c801ac8 100644 --- a/web-panel/bridge/src/server.ts +++ b/web-panel/bridge/src/server.ts @@ -8,7 +8,6 @@ const { DEFAULT_REMOTE_PORT, DEV_SERVER_PORTS, PORT_SCAN_RANGE, - REMOTE_ASSETS_PREFIX, REMOTE_BASE_PATH, REMOTE_HEALTH_PATH, REMOTE_WS_PATH, @@ -21,17 +20,43 @@ const { } = require('./normalizers'); const { createBridgeState } = require('./bridge-state'); const { validateClientMessage, validateSetValueTarget } = require('./protocol-router'); -const { getAdvertisedUrls, serveFile } = require('./server-files'); -const { cloneValue, isValidPort, safeString } = require('./utils'); +const { getAdvertisedUrls, resolveInsideRoot, serveFile } = require('./server-files'); +const { cloneValue, isValidPort, safeString, toStringId } = require('./utils'); const { closeClient, createAcceptKey, FRAME_TOO_LARGE_ERROR, + WS_PROTOCOL_ERROR, makeFrame, parseFrame, sendJson, } = require('./websocket-codec'); -import type { BridgeOptions } from './types'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Socket } from 'node:net'; +import type { BridgeOptions, BridgeClient } from './types'; + +const HTTP_BAD_REQUEST = 400; +const HTTP_FORBIDDEN = 403; +const HTTP_NOT_FOUND = 404; + +const WS_CLOSE_NORMAL = 1000; +const WS_CLOSE_PROTOCOL_ERROR = 1002; +const WS_CLOSE_UNSUPPORTED = 1003; +const WS_CLOSE_TOO_LARGE = 1009; + +declare global { + var __wandRemoteBridgeUrl: string | undefined; + 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 ClientMessage = { + type?: string; + requestId?: string | number | null; + payload?: Record; +}; function createBridgeServer(options: BridgeOptions = {}) { const preferredPort = Number(options.port || process.env.WAND_REMOTE_PORT || DEFAULT_REMOTE_PORT); @@ -39,12 +64,13 @@ function createBridgeServer(options: BridgeOptions = {}) { 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(); + const clients = new Set(); const log = createBridgeLogger(options); let advertisedUrls: string[] = []; - let setValueHandler: any = null; - let commandHandler: any = null; + let setValueHandler: SetValueHandler | null = null; + let commandHandler: CommandHandler | null = null; let listening = false; + let closed = false; const bridgeState = createBridgeState({ clients, log, @@ -55,24 +81,24 @@ function createBridgeServer(options: BridgeOptions = {}) { }), }); - function setAdvertisedPort(nextPort) { + function setAdvertisedPort(nextPort: number) { port = nextPort; advertisedUrls = getAdvertisedUrls(port); - globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0]; + globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry: string) => !entry.includes('localhost')) || advertisedUrls[0]; } - function setHandler(handler) { + function setHandler(handler: SetValueHandler | null) { setValueHandler = typeof handler === 'function' ? handler : null; } - function setCommandHandler(handler) { + function setCommandHandler(handler: CommandHandler | null) { commandHandler = typeof handler === 'function' ? handler : null; } - function handleRequest(request, response) { + function handleRequest(request: IncomingMessage, response: ServerResponse) { const url = parseRequestUrl(request.url); if (!url) { - response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.writeHead(HTTP_BAD_REQUEST, { 'Content-Type': 'text/plain; charset=utf-8' }); response.end('Bad Request'); return; } @@ -100,23 +126,21 @@ function createBridgeServer(options: BridgeOptions = {}) { return; } - if (url.pathname.startsWith(REMOTE_ASSETS_PREFIX)) { - serveFile(response, path.join(panelRoot, url.pathname.replace(REMOTE_BASE_PATH, ''))); + // 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))); return; } - response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.writeHead(HTTP_NOT_FOUND, { 'Content-Type': 'text/plain; charset=utf-8' }); response.end('Not found'); } - async function handleRemoteCommandMessage(client, message) { + async function handleRemoteCommandMessage(client: BridgeClient, message: ClientMessage) { const action = normalizeRemoteCommandAction(message.payload?.action); - const gameId = typeof message.payload?.gameId === 'string' || typeof message.payload?.gameId === 'number' - ? String(message.payload.gameId) - : null; - const titleId = typeof message.payload?.titleId === 'string' || typeof message.payload?.titleId === 'number' - ? String(message.payload.titleId) - : null; + const gameId = toStringId(message.payload?.gameId); + const titleId = toStringId(message.payload?.titleId); if (!action) { sendJson(client, 'error', { @@ -164,76 +188,52 @@ function createBridgeServer(options: BridgeOptions = {}) { } } - async function handleSetValueMessage(client, message) { + async function handleSetValueMessage(client: BridgeClient, message: ClientMessage) { const currentSnapshot = bridgeState.snapshot; 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, + ); + if (!validation.ok) { - sendJson(client, 'set_value_result', { - ok: false, - trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '', - target: safeString(message.payload?.target), - error: validation.error, - }, message.requestId ?? null); + reply(validation.error); return; } - const { target } = validation; if (!setValueHandler) { - sendJson(client, 'set_value_result', { - ok: false, - trainerId: currentSnapshot.trainerMeta.trainer.trainerId, - target, - error: { - code: 'bridge_not_ready', - message: 'The local bridge is not ready to write trainer values yet.', - }, - }, message.requestId ?? null); + reply({ + code: 'bridge_not_ready', + message: 'The local bridge is not ready to write trainer values yet.', + }); return; } - let result = false; + let accepted = false; try { - result = await Promise.resolve(setValueHandler({ - trainerId: currentSnapshot.trainerMeta.trainer.trainerId, + 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); - sendJson(client, 'set_value_result', { - ok: false, - trainerId: currentSnapshot.trainerMeta.trainer.trainerId, - target, - error: { - code: 'set_failed', - message: 'Failed to set trainer value.', - }, - }, message.requestId ?? null); + reply({ code: 'set_failed', message: 'Failed to set trainer value.' }); return; } - if (!result) { - sendJson(client, 'set_value_result', { - ok: false, - trainerId: currentSnapshot.trainerMeta.trainer.trainerId, - target, - error: { - code: 'set_rejected', - message: 'The trainer rejected the requested value.', - }, - }, message.requestId ?? null); - return; - } - - sendJson(client, 'set_value_result', { - ok: true, - trainerId: currentSnapshot.trainerMeta.trainer.trainerId, - target, - }, message.requestId ?? null); + reply(accepted ? undefined : { + code: 'set_rejected', + message: 'The trainer rejected the requested value.', + }); } - async function handleClientMessage(client, message) { + async function handleClientMessage(client: BridgeClient, message: ClientMessage) { const validation = validateClientMessage(message, client.handshaken); if (!validation.ok) { sendJson(client, 'error', validation.error, message?.requestId ?? null); @@ -264,91 +264,118 @@ function createBridgeServer(options: BridgeOptions = {}) { } } - function bindSocket(socket) { - const client = { + // Handling a message can await a renderer round-trip, so frames are drained by a single + // loop per client. Appending on 'data' must never interleave with the loop mutating the + // buffer, or frames are duplicated or lost. + async function drainFrames(client: BridgeClient) { + if (client.draining) { + return; + } + + client.draining = true; + try { + while (!client.closed && client.buffer.length > 0) { + const frame = parseFrame(client.buffer); + if (!frame) { + return; + } + + client.buffer = client.buffer.subarray(frame.bytesConsumed); + + if (!frame.fin) { + closeClient(client, WS_CLOSE_UNSUPPORTED, 'Fragmented frames are not supported.'); + return; + } + + if (frame.opcode === WS_OPCODE.CLOSE) { + let code = WS_CLOSE_NORMAL; + if (frame.payload.length >= 2) { + code = frame.payload.readUInt16BE(0); + } + closeClient(client, code, 'Closing'); + return; + } + + if (frame.opcode === WS_OPCODE.PING) { + client.socket.write(makeFrame(WS_OPCODE.PONG, frame.payload)); + continue; + } + + if (frame.opcode !== WS_OPCODE.TEXT) { + continue; + } + + try { + await handleClientMessage(client, JSON.parse(frame.payload.toString('utf8'))); + } catch (error) { + // 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.', + }); + } + } + } catch (error) { + if (error instanceof Error && 'code' in error) { + if (error.code === FRAME_TOO_LARGE_ERROR) { + closeClient(client, WS_CLOSE_TOO_LARGE, error.message); + return; + } + if (error.code === WS_PROTOCOL_ERROR) { + closeClient(client, WS_CLOSE_PROTOCOL_ERROR, error.message); + return; + } + } + + log('warn', 'Dropping client after an unreadable frame.', error); + closeClient(client, WS_CLOSE_PROTOCOL_ERROR, 'Protocol error.'); + } finally { + client.draining = false; + } + } + + function bindSocket(socket: Socket) { + const client: BridgeClient = { socket, buffer: Buffer.alloc(0), closed: false, + draining: false, handshaken: false, }; clients.add(client); - socket.on('data', async (chunk) => { - try { - client.buffer = Buffer.concat([client.buffer, chunk]); - - while (client.buffer.length > 0) { - const frame = parseFrame(client.buffer); - if (!frame) { - return; - } - - client.buffer = client.buffer.subarray(frame.bytesConsumed); - - if (!frame.fin) { - closeClient(client, 1003, 'Fragmented frames are not supported.'); - return; - } - - if (frame.opcode === WS_OPCODE.CLOSE) { - closeClient(client, 1000, 'Closing'); - return; - } - - if (frame.opcode === WS_OPCODE.PING) { - client.socket.write(makeFrame(WS_OPCODE.PONG, frame.payload)); - continue; - } - - if (frame.opcode !== WS_OPCODE.TEXT) { - continue; - } - - await handleClientMessage(client, JSON.parse(frame.payload.toString('utf8'))); - } - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === FRAME_TOO_LARGE_ERROR) { - closeClient(client, 1009, error.message); - return; - } - sendJson(client, 'error', { - code: 'invalid_message', - message: error instanceof Error ? error.message : 'Failed to process client message.', - }); + const dropClient = (error?: unknown) => { + client.closed = true; + clients.delete(client); + if (error) { + log('warn', 'WebSocket client error.', error); } + }; + + socket.on('data', (chunk: Buffer) => { + client.buffer = Buffer.concat([client.buffer, chunk]); + void drainFrames(client); }); - socket.on('close', () => { - client.closed = true; - clients.delete(client); - }); - - socket.on('end', () => { - client.closed = true; - clients.delete(client); - }); - - socket.on('error', (error) => { - client.closed = true; - clients.delete(client); - log('warn', 'WebSocket client error.', error); - }); + socket.on('close', () => dropClient()); + socket.on('end', () => dropClient()); + socket.on('error', dropClient); } - function handleUpgrade(request, socket) { + function handleUpgrade(request: IncomingMessage, socket: Socket) { const url = parseRequestUrl(request.url); if (!url) { - rejectUpgrade(socket, 400, 'Bad Request'); + rejectUpgrade(socket, HTTP_BAD_REQUEST, 'Bad Request'); return; } if (url.pathname !== REMOTE_WS_PATH) { - rejectUpgrade(socket, 404, 'Not Found'); + rejectUpgrade(socket, HTTP_NOT_FOUND, 'Not Found'); return; } if (!isAllowedWebSocketOrigin(request.headers.origin, request.headers.host)) { - rejectUpgrade(socket, 403, 'Forbidden'); + rejectUpgrade(socket, HTTP_FORBIDDEN, 'Forbidden'); return; } @@ -370,7 +397,7 @@ function createBridgeServer(options: BridgeOptions = {}) { bindSocket(socket); } - function listen(nextPort) { + function listen(nextPort: number) { setAdvertisedPort(nextPort); server.listen(port, host); } @@ -381,7 +408,7 @@ function createBridgeServer(options: BridgeOptions = {}) { const server = http.createServer(handleRequest); server.on('upgrade', handleUpgrade); - server.on('error', (error) => { + server.on('error', (error: Error & { code?: string }) => { if (!listening && error && error.code === 'EADDRINUSE' && port < maxPort) { const nextPort = port + 1; log('warn', `Port ${port} is busy, trying ${nextPort}.`); @@ -405,6 +432,9 @@ function createBridgeServer(options: BridgeOptions = {}) { get listening() { return listening; }, + get closed() { + return closed; + }, get remoteUrl() { return globalThis.__wandRemoteBridgeUrl; }, @@ -415,6 +445,8 @@ function createBridgeServer(options: BridgeOptions = {}) { clients.clear(); bridgeState.clear(); listening = false; + closed = true; + globalThis.__wandRemoteBridgeUrl = undefined; server.close(); }, setCommandHandler, @@ -427,7 +459,7 @@ function createBridgeServer(options: BridgeOptions = {}) { }; } -function parseRequestUrl(requestUrl) { +function parseRequestUrl(requestUrl: string | undefined) { try { return new URL(requestUrl || '/', 'http://localhost'); } catch { @@ -435,7 +467,7 @@ function parseRequestUrl(requestUrl) { } } -function isAllowedWebSocketOrigin(origin, host) { +function isAllowedWebSocketOrigin(origin: string | undefined, host: string | undefined) { if (origin === undefined) { return true; } @@ -453,17 +485,17 @@ function isAllowedWebSocketOrigin(origin, host) { 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); + || (DEV_SERVER_PORTS.includes(parsed.port) && (sameHostname || compatibleLoopback)); } catch { return false; } } -function isLoopback(hostname) { +function isLoopback(hostname: string) { return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname.toLowerCase()); } -function rejectUpgrade(socket, statusCode, statusText) { +function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) { socket.end([ `HTTP/1.1 ${statusCode} ${statusText}`, 'Connection: close', diff --git a/web-panel/bridge/src/types.ts b/web-panel/bridge/src/types.ts index 83797f1..9e1df68 100644 --- a/web-panel/bridge/src/types.ts +++ b/web-panel/bridge/src/types.ts @@ -1,24 +1,79 @@ -export type BridgeOptions = { - host?: string; - logFile?: string; - maxPort?: number | string; - panelRoot?: string; - port?: number | string; - scriptsRoot?: string; +import type { Socket } from 'node:net'; + +/** + * Shared vocabulary for the bridge runtime. Payloads crossing the Wand renderer + * IPC boundary are genuinely unknown until a normalizer validates them, so they + * are typed `unknown` and narrowed there - not `any`. + */ + +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +export type UnknownRecord = Record; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export type LogFn = (level: LogLevel, message: string, error?: unknown) => void; + +/** One connected websocket peer. */ +export type BridgeClient = { + socket: Socket; + buffer: Buffer; + closed: boolean; + draining: boolean; + handshaken: boolean; }; +export type BridgeOptions = { + logFile?: string; + port?: number; + maxPort?: number; + host?: string; + panelRoot?: string; +}; + +export type ServerInfo = { + port: number; + advertisedUrls: string[]; +}; + +/** A decoded websocket frame. */ +export type WsFrame = { + opcode: number; + payload: Buffer; + rest: Buffer; +}; + +export interface BridgeLogger extends LogFn { + file: string; +} + +/** + * Minimal structural views of the Electron objects the bridge touches. Declared here + * rather than in each consumer: `@types/electron` is not a dependency, and the runtime + * modules use `module.exports`, which esbuild disables in any file carrying an `export`. + */ export type WebContentsPort = { - executeJavaScript(source: string, userGesture?: boolean): Promise; isDestroyed(): boolean; - on(event: string, listener: () => void): void; - send(channel: string, payload: unknown): void; + send(channel: string, ...args: unknown[]): void; + executeJavaScript(code: string, userGesture?: boolean): Promise; + on(event: 'dom-ready' | 'did-finish-load', listener: () => void): void; + /** Optional in Electron's older typings; guarded at every call site. */ + once?(event: 'destroyed', listener: () => void): void; +}; + +export type IpcMainEventPort = { + sender?: WebContentsPort; +}; + +export type IpcMainPort = { + 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; }; export type ElectronPort = { - app: { - on(event: 'web-contents-created', listener: (event: unknown, contents: WebContentsPort) => void): void; - }; - ipcMain: { - handle(channel: string, handler: (event: { sender?: WebContentsPort }, payload?: unknown) => unknown): void; - }; + app: AppPort; + ipcMain: IpcMainPort; }; diff --git a/web-panel/bridge/src/utils.ts b/web-panel/bridge/src/utils.ts index 0966ed3..47f5675 100644 --- a/web-panel/bridge/src/utils.ts +++ b/web-panel/bridge/src/utils.ts @@ -1,12 +1,14 @@ -function isRecord(value) { +import type { UnknownRecord } from './types'; + +export function isRecord(value: unknown): value is UnknownRecord { return typeof value === 'object' && value !== null; } -function safeString(value, fallback = '') { +export function safeString(value: unknown, fallback = '') { return typeof value === 'string' && value.length ? value : fallback; } -function firstString(...values) { +export function firstString(...values: unknown[]) { for (const value of values) { if (typeof value !== 'string') { continue; @@ -21,7 +23,7 @@ function firstString(...values) { return ''; } -function cloneValue(value) { +export function cloneValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map(cloneValue); } @@ -30,7 +32,7 @@ function cloneValue(value) { return value; } - const result = {}; + const result: UnknownRecord = {}; for (const [key, entry] of Object.entries(value)) { result[key] = cloneValue(entry); } @@ -38,11 +40,11 @@ function cloneValue(value) { return result; } -function isValidPort(value) { - return Number.isFinite(value) && value > 0 && value < 65536; +export function isValidPort(value: unknown) { + return Number.isFinite(value) && (value as number) > 0 && (value as number) < 65536; } -function toStringId(value) { +export function toStringId(value: unknown) { if (typeof value === 'string' && value.trim()) { return value.trim(); } @@ -53,12 +55,3 @@ function toStringId(value) { return null; } - -module.exports = { - cloneValue, - firstString, - isRecord, - isValidPort, - safeString, - toStringId, -}; diff --git a/web-panel/bridge/src/wand/renderer-scripts.ts b/web-panel/bridge/src/wand/renderer-scripts.ts index 37e6506..2893e53 100644 --- a/web-panel/bridge/src/wand/renderer-scripts.ts +++ b/web-panel/bridge/src/wand/renderer-scripts.ts @@ -3,24 +3,28 @@ const path = require('node:path'); const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('../constants'); const { writeInstallLog } = require('../logger'); -import type { BridgeOptions, ElectronPort } from '../types'; +import type { BridgeOptions, ElectronPort, WebContentsPort } from '../types'; -function loadRendererScripts(panelRoot, scriptsRoot) { +declare global { + var __wandRemoteBridgeRendererScriptsInstalled: boolean | undefined; +} + +function loadRendererScripts(panelRoot: string, scriptsRoot?: string) { const root = scriptsRoot || path.join(panelRoot, RENDERER_SCRIPTS_DIR); if (!fs.existsSync(root)) { return []; } return fs.readdirSync(root) - .filter((name) => name.endsWith('.js')) - .sort((left, right) => left.localeCompare(right)) - .map((name) => ({ + .filter((name: string) => name.endsWith('.js')) + .sort((left: string, right: string) => left.localeCompare(right)) + .map((name: string) => ({ name, source: fs.readFileSync(path.join(root, name), 'utf8'), })); } -function buildRendererBootstrap(remoteUrl, scripts) { +function buildRendererBootstrap(remoteUrl: string, scripts: { name: string; source: string }[]) { const header = ` globalThis.__wandRemoteBridgeUrl = ${JSON.stringify(remoteUrl)}; if (!globalThis.WandEnhancer) { @@ -52,7 +56,7 @@ function buildRendererBootstrap(remoteUrl, scripts) { return `(() => {\n${header}\n${body}\n})();`; } -function installRendererScripts(electron: ElectronPort, runtime, options: BridgeOptions = {}) { +function installRendererScripts(electron: ElectronPort, runtime: { remoteUrl: string }, options: BridgeOptions & { scriptsRoot?: string } = {}) { if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) { return; } @@ -64,14 +68,14 @@ function installRendererScripts(electron: ElectronPort, runtime, options: Bridge return; } - electron.app.on('web-contents-created', (_event, contents) => { + electron.app.on('web-contents-created', (_event: unknown, contents: WebContentsPort) => { const inject = () => { if (!contents || contents.isDestroyed()) { return; } contents.executeJavaScript(buildRendererBootstrap(runtime.remoteUrl, scripts), true) - .catch((error) => writeInstallLog('warn', 'Failed to inject renderer scripts.', error)); + .catch((error: unknown) => writeInstallLog('warn', 'Failed to inject renderer scripts.', error)); }; contents.on('dom-ready', inject); @@ -81,7 +85,7 @@ function installRendererScripts(electron: ElectronPort, runtime, options: Bridge } }); - writeInstallLog('info', `Renderer script injection installed (${scripts.map((script) => 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 c8c8048..a7159b0 100644 --- a/web-panel/bridge/src/wand/runtime.ts +++ b/web-panel/bridge/src/wand/runtime.ts @@ -1,20 +1,31 @@ const crypto = require('node:crypto'); const path = require('node:path'); -const { - IPC_CHANNEL, - REMOTE_COMMAND_REQUEST_CHANNEL, - REMOTE_COMMAND_RESPONSE_CHANNEL, - REMOTE_COMMAND_RESPONSE_TIMEOUT_MS, - REMOTE_GAME_STATUS_CHANNEL, - REMOTE_INSTALLED_APPS_CHANNEL, -} = require('../constants'); +const { IPC_CHANNEL, REMOTE_COMMAND_RESPONSE_TIMEOUT_MS } = require('../constants'); const { writeInstallLog } = require('../logger'); const { ensureBridge } = require('../runtime'); const { installRendererScripts } = require('./renderer-scripts'); const { localizeTrainerSnapshot } = require('./trainer-localization'); const { safeString } = require('../utils'); -import type { BridgeOptions, ElectronPort, WebContentsPort } from '../types'; +import type { BridgeOptions, ElectronPort, IpcMainEventPort, WebContentsPort } from '../types'; + +type RuntimePort = { + remoteUrl: string; + setHandler(handler: (request: unknown) => boolean): void; + setCommandHandler(handler: (request: unknown) => Promise): void; + sync(snapshot: unknown): void; + syncTrainerMeta(snapshot: unknown): void; + syncInstalledApps(snapshot: unknown): void; + syncGameStatus(snapshot: unknown): void; + valueChanged(change: unknown): void; +}; + +declare global { + var __wandRemoteBridgeBoundRenderers: Set | undefined; + var __wandRemoteBridgePendingCommandResponses: Map void> | undefined; + var __wandRemoteBridgeActiveRuntime: RuntimePort | undefined; + var __wandRemoteBridgeIpcInstalled: boolean | undefined; +} const WEMOD_ACCESS_TOKEN_SCRIPT = 'JSON.parse(localStorage.getItem("infinity:globalStore") || "{}")?.token?.accessToken ?? null'; @@ -30,15 +41,10 @@ function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers; globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses; - runtime.setHandler((request) => { + runtime.setHandler((request: unknown) => { let delivered = false; - for (const sender of Array.from(boundRenderers)) { + for (const sender of liveRenderers(boundRenderers)) { try { - if (!sender || sender.isDestroyed()) { - boundRenderers.delete(sender); - continue; - } - sender.send(IPC_CHANNEL.SET_VALUE, request); delivered = true; } catch (error) { @@ -50,21 +56,18 @@ function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) return delivered; }); - runtime.setCommandHandler(async (request) => { - for (const sender of Array.from(boundRenderers)) { - try { - if (!sender || sender.isDestroyed()) { - boundRenderers.delete(sender); - continue; - } - - return await dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses); - } catch (error) { - writeInstallLog('warn', 'Failed to execute remote command in renderer.', error); - } + runtime.setCommandHandler(async (request: unknown) => { + const [sender] = liveRenderers(boundRenderers); + if (!sender) { + return buildRendererBridgeMissingResponse(request); } - return buildRendererBridgeMissingResponse(request); + try { + return await dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses); + } catch (error) { + writeInstallLog('warn', 'Failed to execute remote command in renderer.', error); + return buildRendererBridgeMissingResponse(request); + } }); installIpcHandlers(electron, runtime, boundRenderers, pendingCommandResponses); @@ -76,63 +79,86 @@ function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) return runtime; } -function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandResponses) { +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; if (globalThis.__wandRemoteBridgeIpcInstalled) { return; } globalThis.__wandRemoteBridgeIpcInstalled = true; + const runtime = () => globalThis.__wandRemoteBridgeActiveRuntime as RuntimePort; let trainerSnapshotRevision = 0; - electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event, snapshot) => { + electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event: IpcMainEventPort, snapshot: unknown) => { const revision = ++trainerSnapshotRevision; - runtime.sync(snapshot); + runtime().sync(snapshot); void localizeSnapshot(event?.sender, snapshot).then((localizedSnapshot) => { if (localizedSnapshot !== snapshot && revision === trainerSnapshotRevision) { - runtime.syncTrainerMeta(localizedSnapshot); + runtime().syncTrainerMeta(localizedSnapshot); } - }).catch((error) => { + }).catch((error: unknown) => { writeInstallLog('warn', 'Failed to localize trainer metadata.', error); }); return true; }); - electron.ipcMain.handle(REMOTE_INSTALLED_APPS_CHANNEL, (_event, snapshot) => { - runtime.syncInstalledApps(snapshot); + electron.ipcMain.handle(IPC_CHANNEL.INSTALLED_APPS, (_event: IpcMainEventPort, snapshot: unknown) => { + runtime().syncInstalledApps(snapshot); return true; }); - electron.ipcMain.handle(REMOTE_GAME_STATUS_CHANNEL, (_event, snapshot) => { - runtime.syncGameStatus(snapshot); + electron.ipcMain.handle(IPC_CHANNEL.GAME_STATUS, (_event: IpcMainEventPort, snapshot: unknown) => { + runtime().syncGameStatus(snapshot); return true; }); - electron.ipcMain.handle(REMOTE_COMMAND_RESPONSE_CHANNEL, (_event, response) => { - const requestId = safeString(response?.requestId); - const pending = requestId ? pendingCommandResponses.get(requestId) : null; - if (!pending) { + 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; } - pending.resolve(response); + resolvePending(response); return true; }); - electron.ipcMain.handle(IPC_CHANNEL.VALUE_CHANGED, (_event, change) => { - runtime.valueChanged(change); + electron.ipcMain.handle(IPC_CHANNEL.VALUE_CHANGED, (_event: IpcMainEventPort, change: unknown) => { + runtime().valueChanged(change); return true; }); - electron.ipcMain.handle(IPC_CHANNEL.BIND_HANDLER, (event) => { - if (event && event.sender) { - boundRenderers.add(event.sender); + electron.ipcMain.handle(IPC_CHANNEL.BIND_HANDLER, (event: IpcMainEventPort) => { + const sender = event?.sender; + if (sender) { + if (!boundRenderers.has(sender)) { + boundRenderers.add(sender); + // Without this the set grows for the lifetime of the app: entries are otherwise + // only dropped when a later send happens to fail. + sender.once?.('destroyed', () => boundRenderers.delete(sender)); + } } return true; }); - electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime.remoteUrl); + electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime().remoteUrl); } -async function localizeSnapshot(sender, snapshot) { +function liveRenderers(boundRenderers: Set) { + const live: WebContentsPort[] = []; + for (const sender of Array.from(boundRenderers) as WebContentsPort[]) { + if (sender && !sender.isDestroyed()) { + live.push(sender); + } else { + boundRenderers.delete(sender); + } + } + + return live; +} + +async function localizeSnapshot(sender: WebContentsPort | undefined, snapshot: unknown) { const accessToken = await readWemodAccessToken(sender); return localizeTrainerSnapshot(snapshot, accessToken); } -async function readWemodAccessToken(sender) { +async function readWemodAccessToken(sender: WebContentsPort | undefined) { if (!sender || typeof sender.executeJavaScript !== 'function' || sender.isDestroyed?.()) { return null; } @@ -146,7 +172,7 @@ async function readWemodAccessToken(sender) { } } -function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses) { +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(() => { @@ -154,22 +180,15 @@ function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponse reject(new Error('Renderer remote command timed out.')); }, REMOTE_COMMAND_RESPONSE_TIMEOUT_MS); - pendingCommandResponses.set(requestId, { - resolve: (response) => { - clearTimeout(timer); - pendingCommandResponses.delete(requestId); - resolve(response); - }, - reject: (error) => { - clearTimeout(timer); - pendingCommandResponses.delete(requestId); - reject(error instanceof Error ? error : new Error(String(error))); - }, + pendingCommandResponses.set(requestId, (response) => { + clearTimeout(timer); + pendingCommandResponses.delete(requestId); + resolve(response); }); try { - sender.send(REMOTE_COMMAND_REQUEST_CHANNEL, { - ...request, + sender.send(IPC_CHANNEL.COMMAND_REQUEST, { + ...(request as object), requestId, }); } catch (error) { @@ -180,12 +199,13 @@ function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponse }); } -function buildRendererBridgeMissingResponse(request) { +function buildRendererBridgeMissingResponse(request: unknown) { + const req = request as Record | undefined; return { ok: false, - action: request?.action === 'stop' ? 'stop' : 'launch', - gameId: typeof request?.gameId === 'string' ? request.gameId : null, - titleId: typeof request?.titleId === 'string' ? request.titleId : null, + action: req?.action === 'stop' ? 'stop' : 'launch', + gameId: typeof req?.gameId === 'string' ? req.gameId : null, + titleId: typeof req?.titleId === 'string' ? req.titleId : null, error: { code: 'bridge_not_ready', message: 'The renderer command bridge is not ready yet.', diff --git a/web-panel/bridge/src/wand/trainer-localization.test.ts b/web-panel/bridge/src/wand/trainer-localization.test.ts index 767194d..452f771 100644 --- a/web-panel/bridge/src/wand/trainer-localization.test.ts +++ b/web-panel/bridge/src/wand/trainer-localization.test.ts @@ -3,6 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest" 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 + afterEach(() => vi.restoreAllMocks()) describe("trainer localization", () => { @@ -25,7 +32,7 @@ describe("trainer localization", () => { ) expect(localized).not.toBe(snapshot) - expect(localized.metadata.info.blueprint.cheats[0]).toMatchObject({ + expect(asLocalized(localized).metadata.info.blueprint.cheats[0]).toMatchObject({ name: "Unverwundbar", description: "Kein Schaden", }) @@ -68,8 +75,8 @@ describe("trainer localization", () => { const cached = await localizeTrainerSnapshot(snapshot, "cache-test-token") expect(get).toHaveBeenCalledTimes(1) - expect(localized[0].metadata.info.blueprint.cheats[0].name).toBe("Cached name") - expect(cached.metadata.info.blueprint.cheats[0].name).toBe("Cached name") + 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") }) }) diff --git a/web-panel/bridge/src/wand/trainer-localization.ts b/web-panel/bridge/src/wand/trainer-localization.ts index 7a76f17..4b69bd1 100644 --- a/web-panel/bridge/src/wand/trainer-localization.ts +++ b/web-panel/bridge/src/wand/trainer-localization.ts @@ -8,9 +8,31 @@ let cachedStrings: Record | null = null let inFlightRequestKey = "" let inFlightRequest: Promise | null> | null = null +type FetchTrainerStringsRequest = { + 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; +}; + +type MinimalRequest = { + destroy(): void; + setTimeout(timeout: number, callback: () => void): MinimalRequest; + on(event: 'error', listener: (error: unknown) => void): MinimalRequest; +}; + export async function localizeTrainerSnapshot( - rawSnapshot, - accessToken, + rawSnapshot: unknown, + accessToken: string | null, loadStrings = fetchTrainerStrings ) { const request = buildTrainerRequest(rawSnapshot, accessToken) @@ -18,7 +40,7 @@ export async function localizeTrainerSnapshot( return rawSnapshot } - let strings + let strings: Record | null = null try { strings = await loadStrings(request) } catch { @@ -28,22 +50,24 @@ export async function localizeTrainerSnapshot( return rawSnapshot } - const info = rawSnapshot.metadata.info - const blueprint = info.blueprint + 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 { - ...rawSnapshot, + ...snap, metadata: { - ...rawSnapshot.metadata, + ...metadata, info: { ...info, blueprint: { ...blueprint, - cheats: blueprint.cheats.map((cheat) => - localizeCheat(cheat, strings) + cheats: blueprint.cheats.map((cheat: unknown) => + localizeCheat(cheat, strings as Record) ), }, }, @@ -51,13 +75,18 @@ export async function localizeTrainerSnapshot( } } -function buildTrainerRequest(rawSnapshot, accessToken) { +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 gameId = stringValue( - rawSnapshot.trainerInfo?.gameId || rawSnapshot.metadata?.info?.gameId + trainerInfo?.gameId || info?.gameId ) if (!gameId) { return null @@ -66,12 +95,12 @@ function buildTrainerRequest(rawSnapshot, accessToken) { return { accessToken, gameId, - gameVersion: stringValue(rawSnapshot.gameVersion), - language: stringValue(rawSnapshot.language), + gameVersion: stringValue(snap.gameVersion), + language: stringValue(snap.language), } } -function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }) { +function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }: FetchTrainerStringsRequest) { const requestKey = [accessToken, gameId, gameVersion, language].join("\0") if (requestKey === cachedRequestKey) { return Promise.resolve(cachedStrings) @@ -87,7 +116,7 @@ function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }) { if (language) url.searchParams.set("locale", language) const request = requestJson(url, accessToken) - .then((payload) => normalizeStrings(payload?.i18n?.strings)) + .then((payload: unknown) => normalizeStrings((payload as { i18n?: { strings?: unknown } })?.i18n?.strings)) .then((strings) => { if (strings) { cachedRequestKey = requestKey @@ -107,10 +136,10 @@ function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }) { return request } -function requestJson(url, accessToken): Promise { - return new Promise((resolve) => { +function requestJson(url: URL, accessToken: string): Promise { + return new Promise((resolve) => { let settled = false - const finish = (value) => { + const finish = (value: unknown) => { if (settled) return settled = true resolve(value) @@ -124,7 +153,7 @@ function requestJson(url, accessToken): Promise { Authorization: `Bearer ${accessToken}`, }, }, - (response) => { + (response: MinimalResponse) => { if (response.statusCode !== 200) { response.resume() finish(null) @@ -134,7 +163,7 @@ function requestJson(url, accessToken): Promise { let body = "" let receivedBytes = 0 response.setEncoding("utf8") - response.on("data", (chunk) => { + response.on("data", (chunk: Buffer | string) => { receivedBytes += Buffer.byteLength(chunk) if (receivedBytes > RESPONSE_LIMIT_BYTES) { request.destroy() @@ -150,15 +179,18 @@ function requestJson(url, accessToken): Promise { finish(null) } }) + response.on("error", () => { + finish(null) + }) } - ) + ) as MinimalRequest request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy()) request.on("error", () => finish(null)) }) } -function normalizeStrings(value): Record | null { +function normalizeStrings(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null } @@ -169,23 +201,24 @@ function normalizeStrings(value): Record | null { return Object.keys(strings).length > 0 ? strings : null } -function localizeCheat(cheat, strings) { +function localizeCheat(cheat: unknown, strings: Record) { if (!cheat || typeof cheat !== "object") { return cheat } + const c = cheat as Record return { - ...cheat, - name: translate(cheat.name, strings), - description: translate(cheat.description, strings), - instructions: translate(cheat.instructions, strings), + ...c, + name: translate(c.name, strings), + description: translate(c.description, strings), + instructions: translate(c.instructions, strings), } } -function translate(value, strings) { +function translate(value: unknown, strings: Record) { return typeof value === "string" ? (strings[value] ?? value) : value } -function stringValue(value) { +function stringValue(value: unknown) { 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 c49c280..6d61e7c 100644 --- a/web-panel/bridge/src/websocket-codec.ts +++ b/web-panel/bridge/src/websocket-codec.ts @@ -4,14 +4,24 @@ const { BRIDGE_PROTOCOL_VERSION, MAX_WS_FRAME_BYTES, WS_OPCODE } = require('./co const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const FRAME_TOO_LARGE_ERROR = 'WS_FRAME_TOO_LARGE'; +const WS_PROTOCOL_ERROR = 'WS_PROTOCOL_ERROR'; +const WS_CLOSE_NORMAL = 1000; + +import type { BridgeClient } from './types'; function frameTooLarge() { - const error: any = new RangeError(`WebSocket frames are limited to ${MAX_WS_FRAME_BYTES} bytes.`); + 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; } -function jsonMessage(type, payload, requestId = null) { +function protocolError(message: string) { + const error = new Error(message) as Error & { code: string }; + error.code = WS_PROTOCOL_ERROR; + return error; +} + +function jsonMessage(type: string, payload: unknown, requestId: string | number | null = null) { return JSON.stringify({ type, version: BRIDGE_PROTOCOL_VERSION, @@ -20,7 +30,7 @@ function jsonMessage(type, payload, requestId = null) { }); } -function makeFrame(opcode, payload) { +function makeFrame(opcode: number, payload: Buffer | string) { const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); const header: number[] = []; header.push(0x80 | (opcode & 0x0f)); @@ -43,17 +53,17 @@ function makeFrame(opcode, payload) { return Buffer.concat([prefix, source]); } -function sendText(client, text) { +function sendText(client: BridgeClient, text: string) { if (!client.closed) { client.socket.write(makeFrame(WS_OPCODE.TEXT, Buffer.from(text, 'utf8'))); } } -function sendJson(client, type, payload, requestId = null) { +function sendJson(client: BridgeClient, type: string, payload: unknown, requestId: string | number | null = null) { sendText(client, jsonMessage(type, payload, requestId)); } -function closeClient(client, code = 1000, reason = 'Closing') { +function closeClient(client: BridgeClient, code = WS_CLOSE_NORMAL, reason = 'Closing') { if (client.closed) { return; } @@ -67,7 +77,7 @@ function closeClient(client, code = 1000, reason = 'Closing') { client.socket.end(); } -function parseFrame(buffer) { +function parseFrame(buffer: Buffer) { if (buffer.length < 2) { return null; } @@ -114,6 +124,17 @@ function parseFrame(buffer) { mask = buffer.subarray(offset, offset + 4); offset += 4; + } else { + throw protocolError('Client frames must be masked'); + } + + if (opcode >= 8) { + if (!fin) { + throw protocolError('Control frames must not be fragmented'); + } + if (length > 125) { + throw protocolError('Control frames must have a payload of 125 bytes or less'); + } } if (buffer.length < offset + length) { @@ -135,7 +156,7 @@ function parseFrame(buffer) { }; } -function createAcceptKey(key) { +function createAcceptKey(key: string) { return crypto.createHash('sha1').update(key + WS_GUID).digest('base64'); } @@ -143,6 +164,7 @@ module.exports = { closeClient, createAcceptKey, FRAME_TOO_LARGE_ERROR, + WS_PROTOCOL_ERROR, jsonMessage, makeFrame, parseFrame, diff --git a/web-panel/protocol/messages.ts b/web-panel/protocol/messages.ts index 007f381..89d7018 100644 --- a/web-panel/protocol/messages.ts +++ b/web-panel/protocol/messages.ts @@ -1,4 +1,4 @@ -export { PROTOCOL_VERSION } from './contract'; +export { PROTOCOL_VERSION } from './contract.js'; // String values mirror the wire protocol; do not rename the right-hand side. export enum ECheatType { diff --git a/web-panel/protocol/validation.ts b/web-panel/protocol/validation.ts index e521ec5..0a532fc 100644 --- a/web-panel/protocol/validation.ts +++ b/web-panel/protocol/validation.ts @@ -1,5 +1,5 @@ -import { PROTOCOL_VERSION } from './contract'; -import type { IncomingMessage, OutgoingMessage } from './messages'; +import { PROTOCOL_VERSION } from './contract.js'; +import type { IncomingMessage, OutgoingMessage } from './messages.js'; const INCOMING_TYPES = new Set([ 'hello_ack',