mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-29 01:08:51 +00:00
fix: bump version to 1.0.9.4, update changelog
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
Vendored
+16
-10
@@ -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,
|
||||
|
||||
Vendored
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
Vendored
+84
-17
@@ -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,
|
||||
};
|
||||
|
||||
Vendored
+13
-9
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user