mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-29 02:01:19 +00:00
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:
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
+15
-100
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
+5
-5
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
+4
-3
@@ -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;
|
||||
}
|
||||
+38
-9
@@ -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)}`;
|
||||
+2
-2
@@ -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) {
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noImplicitAny": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user