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',