refactor(web-panel): update architecture + cheat i18n

Reorganize the panel around product capabilities and integrate the
remote i18n feature from origin/master into the new structure.

- Restructure src into capability features (app, trainer, library,
  remote-session, appearance, shared); move the bridge to bridge/src.
- Add lingui-based UI localization and wrap remaining user-facing
  strings (Trans / msg macros); fix the 'END В·' mojibake.
- Port WeMod cheat-metadata i18n: capture the access token from the
  snapshot's renderer in the bridge and fetch localized trainer_meta
  in remote-session.i18n; guarantee snapshot sync on token failure.
- Wire vitest to the app's lingui/preact pipeline (mergeConfig).
This commit is contained in:
kitbyte
2026-06-15 00:10:49 +03:00
parent 8756e41fb9
commit a0b3968d33
106 changed files with 5526 additions and 1421 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"
const bridgeRoot = dirname(fileURLToPath(import.meta.url))
const webPanelRoot = resolve(bridgeRoot, "..")
const distRoot = resolve(webPanelRoot, "dist")
const bridgeEntryPoint = resolve(bridgeRoot, "source.cjs")
const bridgeEntryPoint = resolve(bridgeRoot, "src", "index.ts")
const bridgeOutfile = resolve(distRoot, "bridge.cjs")
const rendererScriptsRoot = resolve(bridgeRoot, "scripts", "default")
const rendererScriptsOutdir = resolve(distRoot, "renderer-scripts")
@@ -4,17 +4,18 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { WebSocketServer } from 'ws';
import demoSession from '../fixtures/demo-session.json' with { type: 'json' };
import webContract from '../protocol/web-contract.json' with { type: 'json' };
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const distDir = path.join(rootDir, 'dist');
const DEFAULT_REMOTE_PORT = 3223;
const DEFAULT_REMOTE_HOST = '0.0.0.0';
const REMOTE_BASE_PATH = '/remote/';
const REMOTE_WS_PATH = '/remote/ws';
const REMOTE_HEALTH_PATH = '/remote/api/health';
const REMOTE_ASSETS_PREFIX = '/remote/assets/';
const DEFAULT_REMOTE_PORT = webContract.defaultRemotePort;
const DEFAULT_REMOTE_HOST = webContract.defaultRemoteHost;
const REMOTE_BASE_PATH = webContract.basePath;
const REMOTE_WS_PATH = webContract.webSocketPath;
const REMOTE_HEALTH_PATH = webContract.healthPath;
const REMOTE_ASSETS_PREFIX = webContract.assetsPath;
const host = process.env.HOST || DEFAULT_REMOTE_HOST;
const port = Number(process.env.PORT || DEFAULT_REMOTE_PORT);
@@ -26,7 +27,7 @@ const wss = new WebSocketServer({ noServer: true });
function jsonMessage(type, payload, requestId = null) {
return JSON.stringify({
type,
version: 1,
version: webContract.protocolVersion,
requestId,
payload,
});
@@ -145,13 +146,20 @@ wss.on('connection', (ws) => {
ws.on('message', (raw) => {
try {
const message = JSON.parse(String(raw));
if (message?.version !== webContract.protocolVersion || typeof message?.type !== 'string' || !message?.payload) {
ws.send(jsonMessage('error', {
code: 'invalid_message',
message: 'Expected a compatible protocol envelope.',
}, message?.requestId ?? null));
return;
}
if (message?.type === 'hello') {
ws.send(
jsonMessage('hello_ack', {
sessionId: `sess_${Date.now()}`,
accepted: true,
serverVersion: '0.1.0-demo',
protocolVersion: 1,
protocolVersion: webContract.protocolVersion,
}, message.requestId ?? null)
);
sendSnapshot(ws);
@@ -160,7 +168,7 @@ wss.on('connection', (ws) => {
if (message?.type === 'set_value') {
const target = message.payload?.target;
if (typeof target !== 'string' || !(target in trainerValues.values)) {
if (message.payload?.trainerId !== trainerMeta.trainer.trainerId || typeof target !== 'string' || !(target in trainerValues.values)) {
ws.send(
jsonMessage('set_value_result', {
ok: false,
@@ -209,4 +217,4 @@ wss.on('connection', (ws) => {
server.listen(port, host, () => {
console.log(`Wand web panel bridge listening on http://${host === DEFAULT_REMOTE_HOST ? 'localhost' : host}:${port}${REMOTE_BASE_PATH}`);
});
});
+3
View File
@@ -0,0 +1,3 @@
{
"type": "commonjs"
}
+142
View File
@@ -0,0 +1,142 @@
const {
buildInstalledAppsDebugPayload,
gameStatusSignature,
installedAppsSignature,
normalizeGameStatusSnapshot,
normalizeInstalledAppsSnapshot,
normalizeSnapshot,
normalizeTrainerValue,
summarizeInstalledAppsSource,
} = require('./normalizers');
const { cloneValue, isRecord, safeString } = require('./utils');
const { sendJson } = require('./websocket-codec');
function createBridgeState({ clients, log, getServerInfo }) {
let currentSnapshot: any = null;
let currentInstalledApps: any = null;
let currentInstalledAppsSignature: string | null = null;
let currentGameStatus: any = null;
let currentGameStatusSignature: string | null = null;
function broadcast(type, payload, requestId = null) {
for (const client of clients) {
sendJson(client, type, payload, requestId);
}
}
function sendSnapshot(client) {
if (!currentSnapshot) {
sendJson(client, 'trainer_changed', { previousTrainerId: null, trainerId: '' });
} else {
sendJson(client, 'trainer_meta', currentSnapshot.trainerMeta);
sendJson(client, 'trainer_values', currentSnapshot.trainerValues);
}
if (currentGameStatus) sendJson(client, 'game_status', currentGameStatus);
if (currentInstalledApps) sendJson(client, 'installed_apps', currentInstalledApps);
}
function sync(rawSnapshot) {
const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null;
const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
currentSnapshot = nextSnapshot;
if (previousTrainerId !== nextTrainerId) {
broadcast('trainer_changed', { previousTrainerId, trainerId: nextTrainerId || '' });
}
if (currentSnapshot) {
broadcast('trainer_meta', currentSnapshot.trainerMeta);
broadcast('trainer_values', currentSnapshot.trainerValues);
}
}
function valueChanged(change) {
if (!currentSnapshot || !isRecord(change)) return;
const target = safeString(change.target);
if (!target) return;
const value = normalizeTrainerValue(currentSnapshot, target, change.value);
currentSnapshot.trainerValues.values[target] = value;
broadcast('value_changed', {
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
target,
value,
oldValue: cloneValue(change.oldValue),
source: safeString(change.source, 'desktop'),
cheatId: typeof change.cheatId === 'string' ? change.cheatId : undefined,
});
}
function syncInstalledApps(rawInstalledApps) {
const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps);
const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps);
if (!nextInstalledApps) {
log('warn', `Ignored invalid installed apps snapshot.${sourceSummary ? ` ${sourceSummary}` : ''}`);
return;
}
const nextSignature = installedAppsSignature(nextInstalledApps);
if (nextSignature === currentInstalledAppsSignature) return;
currentInstalledApps = nextInstalledApps;
currentInstalledAppsSignature = nextSignature;
log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
broadcast('installed_apps', currentInstalledApps);
}
function syncGameStatus(rawGameStatus) {
const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus);
if (!nextGameStatus) {
log('warn', 'Ignored invalid game status snapshot.');
return;
}
const nextSignature = gameStatusSignature(nextGameStatus);
if (nextSignature === currentGameStatusSignature) return;
currentGameStatus = nextGameStatus;
currentGameStatusSignature = nextSignature;
log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`);
broadcast('game_status', currentGameStatus);
}
function buildHealthPayload() {
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
const serverInfo = getServerInfo();
return {
ok: serverInfo.listening,
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || null,
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,
remoteUrl: serverInfo.remoteUrl,
advertisedUrls: serverInfo.advertisedUrls,
};
}
function clear() {
currentSnapshot = null;
currentInstalledApps = null;
currentInstalledAppsSignature = null;
currentGameStatus = null;
currentGameStatusSignature = null;
}
return {
get snapshot() { return currentSnapshot; },
buildHealthPayload,
buildInstalledAppsDebugPayload: () => buildInstalledAppsDebugPayload(currentInstalledApps),
clear,
sendSnapshot,
sync,
syncGameStatus,
syncInstalledApps,
valueChanged,
};
}
module.exports = {
createBridgeState,
};
@@ -1,4 +1,5 @@
const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']);
const WEB_CONTRACT = require('../../protocol/web-contract.json');
const WS_OPCODE = Object.freeze({
TEXT: 1,
@@ -22,23 +23,23 @@ const IPC_CHANNEL = Object.freeze({
module.exports = {
BRIDGE_LOG_FILE_NAME: 'wand-remote-bridge.log',
BRIDGE_PROTOCOL_VERSION: 1,
BRIDGE_SERVER_VERSION: '0.2.0-wand',
DEFAULT_REMOTE_HOST: '0.0.0.0',
DEFAULT_REMOTE_PORT: 3223,
BRIDGE_PROTOCOL_VERSION: WEB_CONTRACT.protocolVersion,
BRIDGE_SERVER_VERSION: WEB_CONTRACT.serverVersion,
DEFAULT_REMOTE_HOST: WEB_CONTRACT.defaultRemoteHost,
DEFAULT_REMOTE_PORT: WEB_CONTRACT.defaultRemotePort,
IPC_CHANNEL,
KNOWN_CHEAT_TYPES,
PORT_SCAN_RANGE: 30,
REMOTE_ASSETS_PREFIX: '/remote/assets/',
REMOTE_BASE_PATH: '/remote/',
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: '/remote/api/health',
REMOTE_INSTALLED_APPS_API_PATH: '/remote/api/installed-apps',
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: '/remote/ws',
REMOTE_WS_PATH: WEB_CONTRACT.webSocketPath,
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
RENDERER_SCRIPT_API_VERSION: 1,
RENDERER_SCRIPTS_DIR: 'renderer-scripts',
@@ -1,7 +1,8 @@
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./bridge-modules/runtime.cjs');
const { installWandRuntime: installRuntime } = require('./bridge-modules/wand-runtime.cjs');
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./runtime');
const { installWandRuntime: installRuntime } = require('./wand/runtime');
import type { BridgeOptions, ElectronPort } from './types';
function withDefaultPanelRoot(options = {}) {
function withDefaultPanelRoot(options: BridgeOptions = {}): BridgeOptions {
if (options.panelRoot) {
return options;
}
@@ -12,15 +13,15 @@ function withDefaultPanelRoot(options = {}) {
};
}
function createBridgeRuntime(options = {}) {
function createBridgeRuntime(options: BridgeOptions = {}) {
return createRuntime(withDefaultPanelRoot(options));
}
function ensureBridge(options = {}) {
function ensureBridge(options: BridgeOptions = {}) {
return ensureRuntime(withDefaultPanelRoot(options));
}
function installWandRuntime(electron, options = {}) {
function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) {
return installRuntime(electron, withDefaultPanelRoot(options));
}
@@ -2,7 +2,8 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { BRIDGE_LOG_FILE_NAME } = require('./constants.cjs');
const { BRIDGE_LOG_FILE_NAME } = require('./constants');
import type { BridgeOptions } from './types';
function writeLogLine(logFile, level, message, error) {
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
@@ -18,7 +19,7 @@ function writeLogLine(logFile, level, message, error) {
} catch { }
}
function createBridgeLogger(options = {}) {
function createBridgeLogger(options: BridgeOptions = {}) {
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
const log = (level, message, error) => writeLogLine(logFile, level, message, error);
log.file = logFile;
@@ -0,0 +1,32 @@
const { isRecord, safeString, toStringId } = require('../utils');
function normalizeRemoteCommandAction(value) {
return value === 'launch' || value === 'stop' ? value : 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);
const payload = { ok, action, gameId, titleId };
if (ok) return payload;
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
return {
...payload,
error: { code: 'command_rejected', message: 'The renderer rejected the remote command.' },
};
}
return {
...payload,
error: {
code: safeString(rawResult.error.code, 'command_rejected'),
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
},
};
}
module.exports = {
normalizeRemoteCommandAction,
normalizeRemoteCommandResult,
};
@@ -0,0 +1,55 @@
const { isRecord, safeString, toStringId } = require('../utils');
function normalizeGameStatusSnapshot(rawSnapshot) {
if (!isRecord(rawSnapshot)) return null;
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
return {
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
session: {
state: rawSession.state === 'running' ? 'running' : 'idle',
event: safeString(rawSession.event, 'snapshot'),
processId: typeof rawSession.processId === 'number' ? rawSession.processId : null,
gameId: toStringId(rawSession.gameId),
titleId: toStringId(rawSession.titleId),
titleName: typeof rawSession.titleName === 'string' ? rawSession.titleName : null,
sessionDurationSeconds: typeof rawSession.sessionDurationSeconds === 'number' ? rawSession.sessionDurationSeconds : null,
startedAt: typeof rawSession.startedAt === 'string' ? rawSession.startedAt : null,
endedAt: typeof rawSession.endedAt === 'string' ? rawSession.endedAt : null,
},
trainer: {
state: rawTrainer.state === 'running' ? 'running' : 'idle',
event: safeString(rawTrainer.event, 'snapshot'),
trainerId: toStringId(rawTrainer.trainerId),
displayName: typeof rawTrainer.displayName === 'string' ? rawTrainer.displayName : null,
gameId: toStringId(rawTrainer.gameId),
titleId: toStringId(rawTrainer.titleId),
},
};
}
function gameStatusSignature(snapshot) {
return [
snapshot.session.state,
snapshot.session.event,
snapshot.session.processId || '',
snapshot.session.gameId || '',
snapshot.session.titleId || '',
snapshot.session.titleName || '',
snapshot.session.sessionDurationSeconds || '',
snapshot.session.startedAt || '',
snapshot.session.endedAt || '',
snapshot.trainer.state,
snapshot.trainer.event,
snapshot.trainer.trainerId || '',
snapshot.trainer.displayName || '',
snapshot.trainer.gameId || '',
snapshot.trainer.titleId || '',
].join('|');
}
module.exports = {
gameStatusSignature,
normalizeGameStatusSnapshot,
};
@@ -1,5 +1,8 @@
const { KNOWN_CHEAT_TYPES } = require('./constants.cjs');
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('./utils.cjs');
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) {
if (typeof option === 'string' || typeof option === 'number') {
@@ -29,7 +32,7 @@ function normalizeArgs(args) {
return {};
}
const next = {};
const next: Record<string, unknown> = {};
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;
@@ -60,7 +63,7 @@ function normalizeCheat(cheat, index) {
return null;
}
const normalized = {
const normalized: Record<string, unknown> = {
uuid: safeString(cheat.uuid, `${target}-${index}`),
target,
type,
@@ -161,88 +164,13 @@ function normalizeInstalledAppsSnapshot(rawSnapshot) {
};
}
function normalizeGameStatusSnapshot(rawSnapshot) {
if (!isRecord(rawSnapshot)) {
return null;
}
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
return {
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
session: {
state: rawSession.state === 'running' ? 'running' : 'idle',
event: safeString(rawSession.event, 'snapshot'),
processId: typeof rawSession.processId === 'number' ? rawSession.processId : null,
gameId: toStringId(rawSession.gameId),
titleId: toStringId(rawSession.titleId),
titleName: typeof rawSession.titleName === 'string' ? rawSession.titleName : null,
sessionDurationSeconds: typeof rawSession.sessionDurationSeconds === 'number' ? rawSession.sessionDurationSeconds : null,
startedAt: typeof rawSession.startedAt === 'string' ? rawSession.startedAt : null,
endedAt: typeof rawSession.endedAt === 'string' ? rawSession.endedAt : null,
},
trainer: {
state: rawTrainer.state === 'running' ? 'running' : 'idle',
event: safeString(rawTrainer.event, 'snapshot'),
trainerId: toStringId(rawTrainer.trainerId),
displayName: typeof rawTrainer.displayName === 'string' ? rawTrainer.displayName : null,
gameId: toStringId(rawTrainer.gameId),
titleId: toStringId(rawTrainer.titleId),
},
};
}
function normalizeRemoteCommandAction(value) {
if (value === 'launch' || value === 'stop') {
return value;
}
return 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);
const payload = {
ok,
action,
gameId,
titleId,
};
if (ok) {
return payload;
}
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
return {
...payload,
error: {
code: 'command_rejected',
message: 'The renderer rejected the remote command.',
},
};
}
return {
...payload,
error: {
code: safeString(rawResult.error.code, 'command_rejected'),
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
},
};
}
function summarizeInstalledAppsSource(rawSnapshot) {
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
return '';
}
const parts = [];
const parts: string[] = [];
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
const value = rawSnapshot.diagnostics[key];
if (typeof value === 'number') {
@@ -269,26 +197,6 @@ function installedAppsSignature(snapshot) {
.join('\n');
}
function gameStatusSignature(snapshot) {
return [
snapshot.session.state,
snapshot.session.event,
snapshot.session.processId || '',
snapshot.session.gameId || '',
snapshot.session.titleId || '',
snapshot.session.titleName || '',
snapshot.session.sessionDurationSeconds || '',
snapshot.session.startedAt || '',
snapshot.session.endedAt || '',
snapshot.trainer.state,
snapshot.trainer.event,
snapshot.trainer.trainerId || '',
snapshot.trainer.displayName || '',
snapshot.trainer.gameId || '',
snapshot.trainer.titleId || '',
].join('|');
}
function buildInstalledAppsDebugPayload(snapshot) {
if (!snapshot) {
return {
@@ -412,6 +320,7 @@ function normalizeSnapshot(rawSnapshot) {
const trainerMeta = {
session: {
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
accessToken: safeString(rawSnapshot.accessToken),
},
trainer: {
trainerId,
@@ -437,6 +346,11 @@ function normalizeSnapshot(rawSnapshot) {
trainerId,
values: isRecord(rawSnapshot.values) ? cloneValue(rawSnapshot.values) : {},
};
for (const cheat of cheats) {
if (cheat.target in trainerValues.values) {
trainerValues.values[cheat.target] = normalizeTrainerValue({ trainerMeta }, cheat.target, trainerValues.values[cheat.target]);
}
}
return {
trainerMeta,
@@ -479,5 +393,6 @@ module.exports = {
normalizeRemoteCommandAction,
normalizeRemoteCommandResult,
normalizeSnapshot,
normalizeTrainerValue,
summarizeInstalledAppsSource,
};
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { normalizeTrainerValue } from './trainer';
describe('trainer normalization', () => {
it('normalizes toggle values before they reach clients or Wand', () => {
const snapshot = {
trainerMeta: {
schema: { cheats: [{ target: 'god', type: 'toggle' }] },
},
};
expect(normalizeTrainerValue(snapshot, 'god', 1)).toBe(true);
expect(normalizeTrainerValue(snapshot, 'god', 0)).toBe(false);
expect(normalizeTrainerValue(snapshot, 'speed', 2)).toBe(2);
});
});
@@ -0,0 +1,10 @@
export function normalizeTrainerValue(snapshot, target, value) {
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)]));
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { validateClientMessage, validateSetValueTarget } from './protocol-router';
const snapshot = {
trainerMeta: {
trainer: { trainerId: 'active' },
schema: { cheats: [{ target: 'god', type: 'toggle' }] },
},
trainerValues: { values: { god: false } },
};
describe('bridge protocol router', () => {
it('requires a compatible hello before commands', () => {
const command = {
type: 'set_value',
version: 1,
requestId: 'set',
payload: { trainerId: 'active', target: 'god', value: true },
};
expect(validateClientMessage(command, false)).toMatchObject({
ok: false,
error: { code: 'handshake_required' },
});
expect(validateClientMessage({ ...command, version: 2 }, true)).toMatchObject({
ok: false,
error: { code: 'protocol_mismatch' },
});
});
it('validates trainer and target while normalizing toggle values', () => {
expect(validateSetValueTarget({
payload: { trainerId: 'other', target: 'god', value: 1 },
}, snapshot)).toMatchObject({ ok: false, error: { code: 'trainer_mismatch' } });
expect(validateSetValueTarget({
payload: { trainerId: 'active', target: 'god', value: 1 },
}, snapshot)).toMatchObject({ ok: true, value: true });
});
});
+78
View File
@@ -0,0 +1,78 @@
import webContract from '../../protocol/web-contract.json';
const BRIDGE_PROTOCOL_VERSION = webContract.protocolVersion;
export function validateClientMessage(message, handshaken) {
if (!isRecord(message) || typeof message.type !== 'string' || !isRecord(message.payload)) {
return invalid('invalid_message', 'Expected a protocol envelope with an object payload.');
}
if (message.version !== BRIDGE_PROTOCOL_VERSION) {
return invalid('protocol_mismatch', `Unsupported protocol version ${String(message.version)}.`);
}
if (message.requestId !== null && typeof message.requestId !== 'string') {
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)) {
return invalid('invalid_hello', 'The hello payload is incomplete.');
}
return { ok: true };
}
if (!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.');
}
export function validateSetValueTarget(message, snapshot) {
const target = safeString(message.payload?.target);
const requestedTrainerId = safeString(message.payload?.trainerId);
const activeTrainerId = snapshot?.trainerMeta?.trainer?.trainerId || '';
if (!snapshot || requestedTrainerId !== activeTrainerId) {
return invalid('trainer_mismatch', 'The requested trainer is not active.');
}
const cheat = snapshot.trainerMeta.schema.cheats.find((entry) => entry.target === target);
if (!target || !cheat || !(target in snapshot.trainerValues.values)) {
return invalid('invalid_target', 'Unknown cheat target.');
}
return {
ok: true,
trainerId: activeTrainerId,
target,
cheat,
value: cheat.type === 'toggle' ? Boolean(message.payload.value) : message.payload.value,
};
}
function invalid(code, message) {
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 : '';
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
findSteamAppId,
getSteamClientIconUrl,
normalizeImageUrl,
} from '../scripts/default/installed-apps-sync/artwork.js';
describe('installed-apps renderer script models', () => {
it('normalizes captured artwork shapes without a Wand runtime', () => {
expect(normalizeImageUrl({ cover: { imageUrl: '//cdn.example/game.webp' } }))
.toBe('https://cdn.example/game.webp');
expect(normalizeImageUrl('file:///local/image.png')).toBeNull();
});
it('finds nested Steam metadata and builds the Wand client icon URL', () => {
const fixture = {
game: {
metadata: {
steam: {
appId: 1245620,
},
},
},
};
expect(findSteamAppId(fixture)).toBe('1245620');
expect(getSteamClientIconUrl(findSteamAppId(fixture)))
.toBe('https://api-cdn.wemod.com/steam_community/1245620/client_icon/96.webp');
});
});
@@ -0,0 +1,91 @@
import { createServer } from 'node:net';
import { describe, expect, it } from 'vitest';
import { WebSocket as NodeWebSocket } from 'ws';
describe('production bridge runtime', () => {
it('preserves the public API and sends cached snapshots after hello', async () => {
const bridge = require('../../dist/bridge.cjs');
expect(Object.keys(bridge).sort()).toEqual(['createBridgeRuntime', 'ensureBridge', 'installWandRuntime']);
const port = await getFreePort();
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
runtime.sync(rawTrainerSnapshot());
try {
await waitUntil(() => runtime.listening);
const messages = await connectAndCollect(port, 3);
expect(messages.map((message) => message.type)).toEqual(['hello_ack', 'trainer_meta', 'trainer_values']);
expect(messages[2].payload.values.god).toBe(true);
} finally {
runtime.close();
}
});
});
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close((error) => error ? reject(error) : resolve(port));
});
});
}
async function connectAndCollect(port: number, count: number): Promise<any[]> {
return await new Promise((resolve, reject) => {
const messages: any[] = [];
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`);
socket.once('error', reject);
socket.once('open', () => socket.send(JSON.stringify({
type: 'hello',
version: 1,
requestId: 'hello',
payload: {
client: 'mobile-web',
clientVersion: 'test',
capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true },
},
})));
socket.on('message', (raw) => {
messages.push(JSON.parse(String(raw)));
if (messages.length === count) {
socket.close();
resolve(messages);
}
});
});
}
async function waitUntil(predicate: () => boolean): Promise<void> {
const deadline = Date.now() + 3000;
while (!predicate()) {
if (Date.now() > deadline) throw new Error('Bridge did not start listening.');
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
function rawTrainerSnapshot() {
return {
instanceId: 'instance',
trainerId: 'trainer',
trainerInfo: { gameId: 'game', displayName: 'Game' },
metadata: {
info: {
blueprint: {
cheats: [{
uuid: 'god',
target: 'god',
type: 'toggle',
name: 'God mode',
category: 'player',
args: {},
}],
},
},
},
values: { god: 1 },
};
}
+19
View File
@@ -0,0 +1,19 @@
const { createBridgeServer } = require('./server');
import type { BridgeOptions } from './types';
function createBridgeRuntime(options: BridgeOptions = {}) {
return createBridgeServer(options);
}
function ensureBridge(options: BridgeOptions = {}) {
if (!globalThis.__wandRemoteBridgeRuntime) {
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
}
return globalThis.__wandRemoteBridgeRuntime;
}
module.exports = {
createBridgeRuntime,
ensureBridge,
};
@@ -2,7 +2,7 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { REMOTE_BASE_PATH } = require('./constants.cjs');
const { REMOTE_BASE_PATH } = require('./constants');
const IPV4_OCTET_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const PHYSICAL_INTERFACE_NAME_PATTERN = /(?:ethernet|wi-?fi|wireless|wlan|lan|local area)/i;
@@ -38,11 +38,11 @@ function contentTypeFor(filePath) {
}
function getAdvertisedUrls(port) {
const candidates = [];
const candidates: any[] = [];
const interfaces = os.networkInterfaces();
let index = 0;
for (const [name, entries] of Object.entries(interfaces)) {
for (const [name, entries] of Object.entries(interfaces) as [string, any[] | undefined][]) {
if (!entries) {
continue;
}
@@ -78,7 +78,7 @@ function isIpv4Family(family) {
}
function scoreIpv4Entry(name, entry) {
const octets = parseIpv4(entry.address);
const octets = parseIpv4(entry.address) as number[];
let score = 0;
if (isPrivateIpv4(octets)) {
@@ -116,7 +116,7 @@ function scoreIpv4Entry(name, entry) {
return score;
}
function parseIpv4(address) {
function parseIpv4(address): number[] | null {
if (typeof address !== 'string') {
return null;
}
@@ -13,40 +13,41 @@ const {
REMOTE_INSTALLED_APPS_API_PATH,
REMOTE_WS_PATH,
WS_OPCODE,
} = require('./constants.cjs');
const { createBridgeLogger } = require('./logger.cjs');
} = require('./constants');
const { createBridgeLogger } = require('./logger');
const {
buildInstalledAppsDebugPayload,
gameStatusSignature,
installedAppsSignature,
normalizeGameStatusSnapshot,
normalizeInstalledAppsSnapshot,
normalizeRemoteCommandAction,
normalizeRemoteCommandResult,
normalizeSnapshot,
summarizeInstalledAppsSource,
} = require('./normalizers.cjs');
const { getAdvertisedUrls, serveFile } = require('./static-server.cjs');
const { cloneValue, isRecord, isValidPort, safeString } = require('./utils.cjs');
const { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket.cjs');
} = 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 { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket-codec');
import type { BridgeOptions } from './types';
function createBridgeRuntime(options = {}) {
function createBridgeServer(options: BridgeOptions = {}) {
const preferredPort = Number(options.port || process.env.WAND_REMOTE_PORT || DEFAULT_REMOTE_PORT);
let port = isValidPort(preferredPort) ? preferredPort : DEFAULT_REMOTE_PORT;
const maxPort = Number(options.maxPort || process.env.WAND_REMOTE_MAX_PORT || port + PORT_SCAN_RANGE);
const 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<any>();
const log = createBridgeLogger(options);
let advertisedUrls = [];
let currentSnapshot = null;
let currentInstalledApps = null;
let currentInstalledAppsSignature = null;
let currentGameStatus = null;
let currentGameStatusSignature = null;
let setValueHandler = null;
let commandHandler = null;
let advertisedUrls: string[] = [];
let setValueHandler: any = null;
let commandHandler: any = null;
let listening = false;
const bridgeState = createBridgeState({
clients,
log,
getServerInfo: () => ({
advertisedUrls,
installedAppsApiPath: REMOTE_INSTALLED_APPS_API_PATH,
listening,
remoteUrl: globalThis.__wandRemoteBridgeUrl,
}),
});
function setAdvertisedPort(nextPort) {
port = nextPort;
@@ -54,112 +55,6 @@ function createBridgeRuntime(options = {}) {
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
}
function broadcast(type, payload, requestId = null) {
for (const client of clients) {
sendJson(client, type, payload, requestId);
}
}
function sendSnapshot(client) {
if (!currentSnapshot) {
sendJson(client, 'trainer_changed', {
previousTrainerId: null,
trainerId: '',
});
} else {
sendJson(client, 'trainer_meta', currentSnapshot.trainerMeta);
sendJson(client, 'trainer_values', currentSnapshot.trainerValues);
}
if (currentGameStatus) {
sendJson(client, 'game_status', currentGameStatus);
}
if (currentInstalledApps) {
sendJson(client, 'installed_apps', currentInstalledApps);
}
}
function sync(rawSnapshot) {
const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null;
const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
currentSnapshot = nextSnapshot;
if (previousTrainerId !== nextTrainerId) {
broadcast('trainer_changed', {
previousTrainerId,
trainerId: nextTrainerId || '',
});
}
if (!currentSnapshot) {
return;
}
broadcast('trainer_meta', currentSnapshot.trainerMeta);
broadcast('trainer_values', currentSnapshot.trainerValues);
}
function valueChanged(change) {
if (!currentSnapshot || !isRecord(change)) {
return;
}
const target = safeString(change.target);
if (!target) {
return;
}
currentSnapshot.trainerValues.values[target] = cloneValue(change.value);
broadcast('value_changed', {
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
target,
value: cloneValue(change.value),
oldValue: cloneValue(change.oldValue),
source: safeString(change.source, 'desktop'),
cheatId: typeof change.cheatId === 'string' ? change.cheatId : undefined,
});
}
function syncInstalledApps(rawInstalledApps) {
const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps);
const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps);
if (!nextInstalledApps) {
log('warn', `Ignored invalid installed apps snapshot.${sourceSummary ? ` ${sourceSummary}` : ''}`);
return;
}
const nextSignature = installedAppsSignature(nextInstalledApps);
if (nextSignature === currentInstalledAppsSignature) {
log('info', `Installed apps snapshot unchanged (${nextInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
return;
}
currentInstalledApps = nextInstalledApps;
currentInstalledAppsSignature = nextSignature;
log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
broadcast('installed_apps', currentInstalledApps);
}
function syncGameStatus(rawGameStatus) {
const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus);
if (!nextGameStatus) {
log('warn', 'Ignored invalid game status snapshot.');
return;
}
const nextSignature = gameStatusSignature(nextGameStatus);
if (nextSignature === currentGameStatusSignature) {
return;
}
currentGameStatus = nextGameStatus;
currentGameStatusSignature = nextSignature;
log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`);
broadcast('game_status', currentGameStatus);
}
function setHandler(handler) {
setValueHandler = typeof handler === 'function' ? handler : null;
}
@@ -168,25 +63,6 @@ function createBridgeRuntime(options = {}) {
commandHandler = typeof handler === 'function' ? handler : null;
}
function buildHealthPayload() {
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
return {
ok: listening,
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || null,
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: REMOTE_INSTALLED_APPS_API_PATH,
remoteUrl: globalThis.__wandRemoteBridgeUrl,
advertisedUrls,
};
}
function handleRequest(request, response) {
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
@@ -209,13 +85,13 @@ function createBridgeRuntime(options = {}) {
if (url.pathname === REMOTE_HEALTH_PATH) {
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(JSON.stringify(buildHealthPayload()));
response.end(JSON.stringify(bridgeState.buildHealthPayload()));
return;
}
if (url.pathname === REMOTE_INSTALLED_APPS_API_PATH) {
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(JSON.stringify(buildInstalledAppsDebugPayload(currentInstalledApps), null, 2));
response.end(JSON.stringify(bridgeState.buildInstalledAppsDebugPayload(), null, 2));
return;
}
@@ -283,19 +159,18 @@ function createBridgeRuntime(options = {}) {
}
async function handleSetValueMessage(client, message) {
const target = safeString(message.payload?.target);
if (!currentSnapshot || !target || !(target in currentSnapshot.trainerValues.values)) {
const currentSnapshot = bridgeState.snapshot;
const validation = validateSetValueTarget(message, currentSnapshot);
if (!validation.ok) {
sendJson(client, 'set_value_result', {
ok: false,
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '',
target,
error: {
code: 'invalid_target',
message: 'Unknown cheat target.',
},
target: safeString(message.payload?.target),
error: validation.error,
}, message.requestId ?? null);
return;
}
const { target } = validation;
if (!setValueHandler) {
sendJson(client, 'set_value_result', {
@@ -315,7 +190,7 @@ function createBridgeRuntime(options = {}) {
result = await Promise.resolve(setValueHandler({
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
target,
value: cloneValue(message.payload?.value),
value: cloneValue(validation.value),
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
}));
} catch (error) {
@@ -352,7 +227,14 @@ function createBridgeRuntime(options = {}) {
}
async function handleClientMessage(client, message) {
const validation = validateClientMessage(message, client.handshaken);
if (!validation.ok) {
sendJson(client, 'error', validation.error, message?.requestId ?? null);
return;
}
if (message?.type === 'hello') {
client.handshaken = true;
sendJson(client, 'hello_ack', {
sessionId: `sess_${Date.now()}`,
accepted: true,
@@ -361,7 +243,7 @@ function createBridgeRuntime(options = {}) {
remoteUrl: globalThis.__wandRemoteBridgeUrl,
advertisedUrls,
}, message.requestId ?? null);
sendSnapshot(client);
bridgeState.sendSnapshot(client);
return;
}
@@ -380,6 +262,7 @@ function createBridgeRuntime(options = {}) {
socket,
buffer: Buffer.alloc(0),
closed: false,
handshaken: false,
};
clients.add(client);
@@ -510,32 +393,19 @@ function createBridgeRuntime(options = {}) {
closeClient(client);
}
clients.clear();
currentSnapshot = null;
currentInstalledApps = null;
currentInstalledAppsSignature = null;
currentGameStatus = null;
currentGameStatusSignature = null;
bridgeState.clear();
listening = false;
server.close();
},
setCommandHandler,
setHandler,
sync,
syncGameStatus,
syncInstalledApps,
valueChanged,
sync: bridgeState.sync,
syncGameStatus: bridgeState.syncGameStatus,
syncInstalledApps: bridgeState.syncInstalledApps,
valueChanged: bridgeState.valueChanged,
};
}
function ensureBridge(options = {}) {
if (!globalThis.__wandRemoteBridgeRuntime) {
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
}
return globalThis.__wandRemoteBridgeRuntime;
}
module.exports = {
createBridgeRuntime,
ensureBridge,
createBridgeServer,
};
+24
View File
@@ -0,0 +1,24 @@
export type BridgeOptions = {
host?: string;
logFile?: string;
maxPort?: number | string;
panelRoot?: string;
port?: number | string;
scriptsRoot?: string;
};
export type WebContentsPort = {
executeJavaScript(source: string, userGesture?: boolean): Promise<unknown>;
isDestroyed(): boolean;
on(event: string, listener: () => void): void;
send(channel: string, payload: unknown): 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;
};
};
@@ -1,8 +1,9 @@
const fs = require('node:fs');
const path = require('node:path');
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('./constants.cjs');
const { writeInstallLog } = require('./logger.cjs');
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('../constants');
const { writeInstallLog } = require('../logger');
import type { BridgeOptions, ElectronPort } from '../types';
function loadRendererScripts(panelRoot, scriptsRoot) {
const root = scriptsRoot || path.join(panelRoot, RENDERER_SCRIPTS_DIR);
@@ -51,7 +52,7 @@ function buildRendererBootstrap(remoteUrl, scripts) {
return `(() => {\n${header}\n${body}\n})();`;
}
function installRendererScripts(electron, runtime, options = {}) {
function installRendererScripts(electron: ElectronPort, runtime, options: BridgeOptions = {}) {
if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) {
return;
}
@@ -8,19 +8,25 @@ const {
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS,
REMOTE_GAME_STATUS_CHANNEL,
REMOTE_INSTALLED_APPS_CHANNEL,
} = require('./constants.cjs');
const { writeInstallLog } = require('./logger.cjs');
const { ensureBridge } = require('./runtime.cjs');
const { installRendererScripts } = require('./renderer-scripts.cjs');
const { safeString } = require('./utils.cjs');
} = require('../constants');
const { writeInstallLog } = require('../logger');
const { ensureBridge } = require('../runtime');
const { installRendererScripts } = require('./renderer-scripts');
const { safeString } = require('../utils');
import type { BridgeOptions, ElectronPort, WebContentsPort } from '../types';
function installWandRuntime(electron, options = {}) {
// 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';
function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) {
const runtime = ensureBridge(options);
if (!electron || !electron.ipcMain || !electron.app) {
throw new Error('Electron main-process API is required to install Wand runtime hooks.');
}
const boundRenderers = globalThis.__wandRemoteBridgeBoundRenderers || new Set();
const boundRenderers: Set<WebContentsPort> = globalThis.__wandRemoteBridgeBoundRenderers || new Set();
const pendingCommandResponses = globalThis.__wandRemoteBridgePendingCommandResponses || new Map();
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses;
@@ -77,8 +83,8 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
}
globalThis.__wandRemoteBridgeIpcInstalled = true;
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (_event, snapshot) => {
runtime.sync(snapshot);
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event, snapshot) => {
void syncSnapshotWithAccessToken(runtime, event?.sender, snapshot);
return true;
});
electron.ipcMain.handle(REMOTE_INSTALLED_APPS_CHANNEL, (_event, snapshot) => {
@@ -113,6 +119,29 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime.remoteUrl);
}
async function syncSnapshotWithAccessToken(runtime, sender, snapshot) {
const accessToken = await readWemodAccessToken(sender);
if (accessToken && snapshot && typeof snapshot === 'object') {
snapshot.accessToken = accessToken;
}
runtime.sync(snapshot);
}
async function readWemodAccessToken(sender) {
if (!sender || typeof sender.executeJavaScript !== 'function' || sender.isDestroyed?.()) {
return null;
}
try {
const token = await sender.executeJavaScript(WEMOD_ACCESS_TOKEN_SCRIPT);
return typeof token === 'string' && token ? token : null;
} catch (error) {
writeInstallLog('warn', 'Failed to read WeMod access token from renderer.', error);
return null;
}
}
function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses) {
return new Promise((resolve, reject) => {
const requestId = `remote_command_${typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : Date.now().toString(36)}`;
@@ -1,6 +1,6 @@
const crypto = require('node:crypto');
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants.cjs');
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants');
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
@@ -15,7 +15,7 @@ function jsonMessage(type, payload, requestId = null) {
function makeFrame(opcode, payload) {
const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
const header = [];
const header: number[] = [];
header.push(0x80 | (opcode & 0x0f));
if (source.length < 126) {
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noImplicitAny": false,
"types": ["node"]
},
"include": ["src"]
}