fix: bump version to 1.0.9.4, update changelog

This commit is contained in:
kitbyte
2026-07-22 00:03:38 +03:00
parent e7c7beb621
commit 643c8f8b62
38 changed files with 788 additions and 365 deletions
@@ -128,12 +128,14 @@ async function executeRemoteLaunchCommand(state, request) {
void syncGameStatus(state, true)
return buildCommandResponse(request, true)
} catch (error) {
state.log(
"warn",
"Remote trainer launch failed.",
error?.stack || String(error)
)
return buildCommandResponse(request, false, {
code: "launch_failed",
message:
error instanceof Error
? error.message
: "Failed to launch the trainer.",
message: "Failed to launch the trainer.",
})
}
}
@@ -171,12 +173,14 @@ async function executeRemoteStopCommand(state, request) {
clearTrainerSnapshot(state, REMOTE_STOP_EVENT, true)
return buildCommandResponse(request, true)
} catch (error) {
state.log(
"warn",
"Remote trainer stop failed.",
error?.stack || String(error)
)
return buildCommandResponse(request, false, {
code: "stop_failed",
message:
error instanceof Error
? error.message
: "Failed to stop the running trainer.",
message: "Failed to stop the running trainer.",
})
}
}
+3 -15
View File
@@ -1,7 +1,7 @@
import {
getWebpackRequire,
isRecord,
} from "./installed-apps-sync/runtime.js"
import { resolveQrRenderer as findWandQrRenderer } from "./remote-popup-cleanup/qr-renderer.js"
;(function installRemotePopupCleanup(WandEnhancer) {
if (globalThis.__wandRemotePopupCleanupInstalled) {
@@ -91,20 +91,8 @@ import {
return qrRenderer
}
const webpackRequire = getWebpackRequire()
for (const record of Object.values(webpackRequire?.c || {})) {
const exports = record?.exports
if (
isRecord(exports) &&
typeof exports.create === "function" &&
typeof exports.mo === "function"
) {
qrRenderer = exports.mo
return qrRenderer
}
}
return null
qrRenderer = findWandQrRenderer(getWebpackRequire())
return qrRenderer
}
const updateLinks = (remoteUrl) => {
@@ -0,0 +1,17 @@
import { isRecord } from "../installed-apps-sync/runtime.js"
const WAND_QR_RENDERER_EXPORT = "mo"
export function resolveQrRenderer(webpackRequire) {
for (const record of Object.values(webpackRequire?.c || {})) {
const exports = record?.exports
if (
isRecord(exports) &&
typeof exports[WAND_QR_RENDERER_EXPORT] === "function"
) {
return exports[WAND_QR_RENDERER_EXPORT]
}
}
return null
}
+16 -10
View File
@@ -1,5 +1,4 @@
const {
buildInstalledAppsDebugPayload,
gameStatusSignature,
installedAppsSignature,
normalizeGameStatusSnapshot,
@@ -20,7 +19,9 @@ function createBridgeState({ clients, log, getServerInfo }) {
function broadcast(type, payload, requestId = null) {
for (const client of clients) {
sendJson(client, type, payload, requestId);
if (client.handshaken) {
sendJson(client, type, payload, requestId);
}
}
}
@@ -50,6 +51,17 @@ function createBridgeState({ clients, log, getServerInfo }) {
}
}
function syncTrainerMeta(rawSnapshot) {
const localizedSnapshot = normalizeSnapshot(rawSnapshot);
const activeTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId;
if (!localizedSnapshot || localizedSnapshot.trainerMeta.trainer.trainerId !== activeTrainerId) {
return;
}
currentSnapshot.trainerMeta = localizedSnapshot.trainerMeta;
broadcast('trainer_meta', currentSnapshot.trainerMeta);
}
function valueChanged(change) {
if (!currentSnapshot || !isRecord(change)) return;
const target = safeString(change.target);
@@ -97,7 +109,6 @@ function createBridgeState({ clients, log, getServerInfo }) {
}
function buildHealthPayload() {
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
const serverInfo = getServerInfo();
return {
ok: serverInfo.listening,
@@ -105,12 +116,7 @@ function createBridgeState({ clients, log, getServerInfo }) {
gameSessionState: currentGameStatus?.session?.state || 'idle',
gameSessionEvent: currentGameStatus?.session?.event || 'snapshot',
runningTrainerId: currentGameStatus?.trainer?.trainerId || null,
installedAppsCount: installedAppsDebug.counts.myGamesEntries,
installedRawAppsCount: installedAppsDebug.counts.rawInstallEntries,
installedTitlesCount: installedAppsDebug.counts.groupedTitles,
installedUniqueTitleIdsCount: installedAppsDebug.counts.uniqueTitleIds,
installedUniqueGameIdsCount: installedAppsDebug.counts.uniqueGameIds,
installedAppsApiPath: serverInfo.installedAppsApiPath,
installedAppsCount: currentInstalledApps?.apps?.length ?? 0,
remoteUrl: serverInfo.remoteUrl,
advertisedUrls: serverInfo.advertisedUrls,
};
@@ -127,10 +133,10 @@ function createBridgeState({ clients, log, getServerInfo }) {
return {
get snapshot() { return currentSnapshot; },
buildHealthPayload,
buildInstalledAppsDebugPayload: () => buildInstalledAppsDebugPayload(currentInstalledApps),
clear,
sendSnapshot,
sync,
syncTrainerMeta,
syncGameStatus,
syncInstalledApps,
valueChanged,
+2 -1
View File
@@ -27,8 +27,10 @@ module.exports = {
BRIDGE_SERVER_VERSION: WEB_CONTRACT.serverVersion,
DEFAULT_REMOTE_HOST: WEB_CONTRACT.defaultRemoteHost,
DEFAULT_REMOTE_PORT: WEB_CONTRACT.defaultRemotePort,
DEV_SERVER_PORTS: Object.freeze(WEB_CONTRACT.devServerPorts.map(String)),
IPC_CHANNEL,
KNOWN_CHEAT_TYPES,
MAX_WS_FRAME_BYTES: 1024 * 1024,
PORT_SCAN_RANGE: WEB_CONTRACT.portScanRange,
REMOTE_ASSETS_PREFIX: WEB_CONTRACT.assetsPath,
REMOTE_BASE_PATH: WEB_CONTRACT.basePath,
@@ -37,7 +39,6 @@ module.exports = {
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS: 15000,
REMOTE_GAME_STATUS_CHANNEL: IPC_CHANNEL.GAME_STATUS,
REMOTE_HEALTH_PATH: WEB_CONTRACT.healthPath,
REMOTE_INSTALLED_APPS_API_PATH: WEB_CONTRACT.installedAppsPath,
REMOTE_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS,
REMOTE_WS_PATH: WEB_CONTRACT.webSocketPath,
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
+10 -117
View File
@@ -88,7 +88,16 @@ function normalizeCheat(cheat, index) {
function normalizeImageUrl(...values) {
const value = firstString(...values);
return value || null;
if (!value) {
return null;
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null;
} catch {
return null;
}
}
function getRawInstalledApps(rawSnapshot) {
@@ -119,10 +128,6 @@ function normalizeInstalledApp(app) {
}
const location = typeof app.location === 'string' ? app.location : '';
const alternateLocations = Array.isArray(app.alternateLocations)
? app.alternateLocations.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim())
: [];
return {
platform,
sku,
@@ -137,8 +142,6 @@ function normalizeInstalledApp(app) {
),
gameId: toStringId(app.gameId),
titleId: toStringId(app.titleId),
location,
alternateLocations,
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,
@@ -152,19 +155,13 @@ function normalizeInstalledAppsSnapshot(rawSnapshot) {
}
const apps = rawApps.map(normalizeInstalledApp).filter(Boolean).sort(compareInstalledApps);
const diagnostics = isRecord(rawSnapshot) && isRecord(rawSnapshot.diagnostics)
? cloneValue(rawSnapshot.diagnostics)
: 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(),
apps,
diagnostics,
};
}
function summarizeInstalledAppsSource(rawSnapshot) {
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
return '';
@@ -189,7 +186,6 @@ function installedAppsSignature(snapshot) {
app.displayName,
app.gameId || '',
app.titleId || '',
app.location,
app.imageUrl || '',
app.platformLastPlayedTimestamp || '',
app.platformTotalPlaytimeMinutes || '',
@@ -197,95 +193,6 @@ function installedAppsSignature(snapshot) {
.join('\n');
}
function buildInstalledAppsDebugPayload(snapshot) {
if (!snapshot) {
return {
ok: false,
instanceId: null,
updatedAt: null,
counts: {
myGamesEntries: 0,
rawInstallEntries: 0,
groupedTitles: 0,
uniqueTitleIds: 0,
uniqueGameIds: 0,
},
diagnostics: null,
byPlatform: {},
titles: [],
apps: [],
};
}
const diagnostics = isRecord(snapshot.diagnostics) ? snapshot.diagnostics : null;
const byPlatform = {};
const uniqueTitleIds = new Set();
const uniqueGameIds = new Set();
const titleGroups = new Map();
for (const app of snapshot.apps) {
byPlatform[app.platform] = (byPlatform[app.platform] || 0) + 1;
if (app.titleId) {
uniqueTitleIds.add(app.titleId);
}
if (app.gameId) {
uniqueGameIds.add(app.gameId);
}
const groupKey = resolveInstalledAppGroupKey(app);
let group = titleGroups.get(groupKey);
if (!group) {
group = {
key: groupKey,
titleId: app.titleId,
displayName: app.displayName,
gameIds: new Set(),
platforms: new Set(),
apps: [],
};
titleGroups.set(groupKey, group);
}
if (app.gameId) {
group.gameIds.add(app.gameId);
}
group.platforms.add(app.platform);
group.apps.push(app);
}
const titles = Array.from(titleGroups.values())
.map((group) => ({
key: group.key,
titleId: group.titleId,
displayName: group.displayName,
gameIds: Array.from(group.gameIds).sort(),
platforms: Array.from(group.platforms).sort(),
appEntries: group.apps.length,
apps: group.apps,
}))
.sort((left, right) => left.displayName.localeCompare(right.displayName));
return {
ok: true,
instanceId: snapshot.instanceId,
updatedAt: snapshot.updatedAt,
counts: {
myGamesEntries: snapshot.apps.length,
rawInstallEntries: typeof diagnostics?.rawInstalledApps === 'number' ? diagnostics.rawInstalledApps : snapshot.apps.length,
groupedTitles: titles.length,
uniqueTitleIds: uniqueTitleIds.size,
uniqueGameIds: uniqueGameIds.size,
},
diagnostics,
byPlatform,
titles,
apps: snapshot.apps,
};
}
function normalizeSnapshot(rawSnapshot) {
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.metadata) || !isRecord(rawSnapshot.metadata.info)) {
return null;
@@ -320,7 +227,6 @@ function normalizeSnapshot(rawSnapshot) {
const trainerMeta = {
session: {
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
accessToken: safeString(rawSnapshot.accessToken),
},
trainer: {
trainerId,
@@ -372,20 +278,7 @@ function compareInstalledApps(left, right) {
return left.sku.localeCompare(right.sku);
}
function resolveInstalledAppGroupKey(app) {
if (app.titleId) {
return `title:${app.titleId}`;
}
if (app.gameId) {
return `game:${app.gameId}`;
}
return `app:${app.correlationId}`;
}
module.exports = {
buildInstalledAppsDebugPayload,
gameStatusSignature,
installedAppsSignature,
normalizeGameStatusSnapshot,
+12
View File
@@ -5,6 +5,7 @@ import {
getSteamClientIconUrl,
normalizeImageUrl,
} from '../scripts/default/installed-apps-sync/artwork.js';
import { resolveQrRenderer } from '../scripts/default/remote-popup-cleanup/qr-renderer.js';
describe('installed-apps renderer script models', () => {
it('normalizes captured artwork shapes without a Wand runtime', () => {
@@ -28,4 +29,15 @@ describe('installed-apps renderer script models', () => {
expect(getSteamClientIconUrl(findSteamAppId(fixture)))
.toBe('https://api-cdn.wemod.com/steam_community/1245620/client_icon/96.webp');
});
it('resolves the tree-shaken Wand QR renderer without a create export', () => {
const renderer = () => undefined;
const webpackRequire = {
c: {
qrCode: { exports: { mo: renderer } },
},
};
expect(resolveQrRenderer(webpackRequire)).toBe(renderer);
});
});
+142 -3
View File
@@ -1,4 +1,4 @@
import { createServer } from 'node:net';
import { connect, createServer } from 'node:net';
import { describe, expect, it } from 'vitest';
import { WebSocket as NodeWebSocket } from 'ws';
@@ -10,12 +10,80 @@ describe('production bridge runtime', () => {
const port = await getFreePort();
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
runtime.sync(rawTrainerSnapshot());
runtime.syncInstalledApps({
apps: [{
platform: 'steam',
sku: '123',
displayName: 'Game',
location: 'C:\\private\\Game',
alternateLocations: ['D:\\also-private\\Game'],
}],
});
try {
await waitUntil(() => runtime.listening);
const messages = await connectAndCollect(port, 3);
expect(messages.map((message) => message.type)).toEqual(['hello_ack', 'trainer_meta', 'trainer_values']);
const messages = await connectAndCollect(port, 4);
expect(messages.map((message) => message.type)).toEqual(['hello_ack', 'trainer_meta', 'trainer_values', 'installed_apps']);
expect(messages[2].payload.values.god).toBe(true);
expect(JSON.stringify(messages)).not.toContain('wand-secret');
expect(JSON.stringify(messages)).not.toContain('private');
} finally {
runtime.close();
}
});
it('rejects a browser WebSocket from a different origin', async () => {
const bridge = require('../../dist/bridge.cjs');
const port = await getFreePort();
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
try {
await waitUntil(() => runtime.listening);
await expectUpgradeStatus(port, 403);
} finally {
runtime.close();
}
});
it('allows the local Vite panel to connect to the loopback bridge', async () => {
const bridge = require('../../dist/bridge.cjs');
const port = await getFreePort();
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
try {
await waitUntil(() => runtime.listening);
await expectUpgradeAccepted(port, 'http://127.0.0.1:4173');
} finally {
runtime.close();
}
});
it('closes an oversized WebSocket frame without buffering its payload', async () => {
const bridge = require('../../dist/bridge.cjs');
const port = await getFreePort();
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
try {
await waitUntil(() => runtime.listening);
await expectSocketClose(port, Buffer.alloc(1024 * 1024 + 1), 1009);
await expectDeclaredHugeFrameClose(port, 1009);
} finally {
runtime.close();
}
});
it('does not trust the HTTP Host header as a URL base', async () => {
const bridge = require('../../dist/bridge.cjs');
const port = await getFreePort();
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
try {
await waitUntil(() => runtime.listening);
const malformedHost = await sendRawHttp(port, 'GET /remote/api/health HTTP/1.1\r\nHost: [\r\nConnection: close\r\n\r\n');
expect(malformedHost).toContain('HTTP/1.1 200 OK');
const malformedTarget = await sendRawHttp(port, 'GET //[ HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
expect(malformedTarget).toContain('HTTP/1.1 400 Bad Request');
} finally {
runtime.close();
}
@@ -59,6 +127,76 @@ async function connectAndCollect(port: number, count: number): Promise<any[]> {
});
}
async function expectUpgradeStatus(port: number, expectedStatus: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`, {
headers: { Origin: 'https://example.com' },
});
socket.once('open', () => reject(new Error('Cross-origin WebSocket was accepted.')));
socket.once('error', () => undefined);
socket.once('unexpected-response', (_request, response) => {
response.resume();
if (response.statusCode === expectedStatus) {
resolve();
} else {
reject(new Error(`Expected HTTP ${expectedStatus}, got ${response.statusCode}.`));
}
});
});
}
async function expectUpgradeAccepted(port: number, origin: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`, {
headers: { Origin: origin },
});
socket.once('open', () => {
socket.close();
resolve();
});
socket.once('error', reject);
});
}
async function expectSocketClose(port: number, payload: Buffer, expectedCode: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`);
socket.once('open', () => socket.send(payload));
socket.once('close', (code) => code === expectedCode
? resolve()
: reject(new Error(`Expected close code ${expectedCode}, got ${code}.`)));
socket.once('error', reject);
});
}
async function expectDeclaredHugeFrameClose(port: number, expectedCode: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`);
socket.once('open', () => {
const header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 0xff;
header.writeUInt32BE(1, 2);
(socket as any)._socket.write(header);
});
socket.once('close', (code) => code === expectedCode
? resolve()
: reject(new Error(`Expected close code ${expectedCode}, got ${code}.`)));
socket.once('error', reject);
});
}
async function sendRawHttp(port: number, request: string): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
const socket = connect(port, '127.0.0.1');
socket.once('connect', () => socket.end(request));
socket.on('data', (chunk) => chunks.push(chunk));
socket.once('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
socket.once('error', reject);
});
}
async function waitUntil(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 3000;
while (!predicate()) {
@@ -70,6 +208,7 @@ async function waitUntil(predicate: () => boolean): Promise<void> {
function rawTrainerSnapshot() {
return {
instanceId: 'instance',
accessToken: 'wand-secret',
trainerId: 'trainer',
trainerInfo: { gameId: 'game', displayName: 'Game' },
metadata: {
+84 -17
View File
@@ -6,11 +6,11 @@ const {
BRIDGE_SERVER_VERSION,
DEFAULT_REMOTE_HOST,
DEFAULT_REMOTE_PORT,
DEV_SERVER_PORTS,
PORT_SCAN_RANGE,
REMOTE_ASSETS_PREFIX,
REMOTE_BASE_PATH,
REMOTE_HEALTH_PATH,
REMOTE_INSTALLED_APPS_API_PATH,
REMOTE_WS_PATH,
WS_OPCODE,
} = require('./constants');
@@ -23,7 +23,14 @@ const { createBridgeState } = require('./bridge-state');
const { validateClientMessage, validateSetValueTarget } = require('./protocol-router');
const { getAdvertisedUrls, serveFile } = require('./server-files');
const { cloneValue, isValidPort, safeString } = require('./utils');
const { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket-codec');
const {
closeClient,
createAcceptKey,
FRAME_TOO_LARGE_ERROR,
makeFrame,
parseFrame,
sendJson,
} = require('./websocket-codec');
import type { BridgeOptions } from './types';
function createBridgeServer(options: BridgeOptions = {}) {
@@ -43,7 +50,6 @@ function createBridgeServer(options: BridgeOptions = {}) {
log,
getServerInfo: () => ({
advertisedUrls,
installedAppsApiPath: REMOTE_INSTALLED_APPS_API_PATH,
listening,
remoteUrl: globalThis.__wandRemoteBridgeUrl,
}),
@@ -64,10 +70,15 @@ function createBridgeServer(options: BridgeOptions = {}) {
}
function handleRequest(request, response) {
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
const url = parseRequestUrl(request.url);
if (!url) {
response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Bad Request');
return;
}
if (url.pathname === '/' || url.pathname === '') {
response.writeHead(302, { Location: '/remote/' });
response.writeHead(302, { Location: REMOTE_BASE_PATH });
response.end();
return;
}
@@ -89,12 +100,6 @@ function createBridgeServer(options: BridgeOptions = {}) {
return;
}
if (url.pathname === REMOTE_INSTALLED_APPS_API_PATH) {
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(JSON.stringify(bridgeState.buildInstalledAppsDebugPayload(), null, 2));
return;
}
if (url.pathname.startsWith(REMOTE_ASSETS_PREFIX)) {
serveFile(response, path.join(panelRoot, url.pathname.replace(REMOTE_BASE_PATH, '')));
return;
@@ -148,11 +153,12 @@ function createBridgeServer(options: BridgeOptions = {}) {
const result = await Promise.resolve(commandHandler({ action, gameId, titleId }));
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult(result, fallback), message.requestId ?? null);
} catch (error) {
log('warn', 'Remote command handler failed.', error);
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({
ok: false,
error: {
code: 'command_failed',
message: error instanceof Error ? error.message : 'Failed to execute the remote command.',
message: 'Failed to execute the remote command.',
},
}, fallback), message.requestId ?? null);
}
@@ -194,13 +200,14 @@ function createBridgeServer(options: BridgeOptions = {}) {
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: error instanceof Error ? error.message : 'Failed to set trainer value.',
message: 'Failed to set trainer value.',
},
}, message.requestId ?? null);
return;
@@ -301,6 +308,10 @@ function createBridgeServer(options: BridgeOptions = {}) {
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.',
@@ -326,15 +337,24 @@ function createBridgeServer(options: BridgeOptions = {}) {
}
function handleUpgrade(request, socket) {
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
const url = parseRequestUrl(request.url);
if (!url) {
rejectUpgrade(socket, 400, 'Bad Request');
return;
}
if (url.pathname !== REMOTE_WS_PATH) {
socket.destroy();
rejectUpgrade(socket, 404, 'Not Found');
return;
}
if (!isAllowedWebSocketOrigin(request.headers.origin, request.headers.host)) {
rejectUpgrade(socket, 403, 'Forbidden');
return;
}
const key = request.headers['sec-websocket-key'];
if (typeof key !== 'string' || !key) {
socket.destroy();
rejectUpgrade(socket, 400, 'Bad Request');
return;
}
@@ -373,7 +393,7 @@ function createBridgeServer(options: BridgeOptions = {}) {
});
server.on('listening', () => {
listening = true;
log('info', `Listening on ${globalThis.__wandRemoteBridgeUrl}`);
log('info', `Listening on ${host}:${port}.`);
});
listen(port);
@@ -400,12 +420,59 @@ function createBridgeServer(options: BridgeOptions = {}) {
setCommandHandler,
setHandler,
sync: bridgeState.sync,
syncTrainerMeta: bridgeState.syncTrainerMeta,
syncGameStatus: bridgeState.syncGameStatus,
syncInstalledApps: bridgeState.syncInstalledApps,
valueChanged: bridgeState.valueChanged,
};
}
function parseRequestUrl(requestUrl) {
try {
return new URL(requestUrl || '/', 'http://localhost');
} catch {
return null;
}
}
function isAllowedWebSocketOrigin(origin, host) {
if (origin === undefined) {
return true;
}
if (typeof origin !== 'string' || typeof host !== 'string') {
return false;
}
try {
const parsed = new URL(origin);
const requested = new URL(`http://${host}`);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
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);
} catch {
return false;
}
}
function isLoopback(hostname) {
return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname.toLowerCase());
}
function rejectUpgrade(socket, statusCode, statusText) {
socket.end([
`HTTP/1.1 ${statusCode} ${statusText}`,
'Connection: close',
'Content-Length: 0',
'',
'',
].join('\r\n'));
}
module.exports = {
createBridgeServer,
};
+13 -9
View File
@@ -12,11 +12,10 @@ const {
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';
// Reads the signed-in WeMod access token from the renderer's localStorage so the
// panel can request localized cheat metadata from the WeMod API.
const WEMOD_ACCESS_TOKEN_SCRIPT =
'JSON.parse(localStorage.getItem("infinity:globalStore") || "{}")?.token?.accessToken ?? null';
@@ -83,8 +82,17 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
}
globalThis.__wandRemoteBridgeIpcInstalled = true;
let trainerSnapshotRevision = 0;
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event, snapshot) => {
void syncSnapshotWithAccessToken(runtime, event?.sender, snapshot);
const revision = ++trainerSnapshotRevision;
runtime.sync(snapshot);
void localizeSnapshot(event?.sender, snapshot).then((localizedSnapshot) => {
if (localizedSnapshot !== snapshot && revision === trainerSnapshotRevision) {
runtime.syncTrainerMeta(localizedSnapshot);
}
}).catch((error) => {
writeInstallLog('warn', 'Failed to localize trainer metadata.', error);
});
return true;
});
electron.ipcMain.handle(REMOTE_INSTALLED_APPS_CHANNEL, (_event, snapshot) => {
@@ -119,13 +127,9 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime.remoteUrl);
}
async function syncSnapshotWithAccessToken(runtime, sender, snapshot) {
async function localizeSnapshot(sender, snapshot) {
const accessToken = await readWemodAccessToken(sender);
if (accessToken && snapshot && typeof snapshot === 'object') {
snapshot.accessToken = accessToken;
}
runtime.sync(snapshot);
return localizeTrainerSnapshot(snapshot, accessToken);
}
async function readWemodAccessToken(sender) {
+98
View File
@@ -0,0 +1,98 @@
import { EventEmitter } from "node:events"
import { afterEach, describe, expect, it, vi } from "vitest"
import { localizeTrainerSnapshot } from "./trainer-localization"
afterEach(() => vi.restoreAllMocks())
describe("trainer localization", () => {
it("uses the bearer token only inside the bridge and returns localized metadata", async () => {
const snapshot = rawTrainerSnapshot()
const loadStrings = vi.fn(async (request) => {
expect(request).toMatchObject({
accessToken: "wand-secret",
gameId: "game",
gameVersion: "1.0",
language: "de-DE",
})
return { cheat_name: "Unverwundbar", cheat_description: "Kein Schaden" }
})
const localized = await localizeTrainerSnapshot(
snapshot,
"wand-secret",
loadStrings
)
expect(localized).not.toBe(snapshot)
expect(localized.metadata.info.blueprint.cheats[0]).toMatchObject({
name: "Unverwundbar",
description: "Kein Schaden",
})
expect(JSON.stringify(localized)).not.toContain("wand-secret")
expect(snapshot.metadata.info.blueprint.cheats[0].name).toBe("cheat_name")
})
it("deduplicates in-flight translation requests and caches successful strings", async () => {
const https = require("node:https")
const get = vi.spyOn(https, "get").mockImplementation((_url, _options, onResponse) => {
const request = new EventEmitter() as any
request.setTimeout = vi.fn()
request.destroy = vi.fn()
queueMicrotask(() => {
const response = new EventEmitter() as any
response.statusCode = 200
response.resume = vi.fn()
response.setEncoding = vi.fn()
const respond = onResponse as (response: any) => void
respond(response)
response.emit("data", JSON.stringify({
i18n: { strings: { cheat_name: "Cached name" } },
}))
response.emit("end")
})
return request
})
const snapshot = {
...rawTrainerSnapshot(),
trainerInfo: { gameId: "cache-test-game" },
}
const pending = [
localizeTrainerSnapshot(snapshot, "cache-test-token"),
localizeTrainerSnapshot(snapshot, "cache-test-token"),
]
const localized = await Promise.all(pending)
const cached = await localizeTrainerSnapshot(snapshot, "cache-test-token")
expect(get).toHaveBeenCalledTimes(1)
expect(localized[0].metadata.info.blueprint.cheats[0].name).toBe("Cached name")
expect(cached.metadata.info.blueprint.cheats[0].name).toBe("Cached name")
})
})
function rawTrainerSnapshot() {
return {
instanceId: "instance",
trainerId: "trainer",
trainerInfo: { gameId: "game" },
gameVersion: "1.0",
language: "de-DE",
metadata: {
info: {
blueprint: {
cheats: [
{
target: "god",
type: "toggle",
name: "cheat_name",
description: "cheat_description",
},
],
},
},
},
}
}
+191
View File
@@ -0,0 +1,191 @@
const https = require("node:https")
const WEMOD_TRAINER_ENDPOINT = "https://api.wemod.com/v3/games"
const RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024
const REQUEST_TIMEOUT_MS = 5000
let cachedRequestKey = ""
let cachedStrings: Record<string, string> | null = null
let inFlightRequestKey = ""
let inFlightRequest: Promise<Record<string, string> | null> | null = null
export async function localizeTrainerSnapshot(
rawSnapshot,
accessToken,
loadStrings = fetchTrainerStrings
) {
const request = buildTrainerRequest(rawSnapshot, accessToken)
if (!request) {
return rawSnapshot
}
let strings
try {
strings = await loadStrings(request)
} catch {
return rawSnapshot
}
if (!strings) {
return rawSnapshot
}
const info = rawSnapshot.metadata.info
const blueprint = info.blueprint
if (!Array.isArray(blueprint?.cheats)) {
return rawSnapshot
}
return {
...rawSnapshot,
metadata: {
...rawSnapshot.metadata,
info: {
...info,
blueprint: {
...blueprint,
cheats: blueprint.cheats.map((cheat) =>
localizeCheat(cheat, strings)
),
},
},
},
}
}
function buildTrainerRequest(rawSnapshot, accessToken) {
if (!accessToken || !rawSnapshot || typeof rawSnapshot !== "object") {
return null
}
const gameId = stringValue(
rawSnapshot.trainerInfo?.gameId || rawSnapshot.metadata?.info?.gameId
)
if (!gameId) {
return null
}
return {
accessToken,
gameId,
gameVersion: stringValue(rawSnapshot.gameVersion),
language: stringValue(rawSnapshot.language),
}
}
function fetchTrainerStrings({ accessToken, gameId, gameVersion, language }) {
const requestKey = [accessToken, gameId, gameVersion, language].join("\0")
if (requestKey === cachedRequestKey) {
return Promise.resolve(cachedStrings)
}
if (requestKey === inFlightRequestKey && inFlightRequest) {
return inFlightRequest
}
const url = new URL(
`${WEMOD_TRAINER_ENDPOINT}/${encodeURIComponent(gameId)}/trainer`
)
if (gameVersion) url.searchParams.set("gameVersions", gameVersion)
if (language) url.searchParams.set("locale", language)
const request = requestJson(url, accessToken)
.then((payload) => normalizeStrings(payload?.i18n?.strings))
.then((strings) => {
if (strings) {
cachedRequestKey = requestKey
cachedStrings = strings
}
return strings
})
.finally(() => {
if (inFlightRequestKey === requestKey) {
inFlightRequestKey = ""
inFlightRequest = null
}
})
inFlightRequestKey = requestKey
inFlightRequest = request
return request
}
function requestJson(url, accessToken): Promise<any> {
return new Promise<any>((resolve) => {
let settled = false
const finish = (value) => {
if (settled) return
settled = true
resolve(value)
}
const request = https.get(
url,
{
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
},
(response) => {
if (response.statusCode !== 200) {
response.resume()
finish(null)
return
}
let body = ""
let receivedBytes = 0
response.setEncoding("utf8")
response.on("data", (chunk) => {
receivedBytes += Buffer.byteLength(chunk)
if (receivedBytes > RESPONSE_LIMIT_BYTES) {
request.destroy()
finish(null)
return
}
body += chunk
})
response.on("end", () => {
try {
finish(JSON.parse(body))
} catch {
finish(null)
}
})
}
)
request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy())
request.on("error", () => finish(null))
})
}
function normalizeStrings(value): Record<string, string> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null
}
const strings = Object.fromEntries(
Object.entries(value).filter((entry) => typeof entry[1] === "string")
) as Record<string, string>
return Object.keys(strings).length > 0 ? strings : null
}
function localizeCheat(cheat, strings) {
if (!cheat || typeof cheat !== "object") {
return cheat
}
return {
...cheat,
name: translate(cheat.name, strings),
description: translate(cheat.description, strings),
instructions: translate(cheat.instructions, strings),
}
}
function translate(value, strings) {
return typeof value === "string" ? (strings[value] ?? value) : value
}
function stringValue(value) {
return typeof value === "string" && value ? value : ""
}
+14 -2
View File
@@ -1,8 +1,15 @@
const crypto = require('node:crypto');
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants');
const { BRIDGE_PROTOCOL_VERSION, MAX_WS_FRAME_BYTES, WS_OPCODE } = require('./constants');
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const FRAME_TOO_LARGE_ERROR = 'WS_FRAME_TOO_LARGE';
function frameTooLarge() {
const error: any = new RangeError(`WebSocket frames are limited to ${MAX_WS_FRAME_BYTES} bytes.`);
error.code = FRAME_TOO_LARGE_ERROR;
return error;
}
function jsonMessage(type, payload, requestId = null) {
return JSON.stringify({
@@ -88,13 +95,17 @@ function parseFrame(buffer) {
const high = buffer.readUInt32BE(offset);
const low = buffer.readUInt32BE(offset + 4);
if (high !== 0) {
throw new Error('Large websocket frames are not supported.');
throw frameTooLarge();
}
length = low;
offset += 8;
}
if (length > MAX_WS_FRAME_BYTES) {
throw frameTooLarge();
}
let mask = null;
if (masked) {
if (buffer.length < offset + 4) {
@@ -131,6 +142,7 @@ function createAcceptKey(key) {
module.exports = {
closeClient,
createAcceptKey,
FRAME_TOO_LARGE_ERROR,
jsonMessage,
makeFrame,
parseFrame,