mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-09-04 17:23:28 +00:00
feat: ship 1.0.8.0 release automation and runtime overhaul
- reduce ASAR IO overhead with streamed archive reads, buffered copies, faster relative-path handling and placeholder integrity records - fix in-place app.asar.unpacked packing/extraction self-copy cases that caused locked-file failures - tighten JS patch discovery with candidate bundle filters and search hints - require prebuilt remote-panel dist artifacts and clean up embedded bridge/script packaging - add unified build entrypoints for PowerShell, cmd and bash and move native CMake output under .tmp - add release metadata validation, changelog section extraction, pre-commit hook and GitHub Actions validation/release pipelines - make CHANGELOG the source of truth for release notes and document the tag-driven release flow - add updater release notes UI with latest/full changelog loading and localize the new update strings - modularize bridge renderer scripts, add installed apps and game status sync, and support remote launch/stop commands - centralize bridge protocol, IPC and WebSocket constants and improve LAN IP selection for QR pairing - refactor remote panel controls/state enums, persist accent color, polish library/session UI and refresh assets
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']);
|
||||
|
||||
const WS_OPCODE = Object.freeze({
|
||||
TEXT: 1,
|
||||
BINARY: 2,
|
||||
CLOSE: 8,
|
||||
PING: 9,
|
||||
PONG: 10,
|
||||
});
|
||||
|
||||
const IPC_CHANNEL = Object.freeze({
|
||||
BIND_HANDLER: 'wand-remote-set-handler-bind',
|
||||
COMMAND_REQUEST: 'wand-remote-command',
|
||||
COMMAND_RESPONSE: 'wand-remote-command-response',
|
||||
GAME_STATUS: 'wand-remote-game-status',
|
||||
INSTALLED_APPS: 'wand-remote-installed-apps',
|
||||
REMOTE_URL: 'wand-remote-url',
|
||||
SET_VALUE: 'wand-remote-set-value',
|
||||
TRAINER_SNAPSHOT: 'wand-remote-sync',
|
||||
VALUE_CHANGED: 'wand-remote-value-changed',
|
||||
});
|
||||
|
||||
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,
|
||||
IPC_CHANNEL,
|
||||
KNOWN_CHEAT_TYPES,
|
||||
PORT_SCAN_RANGE: 30,
|
||||
REMOTE_ASSETS_PREFIX: '/remote/assets/',
|
||||
REMOTE_BASE_PATH: '/remote/',
|
||||
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_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS,
|
||||
REMOTE_WS_PATH: '/remote/ws',
|
||||
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
|
||||
RENDERER_SCRIPT_API_VERSION: 1,
|
||||
RENDERER_SCRIPTS_DIR: 'renderer-scripts',
|
||||
WS_OPCODE,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { BRIDGE_LOG_FILE_NAME } = require('./constants.cjs');
|
||||
|
||||
function writeLogLine(logFile, level, message, error) {
|
||||
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
|
||||
const tag = `[wand-remote-bridge] ${message}`;
|
||||
|
||||
try {
|
||||
console[method](tag, error || '');
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
const detail = error ? ` :: ${error && error.stack ? error.stack : String(error)}` : '';
|
||||
fs.appendFileSync(logFile, `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`);
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function createBridgeLogger(options = {}) {
|
||||
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;
|
||||
return log;
|
||||
}
|
||||
|
||||
function writeInstallLog(level, message, error) {
|
||||
writeLogLine(path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME), level, message, error);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeLogger,
|
||||
writeInstallLog,
|
||||
};
|
||||
@@ -0,0 +1,483 @@
|
||||
const { KNOWN_CHEAT_TYPES } = require('./constants.cjs');
|
||||
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('./utils.cjs');
|
||||
|
||||
function normalizeOption(option) {
|
||||
if (typeof option === 'string' || typeof option === 'number') {
|
||||
return {
|
||||
label: String(option),
|
||||
value: option,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(option)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = option.value;
|
||||
if (typeof value !== 'string' && typeof value !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: safeString(option.label, String(value)),
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeArgs(args) {
|
||||
if (!isRecord(args)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const next = {};
|
||||
if (typeof args.min === 'number') next.min = args.min;
|
||||
if (typeof args.max === 'number') next.max = args.max;
|
||||
if (typeof args.step === 'number') next.step = args.step;
|
||||
if (typeof args.postfix === 'string') next.postfix = args.postfix;
|
||||
if (typeof args.default === 'string' || typeof args.default === 'number' || typeof args.default === 'boolean') {
|
||||
next.default = args.default;
|
||||
}
|
||||
|
||||
if (Array.isArray(args.options)) {
|
||||
next.options = args.options.map(normalizeOption).filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof args.button === 'string' || typeof args.button === 'boolean') {
|
||||
next.button = args.button;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeCheat(cheat, index) {
|
||||
if (!isRecord(cheat)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = safeString(cheat.target);
|
||||
const type = safeString(cheat.type);
|
||||
if (!target || !KNOWN_CHEAT_TYPES.has(type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
uuid: safeString(cheat.uuid, `${target}-${index}`),
|
||||
target,
|
||||
type,
|
||||
name: safeString(cheat.name, target),
|
||||
description: typeof cheat.description === 'string' ? cheat.description : null,
|
||||
instructions: typeof cheat.instructions === 'string' ? cheat.instructions : null,
|
||||
category: safeString(cheat.category, 'general'),
|
||||
parent: typeof cheat.parent === 'string' ? cheat.parent : null,
|
||||
args: normalizeArgs(cheat.args),
|
||||
};
|
||||
|
||||
if (typeof cheat.flags === 'number') {
|
||||
normalized.flags = cheat.flags;
|
||||
}
|
||||
|
||||
if (Array.isArray(cheat.hotkeys)) {
|
||||
normalized.hotkeys = cheat.hotkeys.filter(Array.isArray).map((group) => group.map((item) => String(item)));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeImageUrl(...values) {
|
||||
const value = firstString(...values);
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function getRawInstalledApps(rawSnapshot) {
|
||||
if (Array.isArray(rawSnapshot)) {
|
||||
return rawSnapshot;
|
||||
}
|
||||
|
||||
if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.apps)) {
|
||||
return rawSnapshot.apps;
|
||||
}
|
||||
|
||||
if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.installedApps)) {
|
||||
return rawSnapshot.installedApps;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeInstalledApp(app) {
|
||||
if (!isRecord(app)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platform = safeString(app.platform);
|
||||
const sku = safeString(app.sku);
|
||||
if (!platform || !sku) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const location = typeof app.location === 'string' ? app.location : '';
|
||||
const alternateLocations = Array.isArray(app.alternateLocations)
|
||||
? app.alternateLocations.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim())
|
||||
: [];
|
||||
|
||||
return {
|
||||
platform,
|
||||
sku,
|
||||
correlationId: `${platform}:${sku}`,
|
||||
displayName: firstString(
|
||||
app.displayName,
|
||||
app.titleName,
|
||||
app.gameName,
|
||||
app.name,
|
||||
location.replaceAll('\\', '/').split('/').filter(Boolean).pop() || '',
|
||||
`${platform}:${sku}`
|
||||
),
|
||||
gameId: toStringId(app.gameId),
|
||||
titleId: toStringId(app.titleId),
|
||||
location,
|
||||
alternateLocations,
|
||||
imageUrl: normalizeImageUrl(app.imageUrl, app.iconUrl, app.coverUrl, app.thumbnailUrl, app.logoUrl, app.headerImageUrl),
|
||||
platformLastPlayedTimestamp: typeof app.platformLastPlayedTimestamp === 'number' ? app.platformLastPlayedTimestamp : null,
|
||||
platformTotalPlaytimeMinutes: typeof app.platformTotalPlaytimeMinutes === 'number' ? app.platformTotalPlaytimeMinutes : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInstalledAppsSnapshot(rawSnapshot) {
|
||||
const rawApps = getRawInstalledApps(rawSnapshot);
|
||||
if (!rawApps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const apps = rawApps.map(normalizeInstalledApp).filter(Boolean).sort(compareInstalledApps);
|
||||
const diagnostics = isRecord(rawSnapshot) && isRecord(rawSnapshot.diagnostics)
|
||||
? cloneValue(rawSnapshot.diagnostics)
|
||||
: null;
|
||||
|
||||
return {
|
||||
instanceId: isRecord(rawSnapshot) ? safeString(rawSnapshot.instanceId, 'wand-installed-apps') : 'wand-installed-apps',
|
||||
updatedAt: isRecord(rawSnapshot) && typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
|
||||
apps,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function 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 = [];
|
||||
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
|
||||
const value = rawSnapshot.diagnostics[key];
|
||||
if (typeof value === 'number') {
|
||||
parts.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function installedAppsSignature(snapshot) {
|
||||
return snapshot.apps
|
||||
.map((app) => [
|
||||
app.platform,
|
||||
app.sku,
|
||||
app.displayName,
|
||||
app.gameId || '',
|
||||
app.titleId || '',
|
||||
app.location,
|
||||
app.imageUrl || '',
|
||||
app.platformLastPlayedTimestamp || '',
|
||||
app.platformTotalPlaytimeMinutes || '',
|
||||
].join('|'))
|
||||
.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 {
|
||||
ok: false,
|
||||
instanceId: null,
|
||||
updatedAt: null,
|
||||
counts: {
|
||||
myGamesEntries: 0,
|
||||
rawInstallEntries: 0,
|
||||
groupedTitles: 0,
|
||||
uniqueTitleIds: 0,
|
||||
uniqueGameIds: 0,
|
||||
},
|
||||
diagnostics: null,
|
||||
byPlatform: {},
|
||||
titles: [],
|
||||
apps: [],
|
||||
};
|
||||
}
|
||||
|
||||
const diagnostics = isRecord(snapshot.diagnostics) ? snapshot.diagnostics : null;
|
||||
const byPlatform = {};
|
||||
const uniqueTitleIds = new Set();
|
||||
const uniqueGameIds = new Set();
|
||||
const titleGroups = new Map();
|
||||
|
||||
for (const app of snapshot.apps) {
|
||||
byPlatform[app.platform] = (byPlatform[app.platform] || 0) + 1;
|
||||
|
||||
if (app.titleId) {
|
||||
uniqueTitleIds.add(app.titleId);
|
||||
}
|
||||
|
||||
if (app.gameId) {
|
||||
uniqueGameIds.add(app.gameId);
|
||||
}
|
||||
|
||||
const groupKey = resolveInstalledAppGroupKey(app);
|
||||
let group = titleGroups.get(groupKey);
|
||||
if (!group) {
|
||||
group = {
|
||||
key: groupKey,
|
||||
titleId: app.titleId,
|
||||
displayName: app.displayName,
|
||||
gameIds: new Set(),
|
||||
platforms: new Set(),
|
||||
apps: [],
|
||||
};
|
||||
titleGroups.set(groupKey, group);
|
||||
}
|
||||
|
||||
if (app.gameId) {
|
||||
group.gameIds.add(app.gameId);
|
||||
}
|
||||
|
||||
group.platforms.add(app.platform);
|
||||
group.apps.push(app);
|
||||
}
|
||||
|
||||
const titles = Array.from(titleGroups.values())
|
||||
.map((group) => ({
|
||||
key: group.key,
|
||||
titleId: group.titleId,
|
||||
displayName: group.displayName,
|
||||
gameIds: Array.from(group.gameIds).sort(),
|
||||
platforms: Array.from(group.platforms).sort(),
|
||||
appEntries: group.apps.length,
|
||||
apps: group.apps,
|
||||
}))
|
||||
.sort((left, right) => left.displayName.localeCompare(right.displayName));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
instanceId: snapshot.instanceId,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
counts: {
|
||||
myGamesEntries: snapshot.apps.length,
|
||||
rawInstallEntries: typeof diagnostics?.rawInstalledApps === 'number' ? diagnostics.rawInstalledApps : snapshot.apps.length,
|
||||
groupedTitles: titles.length,
|
||||
uniqueTitleIds: uniqueTitleIds.size,
|
||||
uniqueGameIds: uniqueGameIds.size,
|
||||
},
|
||||
diagnostics,
|
||||
byPlatform,
|
||||
titles,
|
||||
apps: snapshot.apps,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSnapshot(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.metadata) || !isRecord(rawSnapshot.metadata.info)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const info = rawSnapshot.metadata.info;
|
||||
const blueprint = isRecord(info.blueprint) ? info.blueprint : {};
|
||||
const rawCheats = Array.isArray(blueprint.cheats) ? blueprint.cheats : [];
|
||||
const cheats = rawCheats.map(normalizeCheat).filter(Boolean);
|
||||
const categories = Array.from(new Set(cheats.map((entry) => entry.category)));
|
||||
const trainerId = safeString(rawSnapshot.trainerId || rawSnapshot.trainerInfo?.trainerId);
|
||||
const displayName = firstString(
|
||||
rawSnapshot.trainerInfo?.displayName,
|
||||
rawSnapshot.trainerInfo?.gameName,
|
||||
rawSnapshot.trainerInfo?.titleName,
|
||||
rawSnapshot.trainerInfo?.title,
|
||||
rawSnapshot.trainerInfo?.name,
|
||||
info.displayName,
|
||||
info.gameName,
|
||||
info.titleName,
|
||||
info.title,
|
||||
info.name,
|
||||
info.game?.displayName,
|
||||
info.game?.name,
|
||||
info.game?.title
|
||||
);
|
||||
|
||||
if (!trainerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trainerMeta = {
|
||||
session: {
|
||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
|
||||
},
|
||||
trainer: {
|
||||
trainerId,
|
||||
gameId: safeString(rawSnapshot.trainerInfo?.gameId || info.gameId),
|
||||
displayName: displayName || safeString(rawSnapshot.trainerInfo?.gameId || info.gameId, trainerId),
|
||||
titleId: typeof info.titleId === 'string' ? info.titleId : null,
|
||||
gameVersion: typeof rawSnapshot.gameVersion === 'string' ? rawSnapshot.gameVersion : null,
|
||||
trainerLoading: rawSnapshot.trainerLoading === true,
|
||||
gameInstalled: rawSnapshot.gameInstalled !== false,
|
||||
needsCompatibilityWarning: rawSnapshot.needsCompatibilityWarning === true,
|
||||
language: safeString(rawSnapshot.language, 'en-US'),
|
||||
themeId: safeString(rawSnapshot.themeId, 'default'),
|
||||
isTimeLimitExpired: rawSnapshot.isTimeLimitExpired === true,
|
||||
notesReadHash: typeof rawSnapshot.notesReadHash === 'string' ? rawSnapshot.notesReadHash : null,
|
||||
},
|
||||
schema: {
|
||||
categories,
|
||||
cheats,
|
||||
},
|
||||
};
|
||||
|
||||
const trainerValues = {
|
||||
trainerId,
|
||||
values: isRecord(rawSnapshot.values) ? cloneValue(rawSnapshot.values) : {},
|
||||
};
|
||||
|
||||
return {
|
||||
trainerMeta,
|
||||
trainerValues,
|
||||
};
|
||||
}
|
||||
|
||||
function compareInstalledApps(left, right) {
|
||||
const displayNameDiff = left.displayName.localeCompare(right.displayName);
|
||||
if (displayNameDiff !== 0) {
|
||||
return displayNameDiff;
|
||||
}
|
||||
|
||||
const platformDiff = left.platform.localeCompare(right.platform);
|
||||
if (platformDiff !== 0) {
|
||||
return platformDiff;
|
||||
}
|
||||
|
||||
return left.sku.localeCompare(right.sku);
|
||||
}
|
||||
|
||||
function resolveInstalledAppGroupKey(app) {
|
||||
if (app.titleId) {
|
||||
return `title:${app.titleId}`;
|
||||
}
|
||||
|
||||
if (app.gameId) {
|
||||
return `game:${app.gameId}`;
|
||||
}
|
||||
|
||||
return `app:${app.correlationId}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildInstalledAppsDebugPayload,
|
||||
gameStatusSignature,
|
||||
installedAppsSignature,
|
||||
normalizeGameStatusSnapshot,
|
||||
normalizeInstalledAppsSnapshot,
|
||||
normalizeRemoteCommandAction,
|
||||
normalizeRemoteCommandResult,
|
||||
normalizeSnapshot,
|
||||
summarizeInstalledAppsSource,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
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');
|
||||
|
||||
function loadRendererScripts(panelRoot, scriptsRoot) {
|
||||
const root = scriptsRoot || path.join(panelRoot, RENDERER_SCRIPTS_DIR);
|
||||
if (!fs.existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(root)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((name) => ({
|
||||
name,
|
||||
source: fs.readFileSync(path.join(root, name), 'utf8'),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildRendererBootstrap(remoteUrl, scripts) {
|
||||
const header = `
|
||||
globalThis.__wandRemoteBridgeUrl = ${JSON.stringify(remoteUrl)};
|
||||
if (!globalThis.WandEnhancer) {
|
||||
globalThis.WandEnhancer = Object.freeze({
|
||||
apiVersion: ${RENDERER_SCRIPT_API_VERSION},
|
||||
remoteUrl: ${JSON.stringify(remoteUrl)},
|
||||
log: function () { try { console.info.apply(console, ["[wand-enhancer-script]"].concat(Array.from(arguments))); } catch (_) {} },
|
||||
});
|
||||
} else {
|
||||
try { globalThis.__wandRemoteBridgeUrl = ${JSON.stringify(remoteUrl)}; } catch (_) {}
|
||||
}
|
||||
console.info("[wand-remote-bridge] Renderer bootstrap (" + ${scripts.length} + " script(s)).");
|
||||
`;
|
||||
|
||||
const body = scripts.map((script) => {
|
||||
const tag = JSON.stringify(`wand-enhancer-script-${script.name}`);
|
||||
return `
|
||||
;(function (WandEnhancer) {
|
||||
try {
|
||||
${script.source}
|
||||
} catch (error) {
|
||||
try { console.warn("[wand-remote-bridge] Renderer script failed", ${JSON.stringify(script.name)}, error); } catch (_) {}
|
||||
}
|
||||
})(globalThis.WandEnhancer);
|
||||
//# sourceURL=${tag.slice(1, -1)}
|
||||
`;
|
||||
}).join('\n');
|
||||
|
||||
return `(() => {\n${header}\n${body}\n})();`;
|
||||
}
|
||||
|
||||
function installRendererScripts(electron, runtime, options = {}) {
|
||||
if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.__wandRemoteBridgeRendererScriptsInstalled = true;
|
||||
const scripts = loadRendererScripts(options.panelRoot || path.dirname(__dirname), options.scriptsRoot);
|
||||
if (scripts.length === 0) {
|
||||
writeInstallLog('info', 'No renderer scripts found.');
|
||||
return;
|
||||
}
|
||||
|
||||
electron.app.on('web-contents-created', (_event, contents) => {
|
||||
const inject = () => {
|
||||
if (!contents || contents.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
contents.executeJavaScript(buildRendererBootstrap(runtime.remoteUrl, scripts), true)
|
||||
.catch((error) => writeInstallLog('warn', 'Failed to inject renderer scripts.', error));
|
||||
};
|
||||
|
||||
contents.on('dom-ready', inject);
|
||||
contents.on('did-finish-load', inject);
|
||||
for (const delayMs of RENDERER_INJECTION_DELAYS_MS) {
|
||||
setTimeout(inject, delayMs);
|
||||
}
|
||||
});
|
||||
|
||||
writeInstallLog('info', `Renderer script injection installed (${scripts.map((script) => script.name).join(', ')}).`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildRendererBootstrap,
|
||||
installRendererScripts,
|
||||
loadRendererScripts,
|
||||
};
|
||||
@@ -0,0 +1,541 @@
|
||||
const http = require('node:http');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
BRIDGE_PROTOCOL_VERSION,
|
||||
BRIDGE_SERVER_VERSION,
|
||||
DEFAULT_REMOTE_HOST,
|
||||
DEFAULT_REMOTE_PORT,
|
||||
PORT_SCAN_RANGE,
|
||||
REMOTE_ASSETS_PREFIX,
|
||||
REMOTE_BASE_PATH,
|
||||
REMOTE_HEALTH_PATH,
|
||||
REMOTE_INSTALLED_APPS_API_PATH,
|
||||
REMOTE_WS_PATH,
|
||||
WS_OPCODE,
|
||||
} = require('./constants.cjs');
|
||||
const { createBridgeLogger } = require('./logger.cjs');
|
||||
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');
|
||||
|
||||
function createBridgeRuntime(options = {}) {
|
||||
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 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 listening = false;
|
||||
|
||||
function setAdvertisedPort(nextPort) {
|
||||
port = nextPort;
|
||||
advertisedUrls = getAdvertisedUrls(port);
|
||||
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;
|
||||
}
|
||||
|
||||
function setCommandHandler(handler) {
|
||||
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'}`);
|
||||
|
||||
if (url.pathname === '/' || url.pathname === '') {
|
||||
response.writeHead(302, { Location: '/remote/' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === REMOTE_BASE_PATH.slice(0, -1)) {
|
||||
response.writeHead(302, { Location: REMOTE_BASE_PATH });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === REMOTE_BASE_PATH) {
|
||||
serveFile(response, path.join(panelRoot, 'index.html'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === REMOTE_HEALTH_PATH) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
response.end(JSON.stringify(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));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith(REMOTE_ASSETS_PREFIX)) {
|
||||
serveFile(response, path.join(panelRoot, url.pathname.replace(REMOTE_BASE_PATH, '')));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Not found');
|
||||
}
|
||||
|
||||
async function handleRemoteCommandMessage(client, message) {
|
||||
const action = normalizeRemoteCommandAction(message.payload?.action);
|
||||
const gameId = typeof message.payload?.gameId === 'string' || typeof message.payload?.gameId === 'number'
|
||||
? String(message.payload.gameId)
|
||||
: null;
|
||||
const titleId = typeof message.payload?.titleId === 'string' || typeof message.payload?.titleId === 'number'
|
||||
? String(message.payload.titleId)
|
||||
: null;
|
||||
|
||||
if (!action) {
|
||||
sendJson(client, 'error', {
|
||||
code: 'invalid_command',
|
||||
message: 'Unknown remote command.',
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = { action, gameId, titleId };
|
||||
if (action === 'launch' && !gameId) {
|
||||
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid_game',
|
||||
message: 'A game id is required to launch a trainer.',
|
||||
},
|
||||
}, fallback), message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!commandHandler) {
|
||||
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'bridge_not_ready',
|
||||
message: 'The local bridge is not ready to execute remote game commands yet.',
|
||||
},
|
||||
}, fallback), message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Promise.resolve(commandHandler({ action, gameId, titleId }));
|
||||
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult(result, fallback), message.requestId ?? null);
|
||||
} catch (error) {
|
||||
sendJson(client, 'remote_command_result', normalizeRemoteCommandResult({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'command_failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to execute the remote command.',
|
||||
},
|
||||
}, fallback), message.requestId ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetValueMessage(client, message) {
|
||||
const target = safeString(message.payload?.target);
|
||||
if (!currentSnapshot || !target || !(target in currentSnapshot.trainerValues.values)) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '',
|
||||
target,
|
||||
error: {
|
||||
code: 'invalid_target',
|
||||
message: 'Unknown cheat target.',
|
||||
},
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!setValueHandler) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
error: {
|
||||
code: 'bridge_not_ready',
|
||||
message: 'The local bridge is not ready to write trainer values yet.',
|
||||
},
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
let result = false;
|
||||
try {
|
||||
result = await Promise.resolve(setValueHandler({
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
value: cloneValue(message.payload?.value),
|
||||
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
error: {
|
||||
code: 'set_failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to set trainer value.',
|
||||
},
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
error: {
|
||||
code: 'set_rejected',
|
||||
message: 'The trainer rejected the requested value.',
|
||||
},
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: true,
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
}, message.requestId ?? null);
|
||||
}
|
||||
|
||||
async function handleClientMessage(client, message) {
|
||||
if (message?.type === 'hello') {
|
||||
sendJson(client, 'hello_ack', {
|
||||
sessionId: `sess_${Date.now()}`,
|
||||
accepted: true,
|
||||
serverVersion: BRIDGE_SERVER_VERSION,
|
||||
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
advertisedUrls,
|
||||
}, message.requestId ?? null);
|
||||
sendSnapshot(client);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === 'remote_command') {
|
||||
await handleRemoteCommandMessage(client, message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === 'set_value') {
|
||||
await handleSetValueMessage(client, message);
|
||||
}
|
||||
}
|
||||
|
||||
function bindSocket(socket) {
|
||||
const client = {
|
||||
socket,
|
||||
buffer: Buffer.alloc(0),
|
||||
closed: false,
|
||||
};
|
||||
|
||||
clients.add(client);
|
||||
|
||||
socket.on('data', async (chunk) => {
|
||||
try {
|
||||
client.buffer = Buffer.concat([client.buffer, chunk]);
|
||||
|
||||
while (client.buffer.length > 0) {
|
||||
const frame = parseFrame(client.buffer);
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.buffer = client.buffer.subarray(frame.bytesConsumed);
|
||||
|
||||
if (!frame.fin) {
|
||||
closeClient(client, 1003, 'Fragmented frames are not supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.opcode === WS_OPCODE.CLOSE) {
|
||||
closeClient(client, 1000, 'Closing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.opcode === WS_OPCODE.PING) {
|
||||
client.socket.write(makeFrame(WS_OPCODE.PONG, frame.payload));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.opcode !== WS_OPCODE.TEXT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await handleClientMessage(client, JSON.parse(frame.payload.toString('utf8')));
|
||||
}
|
||||
} catch (error) {
|
||||
sendJson(client, 'error', {
|
||||
code: 'invalid_message',
|
||||
message: error instanceof Error ? error.message : 'Failed to process client message.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
client.closed = true;
|
||||
clients.delete(client);
|
||||
});
|
||||
|
||||
socket.on('end', () => {
|
||||
client.closed = true;
|
||||
clients.delete(client);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
client.closed = true;
|
||||
clients.delete(client);
|
||||
log('warn', 'WebSocket client error.', error);
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpgrade(request, socket) {
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||
if (url.pathname !== REMOTE_WS_PATH) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = request.headers['sec-websocket-key'];
|
||||
if (typeof key !== 'string' || !key) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write([
|
||||
'HTTP/1.1 101 Switching Protocols',
|
||||
'Upgrade: websocket',
|
||||
'Connection: Upgrade',
|
||||
`Sec-WebSocket-Accept: ${createAcceptKey(key)}`,
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
|
||||
bindSocket(socket);
|
||||
}
|
||||
|
||||
function listen(nextPort) {
|
||||
setAdvertisedPort(nextPort);
|
||||
server.listen(port, host);
|
||||
}
|
||||
|
||||
setAdvertisedPort(port);
|
||||
log('info', `Bridge starting (pid=${process.pid}, panelRoot=${panelRoot}, preferredPort=${port}, host=${host})`);
|
||||
globalThis.__wandRemoteBridgeLogFile = log.file;
|
||||
|
||||
const server = http.createServer(handleRequest);
|
||||
server.on('upgrade', handleUpgrade);
|
||||
server.on('error', (error) => {
|
||||
if (!listening && error && error.code === 'EADDRINUSE' && port < maxPort) {
|
||||
const nextPort = port + 1;
|
||||
log('warn', `Port ${port} is busy, trying ${nextPort}.`);
|
||||
listen(nextPort);
|
||||
return;
|
||||
}
|
||||
|
||||
log('warn', `Bridge server error on ${host}:${port}.`, error);
|
||||
});
|
||||
server.on('listening', () => {
|
||||
listening = true;
|
||||
log('info', `Listening on ${globalThis.__wandRemoteBridgeUrl}`);
|
||||
});
|
||||
|
||||
listen(port);
|
||||
|
||||
return {
|
||||
get advertisedUrls() {
|
||||
return advertisedUrls.slice();
|
||||
},
|
||||
get listening() {
|
||||
return listening;
|
||||
},
|
||||
get remoteUrl() {
|
||||
return globalThis.__wandRemoteBridgeUrl;
|
||||
},
|
||||
close() {
|
||||
for (const client of clients) {
|
||||
closeClient(client);
|
||||
}
|
||||
clients.clear();
|
||||
currentSnapshot = null;
|
||||
currentInstalledApps = null;
|
||||
currentInstalledAppsSignature = null;
|
||||
currentGameStatus = null;
|
||||
currentGameStatusSignature = null;
|
||||
listening = false;
|
||||
server.close();
|
||||
},
|
||||
setCommandHandler,
|
||||
setHandler,
|
||||
sync,
|
||||
syncGameStatus,
|
||||
syncInstalledApps,
|
||||
valueChanged,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureBridge(options = {}) {
|
||||
if (!globalThis.__wandRemoteBridgeRuntime) {
|
||||
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
|
||||
}
|
||||
|
||||
return globalThis.__wandRemoteBridgeRuntime;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeRuntime,
|
||||
ensureBridge,
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { REMOTE_BASE_PATH } = require('./constants.cjs');
|
||||
|
||||
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;
|
||||
const VIRTUAL_INTERFACE_NAME_PATTERN = /(?:bluetooth|container|docker|hamachi|hyper-v|loopback|npcap|pseudo|tap|tailscale|teredo|tunnel|tun|virtual|vmware|vbox|virtualbox|vpn|wireguard|wsl|zerotier)/i;
|
||||
const VIRTUAL_MAC_PREFIXES = new Set([
|
||||
'00:05:69',
|
||||
'00:0c:29',
|
||||
'00:15:5d',
|
||||
'00:16:3e',
|
||||
'00:1c:14',
|
||||
'00:50:56',
|
||||
'08:00:27',
|
||||
'52:54:00',
|
||||
]);
|
||||
|
||||
function contentTypeFor(filePath) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
switch (extension) {
|
||||
case '.html':
|
||||
return 'text/html; charset=utf-8';
|
||||
case '.js':
|
||||
case '.cjs':
|
||||
return 'application/javascript; charset=utf-8';
|
||||
case '.css':
|
||||
return 'text/css; charset=utf-8';
|
||||
case '.json':
|
||||
return 'application/json; charset=utf-8';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function getAdvertisedUrls(port) {
|
||||
const candidates = [];
|
||||
const interfaces = os.networkInterfaces();
|
||||
let index = 0;
|
||||
|
||||
for (const [name, entries] of Object.entries(interfaces)) {
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isUsableIpv4Entry(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
index,
|
||||
score: scoreIpv4Entry(name, entry),
|
||||
url: `http://${entry.address}:${port}${REMOTE_BASE_PATH}`,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const urls = candidates
|
||||
.sort((left, right) => right.score - left.score || left.index - right.index)
|
||||
.map((candidate) => candidate.url);
|
||||
|
||||
urls.unshift(`http://localhost:${port}${REMOTE_BASE_PATH}`);
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
function isUsableIpv4Entry(entry) {
|
||||
return Boolean(entry && !entry.internal && isIpv4Family(entry.family) && parseIpv4(entry.address));
|
||||
}
|
||||
|
||||
function isIpv4Family(family) {
|
||||
return family === 'IPv4' || family === 4;
|
||||
}
|
||||
|
||||
function scoreIpv4Entry(name, entry) {
|
||||
const octets = parseIpv4(entry.address);
|
||||
let score = 0;
|
||||
|
||||
if (isPrivateIpv4(octets)) {
|
||||
score += 1000;
|
||||
}
|
||||
|
||||
if (octets[0] === 192 && octets[1] === 168) {
|
||||
score += 40;
|
||||
} else if (octets[0] === 10) {
|
||||
score += 30;
|
||||
} else if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
if (PHYSICAL_INTERFACE_NAME_PATTERN.test(name)) {
|
||||
score += 120;
|
||||
}
|
||||
|
||||
if (VIRTUAL_INTERFACE_NAME_PATTERN.test(name)) {
|
||||
score -= 700;
|
||||
}
|
||||
|
||||
if (isVirtualMac(entry.mac)) {
|
||||
score -= 450;
|
||||
}
|
||||
|
||||
if (isLinkLocalIpv4(octets)) {
|
||||
score -= 1200;
|
||||
}
|
||||
|
||||
if (octets[3] === 1 || octets[3] === 254) {
|
||||
score -= 25;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function parseIpv4(address) {
|
||||
if (typeof address !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = address.match(IPV4_OCTET_PATTERN);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const octets = match.slice(1).map((part) => Number(part));
|
||||
return octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) ? octets : null;
|
||||
}
|
||||
|
||||
function isPrivateIpv4(octets) {
|
||||
return octets[0] === 10 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
|
||||
}
|
||||
|
||||
function isLinkLocalIpv4(octets) {
|
||||
return octets[0] === 169 && octets[1] === 254;
|
||||
}
|
||||
|
||||
function isVirtualMac(mac) {
|
||||
if (typeof mac !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return VIRTUAL_MAC_PREFIXES.has(mac.toLowerCase().slice(0, 8));
|
||||
}
|
||||
|
||||
function serveFile(response, filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath);
|
||||
response.writeHead(200, {
|
||||
'Content-Type': contentTypeFor(filePath),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
response.end(content);
|
||||
} catch {
|
||||
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
response.end('Not found');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAdvertisedUrls,
|
||||
serveFile,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
function isRecord(value) {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function safeString(value, fallback = '') {
|
||||
return typeof value === 'string' && value.length ? value : fallback;
|
||||
}
|
||||
|
||||
function firstString(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cloneValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(cloneValue);
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
result[key] = cloneValue(entry);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function isValidPort(value) {
|
||||
return Number.isFinite(value) && value > 0 && value < 65536;
|
||||
}
|
||||
|
||||
function toStringId(value) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cloneValue,
|
||||
firstString,
|
||||
isRecord,
|
||||
isValidPort,
|
||||
safeString,
|
||||
toStringId,
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
const crypto = require('node:crypto');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
IPC_CHANNEL,
|
||||
REMOTE_COMMAND_REQUEST_CHANNEL,
|
||||
REMOTE_COMMAND_RESPONSE_CHANNEL,
|
||||
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS,
|
||||
REMOTE_GAME_STATUS_CHANNEL,
|
||||
REMOTE_INSTALLED_APPS_CHANNEL,
|
||||
} = require('./constants.cjs');
|
||||
const { writeInstallLog } = require('./logger.cjs');
|
||||
const { ensureBridge } = require('./runtime.cjs');
|
||||
const { installRendererScripts } = require('./renderer-scripts.cjs');
|
||||
const { safeString } = require('./utils.cjs');
|
||||
|
||||
function installWandRuntime(electron, options = {}) {
|
||||
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 pendingCommandResponses = globalThis.__wandRemoteBridgePendingCommandResponses || new Map();
|
||||
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
|
||||
globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses;
|
||||
|
||||
runtime.setHandler((request) => {
|
||||
let delivered = false;
|
||||
for (const sender of Array.from(boundRenderers)) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) {
|
||||
boundRenderers.delete(sender);
|
||||
continue;
|
||||
}
|
||||
|
||||
sender.send(IPC_CHANNEL.SET_VALUE, request);
|
||||
delivered = true;
|
||||
} catch (error) {
|
||||
boundRenderers.delete(sender);
|
||||
writeInstallLog('warn', 'Failed to forward set_value to renderer.', error);
|
||||
}
|
||||
}
|
||||
|
||||
return delivered;
|
||||
});
|
||||
|
||||
runtime.setCommandHandler(async (request) => {
|
||||
for (const sender of Array.from(boundRenderers)) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) {
|
||||
boundRenderers.delete(sender);
|
||||
continue;
|
||||
}
|
||||
|
||||
return await dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses);
|
||||
} catch (error) {
|
||||
writeInstallLog('warn', 'Failed to execute remote command in renderer.', error);
|
||||
}
|
||||
}
|
||||
|
||||
return buildRendererBridgeMissingResponse(request);
|
||||
});
|
||||
|
||||
installIpcHandlers(electron, runtime, boundRenderers, pendingCommandResponses);
|
||||
installRendererScripts(electron, runtime, {
|
||||
...options,
|
||||
panelRoot: options.panelRoot || path.dirname(__dirname),
|
||||
});
|
||||
writeInstallLog('info', 'Wand runtime hooks installed.');
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandResponses) {
|
||||
if (globalThis.__wandRemoteBridgeIpcInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.__wandRemoteBridgeIpcInstalled = true;
|
||||
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (_event, snapshot) => {
|
||||
runtime.sync(snapshot);
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle(REMOTE_INSTALLED_APPS_CHANNEL, (_event, snapshot) => {
|
||||
runtime.syncInstalledApps(snapshot);
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle(REMOTE_GAME_STATUS_CHANNEL, (_event, snapshot) => {
|
||||
runtime.syncGameStatus(snapshot);
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle(REMOTE_COMMAND_RESPONSE_CHANNEL, (_event, response) => {
|
||||
const requestId = safeString(response?.requestId);
|
||||
const pending = requestId ? pendingCommandResponses.get(requestId) : null;
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pending.resolve(response);
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle(IPC_CHANNEL.VALUE_CHANGED, (_event, change) => {
|
||||
runtime.valueChanged(change);
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle(IPC_CHANNEL.BIND_HANDLER, (event) => {
|
||||
if (event && event.sender) {
|
||||
boundRenderers.add(event.sender);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime.remoteUrl);
|
||||
}
|
||||
|
||||
function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = `remote_command_${typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : Date.now().toString(36)}`;
|
||||
const timer = setTimeout(() => {
|
||||
pendingCommandResponses.delete(requestId);
|
||||
reject(new Error('Renderer remote command timed out.'));
|
||||
}, REMOTE_COMMAND_RESPONSE_TIMEOUT_MS);
|
||||
|
||||
pendingCommandResponses.set(requestId, {
|
||||
resolve: (response) => {
|
||||
clearTimeout(timer);
|
||||
pendingCommandResponses.delete(requestId);
|
||||
resolve(response);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
pendingCommandResponses.delete(requestId);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
sender.send(REMOTE_COMMAND_REQUEST_CHANNEL, {
|
||||
...request,
|
||||
requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
pendingCommandResponses.delete(requestId);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildRendererBridgeMissingResponse(request) {
|
||||
return {
|
||||
ok: false,
|
||||
action: request?.action === 'stop' ? 'stop' : 'launch',
|
||||
gameId: typeof request?.gameId === 'string' ? request.gameId : null,
|
||||
titleId: typeof request?.titleId === 'string' ? request.titleId : null,
|
||||
error: {
|
||||
code: 'bridge_not_ready',
|
||||
message: 'The renderer command bridge is not ready yet.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
installWandRuntime,
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants.cjs');
|
||||
|
||||
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
function jsonMessage(type, payload, requestId = null) {
|
||||
return JSON.stringify({
|
||||
type,
|
||||
version: BRIDGE_PROTOCOL_VERSION,
|
||||
requestId,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
function makeFrame(opcode, payload) {
|
||||
const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
||||
const header = [];
|
||||
header.push(0x80 | (opcode & 0x0f));
|
||||
|
||||
if (source.length < 126) {
|
||||
header.push(source.length);
|
||||
return Buffer.concat([Buffer.from(header), source]);
|
||||
}
|
||||
|
||||
if (source.length < 65536) {
|
||||
const prefix = Buffer.from([header[0], 126, (source.length >> 8) & 0xff, source.length & 0xff]);
|
||||
return Buffer.concat([prefix, source]);
|
||||
}
|
||||
|
||||
const prefix = Buffer.alloc(10);
|
||||
prefix[0] = header[0];
|
||||
prefix[1] = 127;
|
||||
prefix.writeUInt32BE(0, 2);
|
||||
prefix.writeUInt32BE(source.length, 6);
|
||||
return Buffer.concat([prefix, source]);
|
||||
}
|
||||
|
||||
function sendText(client, text) {
|
||||
if (!client.closed) {
|
||||
client.socket.write(makeFrame(WS_OPCODE.TEXT, Buffer.from(text, 'utf8')));
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(client, type, payload, requestId = null) {
|
||||
sendText(client, jsonMessage(type, payload, requestId));
|
||||
}
|
||||
|
||||
function closeClient(client, code = 1000, reason = 'Closing') {
|
||||
if (client.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.closed = true;
|
||||
const reasonBuffer = Buffer.from(reason, 'utf8');
|
||||
const payload = Buffer.alloc(2 + reasonBuffer.length);
|
||||
payload.writeUInt16BE(code, 0);
|
||||
reasonBuffer.copy(payload, 2);
|
||||
client.socket.write(makeFrame(WS_OPCODE.CLOSE, payload));
|
||||
client.socket.end();
|
||||
}
|
||||
|
||||
function parseFrame(buffer) {
|
||||
if (buffer.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = buffer[0];
|
||||
const second = buffer[1];
|
||||
const fin = (first & 0x80) !== 0;
|
||||
const opcode = first & 0x0f;
|
||||
const masked = (second & 0x80) !== 0;
|
||||
let length = second & 0x7f;
|
||||
let offset = 2;
|
||||
|
||||
if (length === 126) {
|
||||
if (buffer.length < offset + 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
length = buffer.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
} else if (length === 127) {
|
||||
if (buffer.length < offset + 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const high = buffer.readUInt32BE(offset);
|
||||
const low = buffer.readUInt32BE(offset + 4);
|
||||
if (high !== 0) {
|
||||
throw new Error('Large websocket frames are not supported.');
|
||||
}
|
||||
|
||||
length = low;
|
||||
offset += 8;
|
||||
}
|
||||
|
||||
let mask = null;
|
||||
if (masked) {
|
||||
if (buffer.length < offset + 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
mask = buffer.subarray(offset, offset + 4);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
if (buffer.length < offset + length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = Buffer.from(buffer.subarray(offset, offset + length));
|
||||
if (masked && mask) {
|
||||
for (let index = 0; index < payload.length; index += 1) {
|
||||
payload[index] ^= mask[index % 4];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bytesConsumed: offset + length,
|
||||
fin,
|
||||
opcode,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function createAcceptKey(key) {
|
||||
return crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
closeClient,
|
||||
createAcceptKey,
|
||||
jsonMessage,
|
||||
makeFrame,
|
||||
parseFrame,
|
||||
sendJson,
|
||||
sendText,
|
||||
};
|
||||
Reference in New Issue
Block a user