mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-28 17:01:04 +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:
+3
-3
@@ -5,8 +5,8 @@ Local mobile-friendly web panel scaffold for Wand.
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Hosted access on the local machine:
|
||||
@@ -16,7 +16,7 @@ Hosted access on the local machine:
|
||||
Hosted access on the LAN:
|
||||
|
||||
```bash
|
||||
npm run dev:host
|
||||
pnpm run dev:host
|
||||
```
|
||||
|
||||
Then open the machine IP on port `4173`.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { build } from "esbuild"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import { dirname, resolve } from "node:path"
|
||||
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 bridgeOutfile = resolve(distRoot, "bridge.cjs")
|
||||
const rendererScriptsRoot = resolve(bridgeRoot, "scripts", "default")
|
||||
const rendererScriptsOutdir = resolve(distRoot, "renderer-scripts")
|
||||
|
||||
await build({
|
||||
banner: {
|
||||
js: "// Generated by bridge/build.mjs. Do not edit this bundle by hand.",
|
||||
},
|
||||
bundle: true,
|
||||
entryPoints: [bridgeEntryPoint],
|
||||
format: "cjs",
|
||||
legalComments: "none",
|
||||
minify: true,
|
||||
outfile: bridgeOutfile,
|
||||
platform: "node",
|
||||
target: "node16",
|
||||
})
|
||||
|
||||
const rendererEntries = (
|
||||
await readdir(rendererScriptsRoot, { withFileTypes: true })
|
||||
)
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".js"))
|
||||
.map((entry) => resolve(rendererScriptsRoot, entry.name))
|
||||
|
||||
if (rendererEntries.length === 0) {
|
||||
throw new Error(`No renderer script entries found in ${rendererScriptsRoot}`)
|
||||
}
|
||||
|
||||
await build({
|
||||
banner: {
|
||||
js: "// Generated by bridge/build.mjs. Do not edit this bundle by hand.",
|
||||
},
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints: rendererEntries,
|
||||
format: "iife",
|
||||
legalComments: "none",
|
||||
minify: true,
|
||||
outdir: rendererScriptsOutdir,
|
||||
platform: "browser",
|
||||
target: "es2020",
|
||||
})
|
||||
|
||||
console.log(`Built ${bridgeOutfile}`)
|
||||
console.log(
|
||||
`Built ${rendererEntries.length} renderer script(s) in ${rendererScriptsOutdir}`
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
import { installInstalledAppsSync } from "./installed-apps-sync/index.js"
|
||||
|
||||
installInstalledAppsSync(globalThis.WandEnhancer)
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
DATA_IMAGE_URL_PREFIX,
|
||||
IMAGE_FIELD_NAMES,
|
||||
MAX_IMAGE_URL_SEARCH_DEPTH,
|
||||
PROTOCOL_RELATIVE_IMAGE_URL_PATTERN,
|
||||
REMOTE_IMAGE_URL_PATTERN,
|
||||
SIDEBAR_GAME_ROW_CONTAINER_SELECTOR,
|
||||
SIDEBAR_GAME_ROW_IMAGE_SELECTOR,
|
||||
SIDEBAR_GAME_ROW_MORE_SELECTOR,
|
||||
SIDEBAR_GAME_ROW_TITLE_SELECTOR,
|
||||
SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN,
|
||||
STEAM_APP_ID_FIELD_NAMES,
|
||||
STEAM_APP_ID_PATTERN,
|
||||
STEAM_CONTAINER_FIELD_PATTERN,
|
||||
STEAM_CONTAINER_ID_FIELD_NAMES,
|
||||
STEAM_PLATFORM,
|
||||
WEMOD_STEAM_COMMUNITY_CDN_BASE_URL,
|
||||
} from "./constants.js"
|
||||
import { isRecord, safeString, toStringId } from "./runtime.js"
|
||||
|
||||
export function pickImageUrl(...values) {
|
||||
for (const value of values) {
|
||||
const imageUrl = normalizeImageUrl(value)
|
||||
if (imageUrl) {
|
||||
return imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeImageUrl(value, depth = 0) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim()
|
||||
if (
|
||||
REMOTE_IMAGE_URL_PATTERN.test(trimmed) ||
|
||||
trimmed.startsWith(DATA_IMAGE_URL_PREFIX)
|
||||
) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if (PROTOCOL_RELATIVE_IMAGE_URL_PATTERN.test(trimmed)) {
|
||||
return `https:${trimmed}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (depth > MAX_IMAGE_URL_SEARCH_DEPTH || !value) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const imageUrl = normalizeImageUrl(entry, depth + 1)
|
||||
if (imageUrl) {
|
||||
return imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const key of IMAGE_FIELD_NAMES) {
|
||||
const imageUrl = normalizeImageUrl(value[key], depth + 1)
|
||||
if (imageUrl) {
|
||||
return imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getSteamClientIconUrl(...values) {
|
||||
for (const value of values) {
|
||||
const steamAppId = toStringId(value)
|
||||
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
|
||||
return `${WEMOD_STEAM_COMMUNITY_CDN_BASE_URL}/${steamAppId}/client_icon/96.webp`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getSidebarGameRowClientIcons() {
|
||||
const byTitleId = new Map()
|
||||
const byTitleName = new Map()
|
||||
if (typeof document === "undefined") {
|
||||
return { byTitleId, byTitleName }
|
||||
}
|
||||
|
||||
const containers = document.querySelectorAll(
|
||||
SIDEBAR_GAME_ROW_CONTAINER_SELECTOR
|
||||
)
|
||||
for (const container of containers) {
|
||||
const imageElement = container.querySelector(
|
||||
SIDEBAR_GAME_ROW_IMAGE_SELECTOR
|
||||
)
|
||||
const titleElement = container.querySelector(
|
||||
SIDEBAR_GAME_ROW_TITLE_SELECTOR
|
||||
)
|
||||
const moreButton = container.querySelector(SIDEBAR_GAME_ROW_MORE_SELECTOR)
|
||||
if (!imageElement || !titleElement) {
|
||||
continue
|
||||
}
|
||||
|
||||
const backgroundImageUrl = getCssBackgroundImageUrl(
|
||||
safeString(imageElement.style?.backgroundImage) ||
|
||||
getComputedStyle(imageElement).backgroundImage
|
||||
)
|
||||
if (!backgroundImageUrl) {
|
||||
continue
|
||||
}
|
||||
|
||||
const tooltipId = safeString(
|
||||
moreButton?.getAttribute?.("data-tooltip-trigger-for")
|
||||
)
|
||||
const titleId =
|
||||
tooltipId.match(SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN)?.[1] ?? null
|
||||
const titleName = normalizeTitleMatchKey(titleElement.textContent)
|
||||
|
||||
if (titleId) {
|
||||
byTitleId.set(titleId, backgroundImageUrl)
|
||||
}
|
||||
|
||||
if (titleName) {
|
||||
byTitleName.set(titleName, backgroundImageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return { byTitleId, byTitleName }
|
||||
}
|
||||
|
||||
export function getSidebarGameRowClientIconUrl(
|
||||
sidebarIcons,
|
||||
titleId,
|
||||
...names
|
||||
) {
|
||||
const normalizedTitleId = toStringId(titleId)
|
||||
if (normalizedTitleId && sidebarIcons.byTitleId.has(normalizedTitleId)) {
|
||||
return sidebarIcons.byTitleId.get(normalizedTitleId) ?? null
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
const normalizedName = normalizeTitleMatchKey(name)
|
||||
if (normalizedName && sidebarIcons.byTitleName.has(normalizedName)) {
|
||||
return sidebarIcons.byTitleName.get(normalizedName) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findSteamAppId(...roots) {
|
||||
const seen = new Set()
|
||||
const queue = roots.map((value) => ({ value, depth: 0, steamContext: false }))
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()
|
||||
const value = current?.value
|
||||
const depth = current?.depth ?? 0
|
||||
const steamContext = current?.steamContext ?? false
|
||||
|
||||
if (!value || depth > MAX_IMAGE_URL_SEARCH_DEPTH || seen.has(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
const steamAppId = steamContext ? toStringId(value) : null
|
||||
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
|
||||
return steamAppId
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
queue.push({ value: entry, depth: depth + 1, steamContext })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
const steamAppId = getSteamAppIdFromEntry(key, entry, steamContext)
|
||||
if (steamAppId) {
|
||||
return steamAppId
|
||||
}
|
||||
|
||||
if (isRecord(entry) || Array.isArray(entry)) {
|
||||
queue.push({
|
||||
value: entry,
|
||||
depth: depth + 1,
|
||||
steamContext: steamContext || STEAM_CONTAINER_FIELD_PATTERN.test(key),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getInstalledAppSteamAppId(platform, sku) {
|
||||
if (safeString(platform).toLowerCase() !== STEAM_PLATFORM) {
|
||||
return null
|
||||
}
|
||||
|
||||
return sku
|
||||
}
|
||||
|
||||
function normalizeTitleMatchKey(value) {
|
||||
const normalized = safeString(value).trim().toLowerCase().replace(/\s+/g, " ")
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function getCssBackgroundImageUrl(value) {
|
||||
const backgroundImage = safeString(value)
|
||||
if (!backgroundImage || backgroundImage === "none") {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = backgroundImage.match(/url\((['"]?)(.*?)\1\)/i)
|
||||
if (!match?.[2]) {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalizeImageUrl(match[2])
|
||||
}
|
||||
|
||||
function getSteamAppIdFromEntry(key, entry, steamContext) {
|
||||
if (STEAM_APP_ID_FIELD_NAMES.has(key)) {
|
||||
const steamAppId = toStringId(entry)
|
||||
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
|
||||
return steamAppId
|
||||
}
|
||||
}
|
||||
|
||||
if (steamContext && STEAM_CONTAINER_ID_FIELD_NAMES.has(key)) {
|
||||
const steamAppId = toStringId(entry)
|
||||
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
|
||||
return steamAppId
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export const GLOBAL_FLAG = "__wandInstalledAppsSyncInstalled"
|
||||
export const BIND_CHANNEL = "wand-remote-set-handler-bind"
|
||||
export const SYNC_CHANNEL = "wand-remote-installed-apps"
|
||||
export const TRAINER_SNAPSHOT_CHANNEL = "wand-remote-sync"
|
||||
export const GAME_STATUS_CHANNEL = "wand-remote-game-status"
|
||||
export const COMMAND_REQUEST_CHANNEL = "wand-remote-command"
|
||||
export const COMMAND_RESPONSE_CHANNEL = "wand-remote-command-response"
|
||||
export const REMOTE_COMMAND_LAUNCH = "launch"
|
||||
export const REMOTE_COMMAND_STOP = "stop"
|
||||
export const REMOTE_COMMAND_TRIGGER = "remote"
|
||||
export const RETRY_DELAY_MS = 1000
|
||||
export const MAX_BOOTSTRAP_ATTEMPTS = 60
|
||||
export const SYNC_INTERVAL_MS = 15000
|
||||
export const OPTIONAL_SERVICES_RETRY_INTERVAL_MS = 1000
|
||||
export const FOLLOW_UP_SYNC_DELAY_MS = 2500
|
||||
export const UNAVAILABLE_TITLES_BATCH_SIZE = 250
|
||||
export const BOOTSTRAP_LOG_THROTTLE_ATTEMPTS = 5
|
||||
// Wand's webpack module exports the trainer-launch-request class under key `vO`.
|
||||
// Required so `trainerService.launch(req)` records `getMetadata(vO)` state in Wand. See AGENTS.md "Remote Play".
|
||||
export const TRAINER_LAUNCH_REQUEST_EXPORT_KEY = "vO"
|
||||
export const SNAPSHOT_ENTRY_KEY_PREFIX = Object.freeze({
|
||||
TITLE: "title:",
|
||||
GAME: "game:",
|
||||
APP: "app:",
|
||||
})
|
||||
export const GAME_LAUNCHED_EVENT = "game-launched"
|
||||
export const GAME_ENDED_EVENT = "game-ended"
|
||||
export const SYNTHETIC_SESSION_RUNNING_EVENT = "trainer-running"
|
||||
export const SYNTHETIC_SESSION_IDLE_EVENT = "trainer-idle"
|
||||
export const TRAINER_ENDED_EVENT = "trainer-ended"
|
||||
export const REMOTE_STOP_EVENT = "remote-stop"
|
||||
export const STEAM_PLATFORM = "steam"
|
||||
export const STEAM_APP_ID_PATTERN = /^\d+$/
|
||||
export const WEMOD_STEAM_COMMUNITY_CDN_BASE_URL =
|
||||
"https://api-cdn.wemod.com/steam_community"
|
||||
export const REMOTE_IMAGE_URL_PATTERN = /^https?:\/\//i
|
||||
export const PROTOCOL_RELATIVE_IMAGE_URL_PATTERN = /^\/\//
|
||||
export const DATA_IMAGE_URL_PREFIX = "data:image/"
|
||||
export const MAX_IMAGE_URL_SEARCH_DEPTH = 4
|
||||
export const SIDEBAR_GAME_ROW_CONTAINER_SELECTOR = ".sidebar-game-row-container"
|
||||
export const SIDEBAR_GAME_ROW_IMAGE_SELECTOR = ".sidebar-game-row-image"
|
||||
export const SIDEBAR_GAME_ROW_TITLE_SELECTOR = ".sidebar-game-row-title"
|
||||
export const SIDEBAR_GAME_ROW_MORE_SELECTOR = ".sidebar-game-row-more"
|
||||
export const SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN =
|
||||
/sidebar-game-row-(.+?)-more-button-tooltip/
|
||||
export const STEAM_APP_ID_FIELD_NAMES = new Set(["steamAppId", "steamAppID"])
|
||||
export const STEAM_CONTAINER_FIELD_PATTERN = /steam/i
|
||||
export const STEAM_CONTAINER_ID_FIELD_NAMES = new Set(["appId", "appID", "id"])
|
||||
export const LOG_PREFIX = "[wand-installed-apps-sync]"
|
||||
export const LOG_FILE_NAME = "wand-remote-installed-apps-sync.log"
|
||||
export const EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS = new Set(["standalone"])
|
||||
export const IMAGE_FIELD_NAMES = [
|
||||
"imageUrl",
|
||||
"imageURL",
|
||||
"iconUrl",
|
||||
"iconURL",
|
||||
"coverUrl",
|
||||
"coverURL",
|
||||
"thumbnailUrl",
|
||||
"thumbnailURL",
|
||||
"logoUrl",
|
||||
"logoURL",
|
||||
"headerImageUrl",
|
||||
"headerImageURL",
|
||||
"boxArtUrl",
|
||||
"boxartUrl",
|
||||
"posterUrl",
|
||||
"tileUrl",
|
||||
"capsuleUrl",
|
||||
"heroUrl",
|
||||
"backgroundUrl",
|
||||
"image",
|
||||
"icon",
|
||||
"cover",
|
||||
"thumbnail",
|
||||
"logo",
|
||||
"headerImage",
|
||||
"header",
|
||||
"boxArt",
|
||||
"boxart",
|
||||
"poster",
|
||||
"tile",
|
||||
"capsule",
|
||||
"hero",
|
||||
"background",
|
||||
"large",
|
||||
"medium",
|
||||
"small",
|
||||
"original",
|
||||
"source",
|
||||
"href",
|
||||
"uri",
|
||||
"url",
|
||||
"src",
|
||||
]
|
||||
@@ -0,0 +1,338 @@
|
||||
import {
|
||||
GAME_ENDED_EVENT,
|
||||
GAME_LAUNCHED_EVENT,
|
||||
GAME_STATUS_CHANNEL,
|
||||
SYNTHETIC_SESSION_IDLE_EVENT,
|
||||
SYNTHETIC_SESSION_RUNNING_EVENT,
|
||||
TRAINER_ENDED_EVENT,
|
||||
TRAINER_SNAPSHOT_CHANNEL,
|
||||
} from "./constants.js"
|
||||
import { isRecord, safeString, toStringId } from "./runtime.js"
|
||||
|
||||
export function createIdleGameSession() {
|
||||
return {
|
||||
state: "idle",
|
||||
event: "snapshot",
|
||||
processId: null,
|
||||
gameId: null,
|
||||
titleId: null,
|
||||
titleName: null,
|
||||
sessionDurationSeconds: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function createIdleTrainerStatus() {
|
||||
return {
|
||||
state: "idle",
|
||||
event: "snapshot",
|
||||
trainerId: null,
|
||||
displayName: null,
|
||||
gameId: null,
|
||||
titleId: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function installGameStatusSubscriptions(state) {
|
||||
let installed = false
|
||||
|
||||
if (
|
||||
state.gameLifecycleService &&
|
||||
!state.gameLifecycleSubscriptionsInstalled
|
||||
) {
|
||||
installed = installLifecycleSubscriptions(state) || installed
|
||||
}
|
||||
|
||||
if (
|
||||
state.trainerVisibilityService &&
|
||||
!state.trainerVisibilitySubscriptionInstalled
|
||||
) {
|
||||
state.currentRunningTrainer = normalizeRunningTrainerStatus(
|
||||
state.trainerVisibilityService.runningTrainer,
|
||||
"snapshot"
|
||||
)
|
||||
syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer)
|
||||
installed = installTrainerVisibilitySubscription(state) || installed
|
||||
state.trainerVisibilitySubscriptionInstalled = true
|
||||
}
|
||||
|
||||
if (
|
||||
state.trainerService &&
|
||||
!state.trainerEndedSubscriptionInstalled &&
|
||||
typeof state.trainerService.onTrainerEnded === "function"
|
||||
) {
|
||||
state.trainerService.onTrainerEnded(() => {
|
||||
clearTrainerSnapshot(state, TRAINER_ENDED_EVENT, true)
|
||||
})
|
||||
state.trainerEndedSubscriptionInstalled = true
|
||||
installed = true
|
||||
}
|
||||
|
||||
if (!installed) {
|
||||
return
|
||||
}
|
||||
|
||||
state.log(
|
||||
"info",
|
||||
"Game status hooks installed.",
|
||||
`lifecycle=${state.gameLifecycleSubscriptionsInstalled ? "yes" : "no"}, trainer=${state.trainerVisibilitySubscriptionInstalled ? "yes" : "no"}, trainerEnded=${state.trainerEndedSubscriptionInstalled ? "yes" : "no"}`
|
||||
)
|
||||
void syncGameStatus(state, true)
|
||||
}
|
||||
|
||||
export function clearTrainerSnapshot(state, reason, clearSession = false) {
|
||||
state.currentRunningTrainer = {
|
||||
...createIdleTrainerStatus(),
|
||||
event: reason,
|
||||
}
|
||||
|
||||
if (clearSession) {
|
||||
clearGameSession(state, reason)
|
||||
} else if (
|
||||
state.currentGameSession.state === "running" &&
|
||||
isSyntheticGameSessionEvent(state.currentGameSession.event)
|
||||
) {
|
||||
syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer)
|
||||
}
|
||||
|
||||
void syncGameStatus(state, true)
|
||||
if (!state.ipcRenderer) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
void state.ipcRenderer.invoke(TRAINER_SNAPSHOT_CHANNEL, null)
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Trainer snapshot clear IPC failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncGameStatus(state, force = false) {
|
||||
if (!state.ipcRenderer) {
|
||||
return false
|
||||
}
|
||||
|
||||
const snapshot = buildGameStatusSnapshot(state)
|
||||
const signature = makeGameStatusSignature(snapshot)
|
||||
if (!force && signature === state.lastGameStatusSignature) {
|
||||
return false
|
||||
}
|
||||
|
||||
state.lastGameStatusSignature = signature
|
||||
|
||||
try {
|
||||
await state.ipcRenderer.invoke(GAME_STATUS_CHANNEL, snapshot)
|
||||
state.log(
|
||||
"info",
|
||||
"Game status snapshot sent.",
|
||||
`session=${snapshot.session.state}/${snapshot.session.event}, trainer=${snapshot.trainer.state}/${snapshot.trainer.event}`
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"error",
|
||||
"Game status snapshot IPC failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function installLifecycleSubscriptions(state) {
|
||||
let installed = false
|
||||
|
||||
if (typeof state.gameLifecycleService.onGameLaunched === "function") {
|
||||
state.gameLifecycleService.onGameLaunched((event) => {
|
||||
state.currentGameSession = {
|
||||
state: "running",
|
||||
event: GAME_LAUNCHED_EVENT,
|
||||
processId:
|
||||
typeof event?.processId === "number" ? event.processId : null,
|
||||
gameId: toStringId(event?.gameId),
|
||||
titleId: toStringId(event?.titleId),
|
||||
titleName: safeString(event?.titleName),
|
||||
sessionDurationSeconds: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
}
|
||||
void syncGameStatus(state, true)
|
||||
})
|
||||
installed = true
|
||||
}
|
||||
|
||||
if (typeof state.gameLifecycleService.onGameEnded === "function") {
|
||||
state.gameLifecycleService.onGameEnded((event) => {
|
||||
clearGameSession(
|
||||
state,
|
||||
GAME_ENDED_EVENT,
|
||||
typeof event?.sessionDurationSeconds === "number"
|
||||
? event.sessionDurationSeconds
|
||||
: state.currentGameSession.sessionDurationSeconds
|
||||
)
|
||||
void syncGameStatus(state, true)
|
||||
})
|
||||
installed = true
|
||||
}
|
||||
|
||||
if (installed) {
|
||||
state.gameLifecycleSubscriptionsInstalled = true
|
||||
}
|
||||
|
||||
return installed
|
||||
}
|
||||
|
||||
function installTrainerVisibilitySubscription(state) {
|
||||
if (
|
||||
typeof state.trainerVisibilityService.onRunningTrainerChanged !== "function"
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
state.trainerVisibilityService.onRunningTrainerChanged((runningTrainer) => {
|
||||
state.currentRunningTrainer = normalizeRunningTrainerStatus(
|
||||
runningTrainer,
|
||||
runningTrainer ? "trainer-running" : "trainer-idle"
|
||||
)
|
||||
syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer)
|
||||
void syncGameStatus(state, true)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function normalizeRunningTrainerStatus(runningTrainer, event = "snapshot") {
|
||||
const info = isRecord(runningTrainer?.info)
|
||||
? runningTrainer.info
|
||||
: isRecord(runningTrainer)
|
||||
? runningTrainer
|
||||
: null
|
||||
if (!info) {
|
||||
return {
|
||||
...createIdleTrainerStatus(),
|
||||
event,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: "running",
|
||||
event,
|
||||
trainerId: toStringId(info.trainerId) || toStringId(info.id),
|
||||
displayName: safeString(
|
||||
info.displayName,
|
||||
info.gameName,
|
||||
info.titleName,
|
||||
info.title,
|
||||
info.name
|
||||
),
|
||||
gameId: toStringId(info.gameId),
|
||||
titleId: toStringId(info.titleId),
|
||||
}
|
||||
}
|
||||
|
||||
function syncGameSessionFromTrainerStatus(state, trainerStatus) {
|
||||
if (trainerStatus?.state === "running") {
|
||||
if (
|
||||
state.currentGameSession.state === "running" &&
|
||||
!isSyntheticGameSessionEvent(state.currentGameSession.event)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
state.currentGameSession = {
|
||||
state: "running",
|
||||
event: SYNTHETIC_SESSION_RUNNING_EVENT,
|
||||
processId: state.currentGameSession.processId,
|
||||
gameId: trainerStatus.gameId ?? state.currentGameSession.gameId,
|
||||
titleId: trainerStatus.titleId ?? state.currentGameSession.titleId,
|
||||
titleName:
|
||||
trainerStatus.displayName ?? state.currentGameSession.titleName,
|
||||
sessionDurationSeconds: null,
|
||||
startedAt:
|
||||
state.currentGameSession.state === "running" &&
|
||||
isSyntheticGameSessionEvent(state.currentGameSession.event)
|
||||
? state.currentGameSession.startedAt
|
||||
: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
state.currentGameSession.state !== "running" ||
|
||||
!isSyntheticGameSessionEvent(state.currentGameSession.event)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const startedAt = state.currentGameSession.startedAt
|
||||
const sessionDurationSeconds = startedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - new Date(startedAt).getTime()) / 1000)
|
||||
)
|
||||
: null
|
||||
|
||||
clearGameSession(state, SYNTHETIC_SESSION_IDLE_EVENT, sessionDurationSeconds)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function clearGameSession(
|
||||
state,
|
||||
event,
|
||||
sessionDurationSeconds = state.currentGameSession.sessionDurationSeconds
|
||||
) {
|
||||
state.currentGameSession = {
|
||||
state: "idle",
|
||||
event,
|
||||
processId: null,
|
||||
gameId: null,
|
||||
titleId: null,
|
||||
titleName: null,
|
||||
sessionDurationSeconds,
|
||||
startedAt: state.currentGameSession.startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function isSyntheticGameSessionEvent(event) {
|
||||
return (
|
||||
event === SYNTHETIC_SESSION_RUNNING_EVENT ||
|
||||
event === SYNTHETIC_SESSION_IDLE_EVENT
|
||||
)
|
||||
}
|
||||
|
||||
function buildGameStatusSnapshot(state) {
|
||||
return {
|
||||
instanceId: "wand-game-status",
|
||||
updatedAt: new Date().toISOString(),
|
||||
session: { ...state.currentGameSession },
|
||||
trainer: { ...state.currentRunningTrainer },
|
||||
}
|
||||
}
|
||||
|
||||
function makeGameStatusSignature(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("|")
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import {
|
||||
BIND_CHANNEL,
|
||||
BOOTSTRAP_LOG_THROTTLE_ATTEMPTS,
|
||||
COMMAND_REQUEST_CHANNEL,
|
||||
FOLLOW_UP_SYNC_DELAY_MS,
|
||||
GLOBAL_FLAG,
|
||||
MAX_BOOTSTRAP_ATTEMPTS,
|
||||
OPTIONAL_SERVICES_RETRY_INTERVAL_MS,
|
||||
RETRY_DELAY_MS,
|
||||
SYNC_CHANNEL,
|
||||
SYNC_INTERVAL_MS,
|
||||
} from "./constants.js"
|
||||
import {
|
||||
createIdleGameSession,
|
||||
createIdleTrainerStatus,
|
||||
installGameStatusSubscriptions,
|
||||
} from "./game-status.js"
|
||||
import {
|
||||
buildSnapshot,
|
||||
makeInstalledAppsSignature,
|
||||
refreshUnavailableTitles,
|
||||
resolveInstalledData,
|
||||
} from "./installed-data.js"
|
||||
import { createLogger } from "./logger.js"
|
||||
import { handleRemoteCommandRequest } from "./remote-commands.js"
|
||||
import {
|
||||
getAppRoot,
|
||||
getAureliaContainer,
|
||||
getRequire,
|
||||
getWebpackRequire,
|
||||
hasAppRoot,
|
||||
isRecord,
|
||||
summarizeAureliaSubtree,
|
||||
} from "./runtime.js"
|
||||
import {
|
||||
getInstalledAppsService,
|
||||
getStoreRef,
|
||||
hasMissingOptionalServices,
|
||||
resolveOptionalServices,
|
||||
} from "./services.js"
|
||||
|
||||
export function installInstalledAppsSync(WandEnhancer) {
|
||||
if (globalThis[GLOBAL_FLAG]) {
|
||||
return
|
||||
}
|
||||
|
||||
globalThis[GLOBAL_FLAG] = true
|
||||
|
||||
const state = createState(WandEnhancer)
|
||||
state.resolveRemoteCommandServices = () => resolveRemoteCommandServices(state)
|
||||
state.queueSync = (force = false) => queueSync(state, force)
|
||||
state.queueFollowUpSync = () => queueFollowUpSync(state)
|
||||
|
||||
state.log(
|
||||
"info",
|
||||
"Script loaded.",
|
||||
`logFile=${globalThis.__wandInstalledAppsSyncLogFile || "console-only"}`
|
||||
)
|
||||
retryBootstrap(state)
|
||||
}
|
||||
|
||||
function createState(WandEnhancer) {
|
||||
return {
|
||||
WandEnhancer,
|
||||
log: createLogger(WandEnhancer),
|
||||
lastSignature: null,
|
||||
lastGameStatusSignature: null,
|
||||
refreshTimer: null,
|
||||
followUpSyncTimer: null,
|
||||
pollTimer: null,
|
||||
optionalServicesTimer: null,
|
||||
bootstrapAttempts: 0,
|
||||
bridgeBound: false,
|
||||
refreshPatched: false,
|
||||
installedAppsService: null,
|
||||
gameLifecycleService: null,
|
||||
trainerVisibilityService: null,
|
||||
unavailableTitlesService: null,
|
||||
storeRef: null,
|
||||
ipcRenderer: null,
|
||||
lastBootstrapReason: null,
|
||||
gameLifecycleSubscriptionsInstalled: false,
|
||||
trainerVisibilitySubscriptionInstalled: false,
|
||||
trainerEndedSubscriptionInstalled: false,
|
||||
unavailableTitlesFetchKey: null,
|
||||
unavailableTitlesFetchPromise: null,
|
||||
unavailableTitlesById: {},
|
||||
trainerApiService: null,
|
||||
trainerService: null,
|
||||
trainerLaunchRequestCtor: null,
|
||||
commandListenerInstalled: false,
|
||||
missingOptionalServiceWarnings: new Set(),
|
||||
currentGameSession: createIdleGameSession(),
|
||||
currentRunningTrainer: createIdleTrainerStatus(),
|
||||
resolveRemoteCommandServices: null,
|
||||
queueSync: null,
|
||||
queueFollowUpSync: null,
|
||||
}
|
||||
}
|
||||
|
||||
function setBootstrapReason(state, reason) {
|
||||
if (
|
||||
reason === state.lastBootstrapReason &&
|
||||
state.bootstrapAttempts % BOOTSTRAP_LOG_THROTTLE_ATTEMPTS !== 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
state.lastBootstrapReason = reason
|
||||
state.log(
|
||||
"info",
|
||||
`Bootstrap waiting: ${reason}.`,
|
||||
`attempt=${state.bootstrapAttempts + 1}/${MAX_BOOTSTRAP_ATTEMPTS}`
|
||||
)
|
||||
}
|
||||
|
||||
function bindBridge(state) {
|
||||
if (state.bridgeBound || !state.ipcRenderer) {
|
||||
return
|
||||
}
|
||||
|
||||
state.bridgeBound = true
|
||||
|
||||
if (!state.commandListenerInstalled) {
|
||||
state.ipcRenderer.on(COMMAND_REQUEST_CHANNEL, (event, request) =>
|
||||
handleRemoteCommandRequest(state, event, request)
|
||||
)
|
||||
state.commandListenerInstalled = true
|
||||
state.log("info", "Bridge remote command handler installed.")
|
||||
}
|
||||
|
||||
try {
|
||||
void state.ipcRenderer.invoke(BIND_CHANNEL)
|
||||
state.log("info", "Bridge set-value handler bind requested.")
|
||||
} catch (error) {
|
||||
state.log("warn", "Bridge bind failed.", error?.stack || String(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function syncInstalledApps(state, force = false) {
|
||||
if (!state.ipcRenderer || (!state.installedAppsService && !state.storeRef)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const data = resolveInstalledData(state)
|
||||
if (!data) {
|
||||
if (state.installedAppsService) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Service resolved but installedApps is empty/undefined. Store fallback also unavailable."
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (Object.keys(data.rawInstalledApps).length > 0) {
|
||||
await refreshUnavailableTitles(state, data.rawInstalledApps, force)
|
||||
}
|
||||
|
||||
const snapshot = buildSnapshot(state)
|
||||
if (!snapshot) {
|
||||
state.log("warn", "Snapshot build returned nothing.")
|
||||
return false
|
||||
}
|
||||
|
||||
const signature = makeInstalledAppsSignature(snapshot)
|
||||
if (!force && signature === state.lastSignature) {
|
||||
return false
|
||||
}
|
||||
|
||||
state.lastSignature = signature
|
||||
|
||||
try {
|
||||
await state.ipcRenderer.invoke(SYNC_CHANNEL, snapshot)
|
||||
state.log(
|
||||
"info",
|
||||
"Installed apps snapshot sent.",
|
||||
`apps=${snapshot.apps.length}, catalogGames=${snapshot.diagnostics.catalogGames}, rawInstalledApps=${snapshot.diagnostics.rawInstalledApps}`
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"error",
|
||||
"Installed apps snapshot IPC failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function queueSync(state, force = false) {
|
||||
if (state.refreshTimer) {
|
||||
clearTimeout(state.refreshTimer)
|
||||
}
|
||||
|
||||
state.refreshTimer = setTimeout(() => {
|
||||
state.refreshTimer = null
|
||||
void syncInstalledApps(state, force)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function queueFollowUpSync(state) {
|
||||
if (state.followUpSyncTimer) {
|
||||
clearTimeout(state.followUpSyncTimer)
|
||||
}
|
||||
|
||||
state.followUpSyncTimer = setTimeout(() => {
|
||||
state.followUpSyncTimer = null
|
||||
void syncInstalledApps(state, true)
|
||||
}, FOLLOW_UP_SYNC_DELAY_MS)
|
||||
}
|
||||
|
||||
function patchRefreshApps(state) {
|
||||
if (
|
||||
!state.installedAppsService ||
|
||||
state.refreshPatched ||
|
||||
typeof state.installedAppsService.refreshApps !== "function"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const originalRefreshApps = state.installedAppsService.refreshApps.bind(
|
||||
state.installedAppsService
|
||||
)
|
||||
state.refreshPatched = true
|
||||
state.log("info", "refreshApps hook installed.")
|
||||
|
||||
state.installedAppsService.refreshApps = async (...args) => {
|
||||
const result = await originalRefreshApps(...args)
|
||||
state.log("info", "refreshApps completed; queueing installed apps sync.")
|
||||
queueSync(state, true)
|
||||
queueFollowUpSync(state)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemoteCommandServices(state) {
|
||||
const container = getAureliaContainer()
|
||||
const webpackRequire = getWebpackRequire()
|
||||
if (!container || !webpackRequire) {
|
||||
return false
|
||||
}
|
||||
|
||||
resolveRuntimeServices(state, container, webpackRequire)
|
||||
return true
|
||||
}
|
||||
|
||||
function resolveRuntimeServices(state, container, webpackRequire) {
|
||||
resolveOptionalServices(state, container, webpackRequire)
|
||||
installGameStatusSubscriptions(state)
|
||||
|
||||
if (!hasMissingOptionalServices(state)) {
|
||||
stopOptionalServicesRetry(state)
|
||||
}
|
||||
}
|
||||
|
||||
function stopOptionalServicesRetry(state) {
|
||||
if (!state.optionalServicesTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
clearInterval(state.optionalServicesTimer)
|
||||
state.optionalServicesTimer = null
|
||||
}
|
||||
|
||||
function startOptionalServicesRetry(state) {
|
||||
if (state.optionalServicesTimer || !hasMissingOptionalServices(state)) {
|
||||
return
|
||||
}
|
||||
|
||||
state.optionalServicesTimer = setInterval(() => {
|
||||
const container = getAureliaContainer()
|
||||
const webpackRequire = getWebpackRequire()
|
||||
if (container && webpackRequire) {
|
||||
resolveRuntimeServices(state, container, webpackRequire)
|
||||
}
|
||||
}, OPTIONAL_SERVICES_RETRY_INTERVAL_MS)
|
||||
|
||||
state.log(
|
||||
"info",
|
||||
"Optional service retry timer started.",
|
||||
`${OPTIONAL_SERVICES_RETRY_INTERVAL_MS}ms`
|
||||
)
|
||||
}
|
||||
|
||||
function bootstrap(state) {
|
||||
if (!hasAppRoot()) {
|
||||
setBootstrapReason(state, "app root not ready")
|
||||
return false
|
||||
}
|
||||
|
||||
if (!state.ipcRenderer && !resolveIpcRenderer(state)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const webpackRequire = getWebpackRequire()
|
||||
if (!webpackRequire) {
|
||||
setBootstrapReason(state, "webpack runtime not ready")
|
||||
return false
|
||||
}
|
||||
|
||||
const container = getAureliaContainer()
|
||||
if (!container) {
|
||||
logMissingContainer(state)
|
||||
setBootstrapReason(state, "Aurelia container not ready")
|
||||
return false
|
||||
}
|
||||
|
||||
state.log("info", "Aurelia container resolved.")
|
||||
|
||||
if (!state.storeRef) {
|
||||
state.storeRef = getStoreRef(state, container, webpackRequire)
|
||||
if (!state.storeRef) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Store reference unavailable; unsupported installed titles will be missing."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.installedAppsService = getInstalledAppsService(
|
||||
state,
|
||||
container,
|
||||
webpackRequire
|
||||
)
|
||||
if (!state.installedAppsService) {
|
||||
setBootstrapReason(state, "installed apps service not ready")
|
||||
return false
|
||||
}
|
||||
|
||||
resolveRuntimeServices(state, container, webpackRequire)
|
||||
startOptionalServicesRetry(state)
|
||||
|
||||
if (!isRecord(state.installedAppsService.installedApps)) {
|
||||
state.log(
|
||||
"info",
|
||||
"Service instance has no installedApps data yet; reading from store until refreshApps populates it."
|
||||
)
|
||||
if (!state.storeRef) {
|
||||
state.log("warn", "Store fallback also unavailable; will retry on poll.")
|
||||
}
|
||||
}
|
||||
|
||||
bindBridge(state)
|
||||
patchRefreshApps(state)
|
||||
queueSync(state, true)
|
||||
queueFollowUpSync(state)
|
||||
startPollTimer(state)
|
||||
|
||||
state.log("info", "Installed apps sync ready.")
|
||||
return true
|
||||
}
|
||||
|
||||
function resolveIpcRenderer(state) {
|
||||
const electron = getRequire()?.("electron")
|
||||
if (!electron?.ipcRenderer) {
|
||||
setBootstrapReason(state, "electron ipcRenderer not ready")
|
||||
return false
|
||||
}
|
||||
|
||||
state.ipcRenderer = electron.ipcRenderer
|
||||
state.log("info", "Electron ipcRenderer resolved.")
|
||||
return true
|
||||
}
|
||||
|
||||
function startPollTimer(state) {
|
||||
if (state.pollTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
state.pollTimer = setInterval(() => {
|
||||
const container = getAureliaContainer()
|
||||
const webpackRequire = getWebpackRequire()
|
||||
if (container && webpackRequire) {
|
||||
resolveRuntimeServices(state, container, webpackRequire)
|
||||
}
|
||||
void syncInstalledApps(state)
|
||||
}, SYNC_INTERVAL_MS)
|
||||
|
||||
state.log(
|
||||
"info",
|
||||
"Installed apps poll timer started.",
|
||||
`${SYNC_INTERVAL_MS}ms`
|
||||
)
|
||||
}
|
||||
|
||||
function logMissingContainer(state) {
|
||||
if (state.bootstrapAttempts % 10 !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const root = getAppRoot()
|
||||
const aureliaKeys = root
|
||||
? Object.getOwnPropertyNames(root)
|
||||
.filter((key) => key.startsWith("__") || key === "au")
|
||||
.join(", ")
|
||||
: "root=null"
|
||||
state.log(
|
||||
"warn",
|
||||
"Aurelia container not found.",
|
||||
`rootProps=${aureliaKeys || "(none)"}, subtree=${summarizeAureliaSubtree(root)}`
|
||||
)
|
||||
}
|
||||
|
||||
function retryBootstrap(state) {
|
||||
if (bootstrap(state)) {
|
||||
return
|
||||
}
|
||||
|
||||
state.bootstrapAttempts += 1
|
||||
if (state.bootstrapAttempts < MAX_BOOTSTRAP_ATTEMPTS) {
|
||||
setTimeout(() => retryBootstrap(state), RETRY_DELAY_MS)
|
||||
return
|
||||
}
|
||||
|
||||
state.log(
|
||||
"error",
|
||||
"Installed apps sync bootstrap exhausted.",
|
||||
state.lastBootstrapReason || "unknown reason"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import {
|
||||
EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS,
|
||||
SNAPSHOT_ENTRY_KEY_PREFIX,
|
||||
UNAVAILABLE_TITLES_BATCH_SIZE,
|
||||
} from "./constants.js"
|
||||
import {
|
||||
findSteamAppId,
|
||||
getInstalledAppSteamAppId,
|
||||
getSidebarGameRowClientIconUrl,
|
||||
getSidebarGameRowClientIcons,
|
||||
getSteamClientIconUrl,
|
||||
pickImageUrl,
|
||||
} from "./artwork.js"
|
||||
import {
|
||||
getBasename,
|
||||
isRecord,
|
||||
normalizeStringList,
|
||||
safeString,
|
||||
toStringId,
|
||||
} from "./runtime.js"
|
||||
|
||||
export function resolveInstalledData(state) {
|
||||
const storeState = getStoreState(state.storeRef)
|
||||
|
||||
if (isRecord(state.installedAppsService?.installedApps)) {
|
||||
return {
|
||||
rawInstalledApps: state.installedAppsService.installedApps,
|
||||
catalog: isRecord(state.installedAppsService.catalog)
|
||||
? state.installedAppsService.catalog
|
||||
: isRecord(storeState?.catalog)
|
||||
? storeState.catalog
|
||||
: {},
|
||||
installedGameVersions: isRecord(
|
||||
state.installedAppsService.installedVersions
|
||||
)
|
||||
? state.installedAppsService.installedVersions
|
||||
: isRecord(storeState?.installedGameVersions)
|
||||
? storeState.installedGameVersions
|
||||
: {},
|
||||
correlatedUnavailableTitles: getResolvedUnavailableTitles(
|
||||
state,
|
||||
storeState?.correlatedUnavailableTitles
|
||||
),
|
||||
source: "service",
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecord(storeState?.installedApps)) {
|
||||
return {
|
||||
rawInstalledApps: storeState.installedApps,
|
||||
catalog: isRecord(storeState.catalog) ? storeState.catalog : {},
|
||||
installedGameVersions: isRecord(storeState.installedGameVersions)
|
||||
? storeState.installedGameVersions
|
||||
: {},
|
||||
correlatedUnavailableTitles: getResolvedUnavailableTitles(
|
||||
state,
|
||||
storeState.correlatedUnavailableTitles
|
||||
),
|
||||
source: "store",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function refreshUnavailableTitles(
|
||||
state,
|
||||
rawInstalledApps,
|
||||
force = false
|
||||
) {
|
||||
if (!state.unavailableTitlesService) {
|
||||
return state.unavailableTitlesById
|
||||
}
|
||||
|
||||
const correlationIds = getCorrelationIdsForUnavailableTitles(rawInstalledApps)
|
||||
const fetchKey = correlationIds.join("\n")
|
||||
if (!fetchKey) {
|
||||
state.unavailableTitlesFetchKey = ""
|
||||
state.unavailableTitlesById = {}
|
||||
return state.unavailableTitlesById
|
||||
}
|
||||
|
||||
if (!force && fetchKey === state.unavailableTitlesFetchKey) {
|
||||
if (state.unavailableTitlesFetchPromise) {
|
||||
await state.unavailableTitlesFetchPromise
|
||||
}
|
||||
return state.unavailableTitlesById
|
||||
}
|
||||
|
||||
state.unavailableTitlesFetchKey = fetchKey
|
||||
state.unavailableTitlesFetchPromise = fetchUnavailableTitles(
|
||||
state,
|
||||
correlationIds
|
||||
)
|
||||
await state.unavailableTitlesFetchPromise
|
||||
return state.unavailableTitlesById
|
||||
}
|
||||
|
||||
export function buildSnapshot(state) {
|
||||
const data = resolveInstalledData(state)
|
||||
if (!data) {
|
||||
if (state.installedAppsService) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Service resolved but installedApps is empty/undefined. Store fallback also unavailable."
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const {
|
||||
rawInstalledApps,
|
||||
catalog,
|
||||
installedGameVersions,
|
||||
correlatedUnavailableTitles,
|
||||
source,
|
||||
} = data
|
||||
const catalogGames = isRecord(catalog.games) ? catalog.games : {}
|
||||
const catalogTitles = isRecord(catalog.titles) ? catalog.titles : {}
|
||||
const sidebarGameRowClientIcons = getSidebarGameRowClientIcons()
|
||||
const entriesByKey = new Map()
|
||||
let matchedCatalogGames = 0
|
||||
let matchedUnavailableGames = 0
|
||||
|
||||
state.log(
|
||||
"info",
|
||||
`Building snapshot from ${source}.`,
|
||||
`rawInstalledApps=${Object.keys(rawInstalledApps).length}, catalogGames=${Object.keys(catalogGames).length}, installedGameVersions=${Object.keys(installedGameVersions).length}, unavailableTitles=${Object.keys(correlatedUnavailableTitles).length}`
|
||||
)
|
||||
|
||||
for (const [gameId, versions] of Object.entries(installedGameVersions)) {
|
||||
if (!Array.isArray(versions)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const game = catalogGames[gameId]
|
||||
if (!isRecord(game)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const preferredApp = pickPreferredInstalledApp(
|
||||
rawInstalledApps,
|
||||
getCatalogGameCorrelationIds(game, versions)
|
||||
)
|
||||
if (!preferredApp) {
|
||||
continue
|
||||
}
|
||||
|
||||
const titleId = toStringId(game.titleId)
|
||||
const title = titleId
|
||||
? catalogTitles[titleId] || catalogTitles[game.titleId] || null
|
||||
: null
|
||||
const sidebarClientIconUrl = getSidebarGameRowClientIconUrl(
|
||||
sidebarGameRowClientIcons,
|
||||
titleId,
|
||||
title?.name,
|
||||
title?.displayName,
|
||||
game.displayName,
|
||||
game.title,
|
||||
game.name,
|
||||
preferredApp.displayName
|
||||
)
|
||||
|
||||
upsertSnapshotEntry(entriesByKey, {
|
||||
...preferredApp,
|
||||
displayName: safeString(
|
||||
title?.name,
|
||||
title?.displayName,
|
||||
game.displayName,
|
||||
game.title,
|
||||
game.name,
|
||||
preferredApp.displayName,
|
||||
gameId
|
||||
),
|
||||
imageUrl: pickImageUrlForTitle(title, game, preferredApp, sidebarClientIconUrl, versions),
|
||||
gameId: String(gameId),
|
||||
titleId,
|
||||
})
|
||||
matchedCatalogGames += 1
|
||||
}
|
||||
|
||||
for (const unavailableTitle of Object.values(correlatedUnavailableTitles)) {
|
||||
if (!isRecord(unavailableTitle) || !Array.isArray(unavailableTitle.games)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const titleId = toStringId(unavailableTitle.id)
|
||||
for (const game of unavailableTitle.games) {
|
||||
if (!isRecord(game) || !Array.isArray(game.correlationIds)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const preferredApp = pickPreferredInstalledApp(
|
||||
rawInstalledApps,
|
||||
game.correlationIds
|
||||
)
|
||||
if (!preferredApp) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sidebarClientIconUrl = getSidebarGameRowClientIconUrl(
|
||||
sidebarGameRowClientIcons,
|
||||
titleId,
|
||||
unavailableTitle.name,
|
||||
game.name,
|
||||
preferredApp.displayName
|
||||
)
|
||||
upsertSnapshotEntry(entriesByKey, {
|
||||
...preferredApp,
|
||||
displayName: safeString(
|
||||
unavailableTitle.name,
|
||||
game.name,
|
||||
preferredApp.displayName,
|
||||
preferredApp.correlationId
|
||||
),
|
||||
imageUrl: pickImageUrlForTitle(unavailableTitle, game, preferredApp, sidebarClientIconUrl),
|
||||
gameId: toStringId(game.id),
|
||||
titleId,
|
||||
})
|
||||
matchedUnavailableGames += 1
|
||||
}
|
||||
}
|
||||
|
||||
const apps = Array.from(entriesByKey.values()).sort(compareSnapshotEntries)
|
||||
|
||||
return {
|
||||
instanceId: "wand-installed-apps",
|
||||
updatedAt: new Date().toISOString(),
|
||||
apps,
|
||||
diagnostics: {
|
||||
catalogGames: Object.keys(catalogGames).length,
|
||||
catalogTitles: Object.keys(catalogTitles).length,
|
||||
installedGameVersions: Object.keys(installedGameVersions).length,
|
||||
correlatedUnavailableTitles: Object.keys(correlatedUnavailableTitles)
|
||||
.length,
|
||||
matchedCatalogGames,
|
||||
matchedUnavailableGames,
|
||||
myGames: apps.length,
|
||||
rawInstalledApps: Object.keys(rawInstalledApps).length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function makeInstalledAppsSignature(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")
|
||||
}
|
||||
|
||||
export function toInstalledAppRecord(correlationId, app) {
|
||||
if (
|
||||
!isRecord(app) ||
|
||||
typeof correlationId !== "string" ||
|
||||
!correlationId.trim()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [fallbackPlatform, fallbackSku] = correlationId.split(":")
|
||||
const platform = safeString(app.platform, fallbackPlatform)
|
||||
const sku = safeString(app.sku, fallbackSku)
|
||||
if (!platform || !sku) {
|
||||
return null
|
||||
}
|
||||
|
||||
const location = typeof app.location === "string" ? app.location : ""
|
||||
const alternateLocations = normalizeStringList(app.alternateLocations)
|
||||
|
||||
return {
|
||||
platform,
|
||||
sku,
|
||||
correlationId,
|
||||
displayName: safeString(
|
||||
app.displayName,
|
||||
app.titleName,
|
||||
app.gameName,
|
||||
app.name,
|
||||
getBasename(location),
|
||||
correlationId
|
||||
),
|
||||
location,
|
||||
alternateLocations,
|
||||
imageUrl: pickImageUrl(
|
||||
app.imageUrl,
|
||||
app.iconUrl,
|
||||
app.coverUrl,
|
||||
app.thumbnailUrl,
|
||||
app.logoUrl,
|
||||
app.headerImageUrl,
|
||||
app.icon,
|
||||
app.images,
|
||||
app.assets,
|
||||
getSteamClientIconUrl(
|
||||
findSteamAppId(app),
|
||||
getInstalledAppSteamAppId(platform, sku)
|
||||
)
|
||||
),
|
||||
platformLastPlayedTimestamp:
|
||||
typeof app.platformLastPlayedTimestamp === "number"
|
||||
? app.platformLastPlayedTimestamp
|
||||
: null,
|
||||
platformTotalPlaytimeMinutes:
|
||||
typeof app.platformTotalPlaytimeMinutes === "number"
|
||||
? app.platformTotalPlaytimeMinutes
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function compareInstalledAppRecords(left, right) {
|
||||
const lastPlayedDiff =
|
||||
(right.platformLastPlayedTimestamp ?? 0) -
|
||||
(left.platformLastPlayedTimestamp ?? 0)
|
||||
if (lastPlayedDiff !== 0) {
|
||||
return lastPlayedDiff
|
||||
}
|
||||
|
||||
const playtimeDiff =
|
||||
(right.platformTotalPlaytimeMinutes ?? 0) -
|
||||
(left.platformTotalPlaytimeMinutes ?? 0)
|
||||
if (playtimeDiff !== 0) {
|
||||
return playtimeDiff
|
||||
}
|
||||
|
||||
return compareByIdentity(left, right)
|
||||
}
|
||||
|
||||
export function getInstalledVersionsForGame(gameId, data) {
|
||||
const versions = Array.isArray(data?.installedGameVersions?.[gameId])
|
||||
? data.installedGameVersions[gameId]
|
||||
: []
|
||||
return Array.from(
|
||||
new Set(
|
||||
versions
|
||||
.map((entry) => entry?.version)
|
||||
.filter(
|
||||
(entry) => typeof entry === "string" || typeof entry === "number"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getStoreState(storeRef) {
|
||||
const state =
|
||||
typeof storeRef?.state?.getValue === "function"
|
||||
? storeRef.state.getValue()
|
||||
: null
|
||||
return isRecord(state) ? state : null
|
||||
}
|
||||
|
||||
function getResolvedUnavailableTitles(state, liveTitles) {
|
||||
const liveCount = isRecord(liveTitles) ? Object.keys(liveTitles).length : 0
|
||||
return liveCount > 0 ? liveTitles : state.unavailableTitlesById
|
||||
}
|
||||
|
||||
function getCorrelationIdsForUnavailableTitles(rawInstalledApps) {
|
||||
return Object.entries(rawInstalledApps)
|
||||
.filter(
|
||||
([, app]) =>
|
||||
isRecord(app) &&
|
||||
!EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS.has(safeString(app.platform))
|
||||
)
|
||||
.map(([correlationId]) => correlationId)
|
||||
.sort()
|
||||
}
|
||||
|
||||
async function fetchUnavailableTitles(state, correlationIds) {
|
||||
const nextTitlesById = {}
|
||||
|
||||
try {
|
||||
for (
|
||||
let index = 0;
|
||||
index < correlationIds.length;
|
||||
index += UNAVAILABLE_TITLES_BATCH_SIZE
|
||||
) {
|
||||
const batch = correlationIds.slice(
|
||||
index,
|
||||
index + UNAVAILABLE_TITLES_BATCH_SIZE
|
||||
)
|
||||
const response =
|
||||
await state.unavailableTitlesService.getUnavailableTitlesByCorrelationIds(
|
||||
batch
|
||||
)
|
||||
for (const title of normalizeUnavailableTitlesResponse(response)) {
|
||||
nextTitlesById[title.id] = title
|
||||
}
|
||||
}
|
||||
|
||||
state.unavailableTitlesById = nextTitlesById
|
||||
state.log(
|
||||
"info",
|
||||
"Unavailable titles refreshed.",
|
||||
`correlationIds=${correlationIds.length}, titles=${Object.keys(nextTitlesById).length}`
|
||||
)
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Unavailable titles refresh failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
} finally {
|
||||
state.unavailableTitlesFetchPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUnavailableTitlesResponse(value) {
|
||||
const titles = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(value?.data)
|
||||
? value.data
|
||||
: []
|
||||
return titles.map(normalizeUnavailableTitle).filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeUnavailableTitle(title) {
|
||||
if (!isRecord(title)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const titleId = toStringId(title.id ?? title.titleId)
|
||||
if (!titleId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const games = Array.isArray(title.games)
|
||||
? title.games.map(normalizeUnavailableTitleGame).filter(Boolean)
|
||||
: []
|
||||
if (games.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...title,
|
||||
id: titleId,
|
||||
name: safeString(title.name, title.titleName, titleId),
|
||||
games,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUnavailableTitleGame(game) {
|
||||
if (!isRecord(game)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const gameId = toStringId(game.id ?? game.gameId)
|
||||
const correlationIds = normalizeStringList(game.correlationIds)
|
||||
if (!gameId || correlationIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...game,
|
||||
id: gameId,
|
||||
platformId: safeString(game.platformId, "unknown"),
|
||||
correlationIds,
|
||||
flags: typeof game.flags === "number" ? game.flags : 0,
|
||||
name: safeString(game.name, game.titleName, game.title, gameId),
|
||||
}
|
||||
}
|
||||
|
||||
function getCatalogGameCorrelationIds(game, versions) {
|
||||
const correlationIds = []
|
||||
|
||||
if (Array.isArray(game.correlationIds)) {
|
||||
for (const correlationId of game.correlationIds) {
|
||||
if (typeof correlationId === "string" && correlationId.trim()) {
|
||||
correlationIds.push(correlationId.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const version of versions) {
|
||||
if (
|
||||
typeof version?.correlationId === "string" &&
|
||||
version.correlationId.trim()
|
||||
) {
|
||||
correlationIds.push(version.correlationId.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return correlationIds
|
||||
}
|
||||
|
||||
function pickPreferredInstalledApp(rawInstalledApps, correlationIds) {
|
||||
const candidates = Array.from(new Set(correlationIds))
|
||||
.map((correlationId) =>
|
||||
toInstalledAppRecord(correlationId, rawInstalledApps[correlationId])
|
||||
)
|
||||
.filter(Boolean)
|
||||
.sort(compareInstalledAppRecords)
|
||||
|
||||
return candidates[0] || null
|
||||
}
|
||||
|
||||
function upsertSnapshotEntry(entriesByKey, entry) {
|
||||
const key = getSnapshotEntryKey(entry)
|
||||
const current = entriesByKey.get(key)
|
||||
if (!current || compareSnapshotEntries(entry, current) < 0) {
|
||||
entriesByKey.set(key, entry)
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshotEntryKey(entry) {
|
||||
if (entry.titleId) {
|
||||
return `${SNAPSHOT_ENTRY_KEY_PREFIX.TITLE}${entry.titleId}`
|
||||
}
|
||||
|
||||
if (entry.gameId) {
|
||||
return `${SNAPSHOT_ENTRY_KEY_PREFIX.GAME}${entry.gameId}`
|
||||
}
|
||||
|
||||
return `${SNAPSHOT_ENTRY_KEY_PREFIX.APP}${entry.correlationId}`
|
||||
}
|
||||
|
||||
function compareSnapshotEntries(left, right) {
|
||||
const lastPlayedDiff =
|
||||
(right.platformLastPlayedTimestamp ?? 0) -
|
||||
(left.platformLastPlayedTimestamp ?? 0)
|
||||
if (lastPlayedDiff !== 0) {
|
||||
return lastPlayedDiff
|
||||
}
|
||||
|
||||
const playtimeDiff =
|
||||
(right.platformTotalPlaytimeMinutes ?? 0) -
|
||||
(left.platformTotalPlaytimeMinutes ?? 0)
|
||||
if (playtimeDiff !== 0) {
|
||||
return playtimeDiff
|
||||
}
|
||||
|
||||
return compareByIdentity(left, right)
|
||||
}
|
||||
|
||||
function compareByIdentity(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 pickImageUrlForTitle(title, game, preferredApp, sidebarClientIconUrl, versions) {
|
||||
const steamRoots = versions !== undefined
|
||||
? [title, game, versions, preferredApp]
|
||||
: [title, game, preferredApp]
|
||||
|
||||
return pickImageUrl(
|
||||
title?.imageUrl,
|
||||
title?.iconUrl,
|
||||
title?.coverUrl,
|
||||
title?.thumbnailUrl,
|
||||
title?.logoUrl,
|
||||
title?.headerImageUrl,
|
||||
getSteamClientIconUrl(
|
||||
findSteamAppId(...steamRoots),
|
||||
getInstalledAppSteamAppId(preferredApp.platform, preferredApp.sku)
|
||||
),
|
||||
sidebarClientIconUrl,
|
||||
title?.images,
|
||||
title?.assets,
|
||||
game.imageUrl,
|
||||
game.iconUrl,
|
||||
game.coverUrl,
|
||||
game.thumbnailUrl,
|
||||
game.logoUrl,
|
||||
game.headerImageUrl,
|
||||
game.images,
|
||||
game.assets,
|
||||
preferredApp.imageUrl
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { LOG_FILE_NAME, LOG_PREFIX } from "./constants.js"
|
||||
import { getRequire } from "./runtime.js"
|
||||
|
||||
export function createLogger(WandEnhancer) {
|
||||
let filePath = null
|
||||
|
||||
try {
|
||||
const require = getRequire()
|
||||
const os = require?.("node:os")
|
||||
const path = require?.("node:path")
|
||||
if (os && path) {
|
||||
filePath = path.join(os.tmpdir(), LOG_FILE_NAME)
|
||||
globalThis.__wandInstalledAppsSyncLogFile = filePath
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
return function log(level, message, detail) {
|
||||
const method =
|
||||
level === "error" ? "error" : level === "warn" ? "warn" : "info"
|
||||
const line = `[${new Date().toISOString()}] [${level}] ${message}${detail ? ` :: ${detail}` : ""}`
|
||||
|
||||
try {
|
||||
console[method](LOG_PREFIX, message, detail || "")
|
||||
} catch (error) {}
|
||||
|
||||
try {
|
||||
if (WandEnhancer?.log) {
|
||||
WandEnhancer.log(`${LOG_PREFIX} ${message}`, detail || "")
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
writeFile(filePath, line)
|
||||
}
|
||||
}
|
||||
|
||||
function writeFile(filePath, line) {
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const require = getRequire()
|
||||
const fs = require?.("node:fs")
|
||||
fs?.appendFileSync(filePath, `${line}\n`)
|
||||
} catch (error) {}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
COMMAND_RESPONSE_CHANNEL,
|
||||
REMOTE_COMMAND_LAUNCH,
|
||||
REMOTE_COMMAND_STOP,
|
||||
REMOTE_COMMAND_TRIGGER,
|
||||
REMOTE_STOP_EVENT,
|
||||
} from "./constants.js"
|
||||
import { clearTrainerSnapshot, syncGameStatus } from "./game-status.js"
|
||||
import {
|
||||
compareInstalledAppRecords,
|
||||
getInstalledVersionsForGame,
|
||||
resolveInstalledData,
|
||||
toInstalledAppRecord,
|
||||
} from "./installed-data.js"
|
||||
import {
|
||||
getPreferredLocale,
|
||||
isRecord,
|
||||
safeString,
|
||||
toStringId,
|
||||
} from "./runtime.js"
|
||||
|
||||
export function handleRemoteCommandRequest(state, _event, request) {
|
||||
void (async () => {
|
||||
let response
|
||||
|
||||
if (request?.action === REMOTE_COMMAND_LAUNCH) {
|
||||
response = await executeRemoteLaunchCommand(state, request)
|
||||
} else if (request?.action === REMOTE_COMMAND_STOP) {
|
||||
response = await executeRemoteStopCommand(state, request)
|
||||
} else {
|
||||
response = buildCommandResponse(request, false, {
|
||||
code: "invalid_command",
|
||||
message: "Unknown remote command.",
|
||||
})
|
||||
}
|
||||
|
||||
await sendRemoteCommandResponse(state, response)
|
||||
})()
|
||||
}
|
||||
|
||||
function buildCommandResponse(request, ok, error = null) {
|
||||
const response = {
|
||||
requestId: safeString(request?.requestId),
|
||||
ok,
|
||||
action:
|
||||
request?.action === REMOTE_COMMAND_STOP
|
||||
? REMOTE_COMMAND_STOP
|
||||
: REMOTE_COMMAND_LAUNCH,
|
||||
gameId: toStringId(request?.gameId),
|
||||
titleId: toStringId(request?.titleId),
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
return response
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeRemoteLaunchCommand(state, request) {
|
||||
const gameId = toStringId(request?.gameId)
|
||||
if (!gameId) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "invalid_game",
|
||||
message: "A game id is required to launch a trainer.",
|
||||
})
|
||||
}
|
||||
|
||||
if (!state.resolveRemoteCommandServices()) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "bridge_not_ready",
|
||||
message: "The Wand renderer container is not ready yet.",
|
||||
})
|
||||
}
|
||||
|
||||
if (!state.trainerService) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "trainer_service_missing",
|
||||
message: "The Wand trainer service is not available yet.",
|
||||
})
|
||||
}
|
||||
|
||||
if (!state.trainerLaunchRequestCtor) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "trainer_launch_missing",
|
||||
message:
|
||||
"The Wand trainer launch request constructor is not available yet.",
|
||||
})
|
||||
}
|
||||
|
||||
const data = resolveInstalledData(state)
|
||||
if (!data) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "installations_missing",
|
||||
message: "Installed game data is not available yet.",
|
||||
})
|
||||
}
|
||||
|
||||
const launchInfo = getLaunchInfoForGame(gameId, data)
|
||||
if (!isRecord(launchInfo.app)) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "game_not_installed",
|
||||
message: "Wand could not resolve a preferred installation for this game.",
|
||||
})
|
||||
}
|
||||
|
||||
const trainerInfo = await resolveTrainerInfoForGame(state, gameId, data)
|
||||
if (!trainerInfo) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "trainer_not_found",
|
||||
message: "Wand could not find a compatible trainer for this game.",
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const launchRequest = new state.trainerLaunchRequestCtor(
|
||||
trainerInfo,
|
||||
launchInfo.app,
|
||||
launchInfo.version,
|
||||
REMOTE_COMMAND_TRIGGER
|
||||
)
|
||||
await state.trainerService.launch(launchRequest)
|
||||
state.queueSync(true)
|
||||
state.queueFollowUpSync()
|
||||
void syncGameStatus(state, true)
|
||||
return buildCommandResponse(request, true)
|
||||
} catch (error) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "launch_failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to launch the trainer.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function executeRemoteStopCommand(state, request) {
|
||||
if (!state.resolveRemoteCommandServices()) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "bridge_not_ready",
|
||||
message: "The Wand renderer container is not ready yet.",
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
!state.trainerService ||
|
||||
typeof state.trainerService.endTrainer !== "function"
|
||||
) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "trainer_service_missing",
|
||||
message: "The Wand trainer service is not available yet.",
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
!state.trainerService.trainer &&
|
||||
state.currentRunningTrainer.state !== "running"
|
||||
) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "no_active_trainer",
|
||||
message: "No trainer is running right now.",
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await state.trainerService.endTrainer()
|
||||
clearTrainerSnapshot(state, REMOTE_STOP_EVENT, true)
|
||||
return buildCommandResponse(request, true)
|
||||
} catch (error) {
|
||||
return buildCommandResponse(request, false, {
|
||||
code: "stop_failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to stop the running trainer.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getLaunchInfoForGame(gameId, data) {
|
||||
const versions = Array.isArray(data?.installedGameVersions?.[gameId])
|
||||
? data.installedGameVersions[gameId]
|
||||
: []
|
||||
const game = isRecord(data?.catalog?.games?.[gameId])
|
||||
? data.catalog.games[gameId]
|
||||
: null
|
||||
const candidates = []
|
||||
|
||||
if (Array.isArray(game?.correlationIds)) {
|
||||
for (const correlationId of game.correlationIds) {
|
||||
if (typeof correlationId === "string" && correlationId.trim()) {
|
||||
candidates.push({ correlationId: correlationId.trim(), version: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const versionEntry of versions) {
|
||||
if (
|
||||
typeof versionEntry?.correlationId === "string" &&
|
||||
versionEntry.correlationId.trim()
|
||||
) {
|
||||
candidates.push({
|
||||
correlationId: versionEntry.correlationId.trim(),
|
||||
version: versionEntry.version ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rankedCandidates = Array.from(
|
||||
new Map(
|
||||
candidates.map((candidate) => [candidate.correlationId, candidate])
|
||||
).values()
|
||||
)
|
||||
.map((candidate) => normalizeLaunchCandidate(candidate, data))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) =>
|
||||
compareInstalledAppRecords(left.normalizedApp, right.normalizedApp)
|
||||
)
|
||||
|
||||
if (!rankedCandidates[0]) {
|
||||
return { app: null, version: null }
|
||||
}
|
||||
|
||||
return {
|
||||
app: rankedCandidates[0].app,
|
||||
version: rankedCandidates[0].version,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTrainerInfoForGame(state, gameId, data) {
|
||||
if (!state.trainerApiService) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const localTrainer = unwrapTrainerInfo(
|
||||
await state.trainerApiService.getLatestLocalTrainerForGame(gameId)
|
||||
)
|
||||
if (localTrainer) {
|
||||
return localTrainer
|
||||
}
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Local trainer lookup failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return unwrapTrainerInfo(
|
||||
await state.trainerApiService.getMostCompatibleTrainerForGame(
|
||||
gameId,
|
||||
getPreferredLocale(),
|
||||
getInstalledVersionsForGame(gameId, data),
|
||||
false
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Compatible trainer lookup failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLaunchCandidate(candidate, data) {
|
||||
const app = data?.rawInstalledApps?.[candidate.correlationId]
|
||||
const normalizedApp = toInstalledAppRecord(candidate.correlationId, app)
|
||||
if (!normalizedApp || !isRecord(app)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
version: candidate.version,
|
||||
normalizedApp,
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapTrainerInfo(value) {
|
||||
if (isRecord(value?.trainer)) {
|
||||
return value.trainer
|
||||
}
|
||||
|
||||
return isRecord(value) ? value : null
|
||||
}
|
||||
|
||||
async function sendRemoteCommandResponse(state, response) {
|
||||
if (!state.ipcRenderer) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await state.ipcRenderer.invoke(COMMAND_RESPONSE_CHANNEL, response)
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Remote command response IPC failed.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
export function isRecord(value) {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
export function getRequire() {
|
||||
return (
|
||||
globalThis.require ||
|
||||
(typeof window !== "undefined" ? window.require : null)
|
||||
)
|
||||
}
|
||||
|
||||
export function safeString(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function getWebpackRequire() {
|
||||
const chunk = globalThis.webpackChunkWeMod
|
||||
if (!Array.isArray(chunk)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof chunk.__wandWebpackRequire === "function") {
|
||||
return chunk.__wandWebpackRequire
|
||||
}
|
||||
|
||||
let resolvedRequire = null
|
||||
chunk.push([
|
||||
[`wand-enhancer-${Date.now()}`],
|
||||
{},
|
||||
(webpackRequire) => {
|
||||
resolvedRequire = webpackRequire
|
||||
},
|
||||
])
|
||||
|
||||
if (typeof resolvedRequire === "function") {
|
||||
chunk.__wandWebpackRequire = resolvedRequire
|
||||
}
|
||||
|
||||
return resolvedRequire
|
||||
}
|
||||
|
||||
export function getAppRoot() {
|
||||
return (
|
||||
document.getElementById("root") ||
|
||||
document.querySelector("[aurelia-app]") ||
|
||||
document.querySelector("root")
|
||||
)
|
||||
}
|
||||
|
||||
export function hasAppRoot() {
|
||||
return Boolean(getAppRoot())
|
||||
}
|
||||
|
||||
export function getAureliaContainer() {
|
||||
const root = getAppRoot()
|
||||
const rootContainer = getContainerFromSubtree(root)
|
||||
if (rootContainer) {
|
||||
return rootContainer
|
||||
}
|
||||
|
||||
const bodyContainer = getContainerFromElement(document.body)
|
||||
if (bodyContainer) {
|
||||
return bodyContainer
|
||||
}
|
||||
|
||||
if (isRecord(globalThis.aurelia) && globalThis.aurelia.container) {
|
||||
return globalThis.aurelia.container
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function summarizeAureliaSubtree(root) {
|
||||
if (!root) {
|
||||
return "root=null"
|
||||
}
|
||||
|
||||
let elementsWithAu = 0
|
||||
let controllerEntries = 0
|
||||
let namedAuEntries = 0
|
||||
|
||||
function inspectElement(element) {
|
||||
if (!isRecord(element?.au)) {
|
||||
return
|
||||
}
|
||||
|
||||
elementsWithAu += 1
|
||||
|
||||
if (element.au.controller) {
|
||||
controllerEntries += 1
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(element.au)) {
|
||||
if (key !== "controller" && isRecord(value)) {
|
||||
namedAuEntries += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inspectElement(root)
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
|
||||
let element = walker.nextNode()
|
||||
while (element) {
|
||||
inspectElement(element)
|
||||
element = walker.nextNode()
|
||||
}
|
||||
|
||||
return `elementsWithAu=${elementsWithAu}, controllerEntries=${controllerEntries}, namedAuEntries=${namedAuEntries}`
|
||||
}
|
||||
|
||||
export function findExportedConstructor(webpackRequire, predicate) {
|
||||
const cache = webpackRequire?.c
|
||||
if (!cache || typeof cache !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const record of Object.values(cache)) {
|
||||
const exports = record?.exports
|
||||
const candidates = []
|
||||
|
||||
if (typeof exports === "function") {
|
||||
candidates.push(exports)
|
||||
} else if (isRecord(exports)) {
|
||||
if (typeof exports.default === "function") {
|
||||
candidates.push(exports.default)
|
||||
}
|
||||
|
||||
for (const value of Object.values(exports)) {
|
||||
if (typeof value === "function") {
|
||||
candidates.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate?.prototype && predicate(candidate.prototype)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findInstanceInContainerGraph(root, predicate, maxDepth = 4) {
|
||||
if (!root) {
|
||||
return null
|
||||
}
|
||||
|
||||
const seen = new Set()
|
||||
const queue = [{ value: root, depth: 0 }]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()
|
||||
const value = current?.value
|
||||
const depth = current?.depth ?? 0
|
||||
if (!value || seen.has(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(value)
|
||||
|
||||
try {
|
||||
if (predicate(value)) {
|
||||
return value
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
if (depth >= maxDepth) {
|
||||
continue
|
||||
}
|
||||
|
||||
enqueueNestedValues(queue, seen, value, depth + 1)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getBasename(location) {
|
||||
if (typeof location !== "string" || !location.trim()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const normalized = location.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
const leaf = normalized.split("/").filter(Boolean).pop()
|
||||
return leaf ? leaf.trim() : ""
|
||||
}
|
||||
|
||||
export function toStringId(value) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeStringList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((entry) => typeof entry === "string" && entry.trim())
|
||||
.map((entry) => entry.trim())
|
||||
}
|
||||
|
||||
export function getPreferredLocale() {
|
||||
return safeString(
|
||||
document.documentElement?.lang,
|
||||
Array.isArray(globalThis.navigator?.languages)
|
||||
? globalThis.navigator.languages.find(
|
||||
(entry) => typeof entry === "string" && entry.trim()
|
||||
)
|
||||
: "",
|
||||
globalThis.navigator?.language,
|
||||
"en-US"
|
||||
)
|
||||
}
|
||||
|
||||
function getContainerFromAu(au) {
|
||||
if (!isRecord(au)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (au.container) {
|
||||
return au.container
|
||||
}
|
||||
|
||||
const directControllerContainer =
|
||||
au.controller?.container || au.controller?.viewModel?.container
|
||||
if (directControllerContainer) {
|
||||
return directControllerContainer
|
||||
}
|
||||
|
||||
for (const value of Object.values(au)) {
|
||||
if (!isRecord(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const container =
|
||||
value.container ||
|
||||
value.controller?.container ||
|
||||
value.viewModel?.container ||
|
||||
value.controller?.viewModel?.container
|
||||
if (container) {
|
||||
return container
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getContainerFromElement(element) {
|
||||
if (!element) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (element.__aurelia__?.container) {
|
||||
return element.__aurelia__.container
|
||||
}
|
||||
|
||||
return getContainerFromAu(element.au)
|
||||
}
|
||||
|
||||
function getContainerFromSubtree(root) {
|
||||
if (!root) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rootContainer = getContainerFromElement(root)
|
||||
if (rootContainer) {
|
||||
return rootContainer
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
|
||||
let element = walker.nextNode()
|
||||
while (element) {
|
||||
const container = getContainerFromElement(element)
|
||||
if (container) {
|
||||
return container
|
||||
}
|
||||
|
||||
element = walker.nextNode()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function enqueueNestedValues(queue, seen, value, depth) {
|
||||
if (value instanceof Map) {
|
||||
enqueueIterable(queue, seen, value.values(), depth)
|
||||
return
|
||||
}
|
||||
|
||||
if (value instanceof Set || Array.isArray(value)) {
|
||||
enqueueIterable(queue, seen, value.values(), depth)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isRecord(value) && typeof value !== "function") {
|
||||
return
|
||||
}
|
||||
|
||||
enqueueIterable(queue, seen, Object.values(value), depth)
|
||||
}
|
||||
|
||||
function enqueueIterable(queue, seen, values, depth) {
|
||||
for (const entry of values) {
|
||||
if ((isRecord(entry) || typeof entry === "function") && !seen.has(entry)) {
|
||||
queue.push({ value: entry, depth })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { TRAINER_LAUNCH_REQUEST_EXPORT_KEY } from "./constants.js"
|
||||
import {
|
||||
findExportedConstructor,
|
||||
findInstanceInContainerGraph,
|
||||
isRecord,
|
||||
} from "./runtime.js"
|
||||
|
||||
export function hasMissingOptionalServices(state) {
|
||||
return (
|
||||
!state.gameLifecycleService ||
|
||||
!state.trainerVisibilityService ||
|
||||
!state.unavailableTitlesService
|
||||
)
|
||||
}
|
||||
|
||||
const OPTIONAL_SERVICE_SPECS = [
|
||||
{
|
||||
stateKey: "unavailableTitlesService",
|
||||
methods: ["getUnavailableTitle", "searchUnavailableTitles", "getUnavailableTitlesByCorrelationIds"],
|
||||
label: "Unavailable titles service",
|
||||
},
|
||||
{
|
||||
stateKey: "gameLifecycleService",
|
||||
methods: ["onGameLaunched", "onGameEnded", "launch"],
|
||||
label: "Game lifecycle service",
|
||||
},
|
||||
{
|
||||
stateKey: "trainerVisibilityService",
|
||||
methods: ["onDisplayTrainerChanged", "onVisibleTrainerChanged", "onRunningTrainerChanged"],
|
||||
label: "Trainer visibility service",
|
||||
},
|
||||
{
|
||||
stateKey: "trainerApiService",
|
||||
methods: ["getLatestLocalTrainerForGame", "getMostCompatibleTrainerForGame", "getTrainerById"],
|
||||
label: "Trainer API service",
|
||||
},
|
||||
{
|
||||
stateKey: "trainerService",
|
||||
methods: ["launch", "endTrainer", "onNewTrainer", "onTrainerEnded"],
|
||||
label: "Trainer service",
|
||||
},
|
||||
]
|
||||
|
||||
export function resolveOptionalServices(state, container, webpackRequire) {
|
||||
for (const spec of OPTIONAL_SERVICE_SPECS) {
|
||||
if (!state[spec.stateKey]) {
|
||||
state[spec.stateKey] = resolveOptionalService(state, container, webpackRequire, spec)
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.trainerLaunchRequestCtor) {
|
||||
state.trainerLaunchRequestCtor = getTrainerLaunchRequestCtor(state, webpackRequire)
|
||||
}
|
||||
}
|
||||
|
||||
export function getInstalledAppsService(state, container, webpackRequire) {
|
||||
const ctor = findExportedConstructor(webpackRequire, (prototype) => {
|
||||
return (
|
||||
typeof prototype.refreshApps === "function" &&
|
||||
typeof prototype.watchGame === "function"
|
||||
)
|
||||
})
|
||||
|
||||
if (!ctor) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Installed apps service constructor not found in webpack cache."
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const service = container.get(ctor)
|
||||
const appsCount = isRecord(service.installedApps)
|
||||
? Object.keys(service.installedApps).length
|
||||
: -1
|
||||
const catalogGamesCount = isRecord(service.catalog?.games)
|
||||
? Object.keys(service.catalog.games).length
|
||||
: -1
|
||||
state.log(
|
||||
"info",
|
||||
"Installed apps service resolved.",
|
||||
`ctor=${ctor.name || "<anon>"}, installedApps=${appsCount}, catalogGames=${catalogGamesCount}`
|
||||
)
|
||||
return service
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Failed to resolve installed apps service from Aurelia container.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoreRef(state, container, webpackRequire) {
|
||||
const storeCtor = findExportedConstructor(webpackRequire, (prototype) => {
|
||||
return (
|
||||
typeof prototype.dispatch === "function" &&
|
||||
typeof prototype.registerAction === "function" &&
|
||||
typeof prototype.unregisterAction === "function"
|
||||
)
|
||||
})
|
||||
|
||||
if (!storeCtor) {
|
||||
state.log("warn", "Store constructor not found in webpack cache.")
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const store = container.get(storeCtor)
|
||||
const storeState =
|
||||
typeof store.state?.getValue === "function"
|
||||
? store.state.getValue()
|
||||
: null
|
||||
const installedAppsCount = isRecord(storeState?.installedApps)
|
||||
? Object.keys(storeState.installedApps).length
|
||||
: -1
|
||||
const catalogGamesCount = isRecord(storeState?.catalog?.games)
|
||||
? Object.keys(storeState.catalog.games).length
|
||||
: -1
|
||||
state.log(
|
||||
"info",
|
||||
"Store resolved via fallback.",
|
||||
`ctor=${storeCtor.name || "<anon>"}, installedApps=${installedAppsCount}, catalogGames=${catalogGamesCount}`
|
||||
)
|
||||
return store
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
"Failed to resolve Store from container.",
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOptionalService(state, container, webpackRequire, spec) {
|
||||
const matchesMethods = (target) => hasAllMethods(target, spec.methods)
|
||||
const ctor = findExportedConstructor(webpackRequire, matchesMethods)
|
||||
if (ctor) {
|
||||
return getContainerService(state, container, ctor, spec.stateKey, spec.label)
|
||||
}
|
||||
|
||||
return findFallbackService(
|
||||
state,
|
||||
container,
|
||||
spec.stateKey,
|
||||
`${spec.label} constructor not found in webpack cache.`,
|
||||
matchesMethods
|
||||
)
|
||||
}
|
||||
|
||||
function hasAllMethods(target, methods) {
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const method of methods) {
|
||||
if (typeof target[method] !== "function") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getTrainerLaunchRequestCtor(state, webpackRequire) {
|
||||
const cache = webpackRequire?.c
|
||||
if (!cache || typeof cache !== "object") {
|
||||
warnMissingOptionalService(
|
||||
state,
|
||||
"trainerLaunchRequestCtor",
|
||||
"Trainer launch request constructor cache is unavailable."
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
for (const record of Object.values(cache)) {
|
||||
const exports = record?.exports
|
||||
if (!isRecord(exports)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidate = exports[TRAINER_LAUNCH_REQUEST_EXPORT_KEY]
|
||||
if (
|
||||
typeof candidate === "function" &&
|
||||
typeof exports.ZS === "function" &&
|
||||
typeof exports.jR === "function" &&
|
||||
typeof exports.UY === "function"
|
||||
) {
|
||||
clearMissingOptionalServiceWarning(state, "trainerLaunchRequestCtor")
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
warnMissingOptionalService(
|
||||
state,
|
||||
"trainerLaunchRequestCtor",
|
||||
"Trainer launch request constructor not found in webpack cache."
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
function getContainerService(state, container, ctor, warningKey, label) {
|
||||
try {
|
||||
const service = container.get(ctor)
|
||||
clearMissingOptionalServiceWarning(state, warningKey)
|
||||
state.log(
|
||||
"info",
|
||||
`${label} resolved.`,
|
||||
`ctor=${ctor.name || "<anon>"}${service?.runningTrainer ? ", running=yes" : ""}`
|
||||
)
|
||||
return service
|
||||
} catch (error) {
|
||||
state.log(
|
||||
"warn",
|
||||
`Failed to resolve ${label.toLowerCase()} from Aurelia container.`,
|
||||
error?.stack || String(error)
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function findFallbackService(
|
||||
state,
|
||||
container,
|
||||
warningKey,
|
||||
missingMessage,
|
||||
predicate
|
||||
) {
|
||||
const fallbackService = findInstanceInContainerGraph(container, predicate)
|
||||
if (fallbackService) {
|
||||
clearMissingOptionalServiceWarning(state, warningKey)
|
||||
state.log("info", `${warningKey} resolved from container graph.`)
|
||||
return fallbackService
|
||||
}
|
||||
|
||||
warnMissingOptionalService(state, warningKey, missingMessage)
|
||||
return null
|
||||
}
|
||||
|
||||
function warnMissingOptionalService(state, key, message) {
|
||||
if (state.missingOptionalServiceWarnings.has(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
state.missingOptionalServiceWarnings.add(key)
|
||||
state.log("warn", message)
|
||||
}
|
||||
|
||||
function clearMissingOptionalServiceWarning(state, key) {
|
||||
state.missingOptionalServiceWarnings.delete(key)
|
||||
}
|
||||
+22
-21
@@ -1,12 +1,12 @@
|
||||
(function installRemotePopupCleanup(WandEnhancer) {
|
||||
;(function installRemotePopupCleanup(WandEnhancer) {
|
||||
if (globalThis.__wandRemotePopupCleanupInstalled) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
globalThis.__wandRemotePopupCleanupInstalled = true;
|
||||
globalThis.__wandRemotePopupCleanupInstalled = true
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'wand-remote-popup-cleanup-style';
|
||||
const style = document.createElement("style")
|
||||
style.id = "wand-remote-popup-cleanup-style"
|
||||
style.textContent = `
|
||||
remote-tooltip .remote-tooltip .top-wrapper,
|
||||
remote-tooltip .remote-tooltip .remote-tooltip-section-divider,
|
||||
@@ -59,36 +59,37 @@
|
||||
border-radius: 12px !important;
|
||||
transform: none !important;
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
const installStyle = () => {
|
||||
if (!document.getElementById(style.id)) {
|
||||
document.head.appendChild(style);
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const updateLinks = () => {
|
||||
const remoteUrl = globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl;
|
||||
const remoteUrl =
|
||||
globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl
|
||||
if (!remoteUrl) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
for (const anchor of document.querySelectorAll('remote-tooltip a[href]')) {
|
||||
anchor.setAttribute('href', remoteUrl);
|
||||
anchor.textContent = remoteUrl.replace(/\/$/, '');
|
||||
for (const anchor of document.querySelectorAll("remote-tooltip a[href]")) {
|
||||
anchor.setAttribute("href", remoteUrl)
|
||||
anchor.textContent = remoteUrl.replace(/\/$/, "")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
installStyle();
|
||||
updateLinks();
|
||||
installStyle()
|
||||
updateLinks()
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
installStyle();
|
||||
updateLinks();
|
||||
});
|
||||
installStyle()
|
||||
updateLinks()
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
})(globalThis.WandEnhancer);
|
||||
})
|
||||
})(globalThis.WandEnhancer)
|
||||
@@ -0,0 +1,31 @@
|
||||
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./bridge-modules/runtime.cjs');
|
||||
const { installWandRuntime: installRuntime } = require('./bridge-modules/wand-runtime.cjs');
|
||||
|
||||
function withDefaultPanelRoot(options = {}) {
|
||||
if (options.panelRoot) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
panelRoot: __dirname,
|
||||
};
|
||||
}
|
||||
|
||||
function createBridgeRuntime(options = {}) {
|
||||
return createRuntime(withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
function ensureBridge(options = {}) {
|
||||
return ensureRuntime(withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
function installWandRuntime(electron, options = {}) {
|
||||
return installRuntime(electron, withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeRuntime,
|
||||
ensureBridge,
|
||||
installWandRuntime,
|
||||
};
|
||||
@@ -1,896 +0,0 @@
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']);
|
||||
const DEFAULT_REMOTE_PORT = 3223;
|
||||
const PORT_SCAN_RANGE = 30;
|
||||
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 BRIDGE_LOG_FILE_NAME = 'wand-remote-bridge.log';
|
||||
const RENDERER_SCRIPTS_DIR = 'renderer-scripts';
|
||||
const RENDERER_SCRIPT_API_VERSION = 1;
|
||||
|
||||
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' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cloneValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(cloneValue);
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
const result = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
result[key] = cloneValue(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isValidPort(value) {
|
||||
return Number.isFinite(value) && value > 0 && value < 65536;
|
||||
}
|
||||
|
||||
function normalizeOption(option) {
|
||||
if (typeof option === 'string' || typeof option === 'number') {
|
||||
return {
|
||||
label: String(option),
|
||||
value: option,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRecord(option)) {
|
||||
const value = option.value;
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return {
|
||||
label: safeString(option.label, String(value)),
|
||||
value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 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 jsonMessage(type, payload, requestId = null) {
|
||||
return JSON.stringify({
|
||||
type,
|
||||
version: 1,
|
||||
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(1, 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(8, 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 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 urls = [];
|
||||
const interfaces = os.networkInterfaces();
|
||||
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry || entry.internal || entry.family !== 'IPv4') {
|
||||
continue;
|
||||
}
|
||||
|
||||
urls.push(`http://${entry.address}:${port}${REMOTE_BASE_PATH}`);
|
||||
}
|
||||
}
|
||||
|
||||
urls.unshift(`http://localhost:${port}${REMOTE_BASE_PATH}`);
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
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 || __dirname;
|
||||
const clients = new Set();
|
||||
let advertisedUrls = [];
|
||||
let currentSnapshot = null;
|
||||
let setValueHandler = null;
|
||||
let listening = false;
|
||||
|
||||
function setAdvertisedPort(nextPort) {
|
||||
port = nextPort;
|
||||
advertisedUrls = getAdvertisedUrls(port);
|
||||
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
|
||||
}
|
||||
|
||||
setAdvertisedPort(port);
|
||||
|
||||
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
|
||||
|
||||
function log(level, message, error) {
|
||||
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
|
||||
const tag = `[wand-remote-bridge] ${message}`;
|
||||
try { console[method](tag, error || ''); } catch { /* renderer may close console */ }
|
||||
try {
|
||||
const detail = error ? ` :: ${error && error.stack ? error.stack : String(error)}` : '';
|
||||
fs.appendFileSync(logFile, `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
log('info', `Bridge starting (pid=${process.pid}, panelRoot=${panelRoot}, preferredPort=${port}, host=${host})`);
|
||||
globalThis.__wandRemoteBridgeLogFile = logFile;
|
||||
|
||||
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: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(client, 'trainer_meta', currentSnapshot.trainerMeta);
|
||||
sendJson(client, 'trainer_values', currentSnapshot.trainerValues);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 setHandler(handler) {
|
||||
setValueHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((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({
|
||||
ok: listening,
|
||||
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || null,
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
advertisedUrls,
|
||||
}));
|
||||
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');
|
||||
});
|
||||
|
||||
server.on('upgrade', (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;
|
||||
}
|
||||
|
||||
const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
|
||||
socket.write([
|
||||
'HTTP/1.1 101 Switching Protocols',
|
||||
'Upgrade: websocket',
|
||||
'Connection: Upgrade',
|
||||
`Sec-WebSocket-Accept: ${accept}`,
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
|
||||
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 === 8) {
|
||||
closeClient(client, 1000, 'Closing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.opcode === 9) {
|
||||
client.socket.write(makeFrame(10, frame.payload));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.opcode !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = JSON.parse(frame.payload.toString('utf8'));
|
||||
if (message?.type === 'hello') {
|
||||
sendJson(client, 'hello_ack', {
|
||||
sessionId: `sess_${Date.now()}`,
|
||||
accepted: true,
|
||||
serverVersion: '0.2.0-wand',
|
||||
protocolVersion: 1,
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
advertisedUrls,
|
||||
}, message.requestId ?? null);
|
||||
sendSnapshot(client);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message?.type === 'set_value') {
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: true,
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
}, message.requestId ?? null);
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
function listen(nextPort) {
|
||||
setAdvertisedPort(nextPort);
|
||||
server.listen(port, host);
|
||||
}
|
||||
|
||||
listen(port);
|
||||
|
||||
return {
|
||||
get listening() {
|
||||
return listening;
|
||||
},
|
||||
get remoteUrl() {
|
||||
return globalThis.__wandRemoteBridgeUrl;
|
||||
},
|
||||
get advertisedUrls() {
|
||||
return advertisedUrls.slice();
|
||||
},
|
||||
sync,
|
||||
valueChanged,
|
||||
setHandler,
|
||||
close() {
|
||||
for (const client of clients) {
|
||||
closeClient(client);
|
||||
}
|
||||
clients.clear();
|
||||
currentSnapshot = null;
|
||||
listening = false;
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ensureBridge(options = {}) {
|
||||
if (!globalThis.__wandRemoteBridgeRuntime) {
|
||||
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
|
||||
}
|
||||
|
||||
return globalThis.__wandRemoteBridgeRuntime;
|
||||
}
|
||||
|
||||
function writeInstallLog(level, message, error) {
|
||||
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
|
||||
const tag = `[wand-remote-bridge] ${message}`;
|
||||
try { console[method](tag, error || ''); } catch { /* best-effort */ }
|
||||
try {
|
||||
const detail = error ? ` :: ${error && error.stack ? error.stack : String(error)}` : '';
|
||||
fs.appendFileSync(path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME), `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
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) {
|
||||
// Inline each script source directly instead of wrapping it in `new Function(...)`.
|
||||
// Wand's renderer ships with a strict CSP (no `unsafe-eval`), so any attempt to eval
|
||||
// a string at runtime — including the `Function` constructor — silently throws
|
||||
// "EvalError: Refused to evaluate a string as JavaScript". `executeJavaScript`
|
||||
// itself runs in the page's V8 context and is not affected by CSP, so concatenating
|
||||
// sources into a single payload makes scripts behave the same as a manual paste in
|
||||
// DevTools (which is the only path the user reported as working).
|
||||
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 || __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);
|
||||
setTimeout(inject, 500);
|
||||
setTimeout(inject, 2000);
|
||||
});
|
||||
|
||||
writeInstallLog('info', `Renderer script injection installed (${scripts.map((script) => script.name).join(', ')}).`);
|
||||
}
|
||||
|
||||
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();
|
||||
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
|
||||
|
||||
runtime.setHandler((request) => {
|
||||
let delivered = false;
|
||||
for (const sender of Array.from(boundRenderers)) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) {
|
||||
boundRenderers.delete(sender);
|
||||
continue;
|
||||
}
|
||||
|
||||
sender.send('wand-remote-set-value', request);
|
||||
delivered = true;
|
||||
} catch (error) {
|
||||
boundRenderers.delete(sender);
|
||||
writeInstallLog('warn', 'Failed to forward set_value to renderer.', error);
|
||||
}
|
||||
}
|
||||
|
||||
return delivered;
|
||||
});
|
||||
|
||||
if (!globalThis.__wandRemoteBridgeIpcInstalled) {
|
||||
globalThis.__wandRemoteBridgeIpcInstalled = true;
|
||||
electron.ipcMain.handle('wand-remote-sync', (_event, snapshot) => {
|
||||
runtime.sync(snapshot);
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle('wand-remote-value-changed', (_event, change) => {
|
||||
runtime.valueChanged(change);
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle('wand-remote-set-handler-bind', (event) => {
|
||||
if (event && event.sender) {
|
||||
boundRenderers.add(event.sender);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle('wand-remote-url', () => runtime.remoteUrl);
|
||||
}
|
||||
|
||||
installRendererScripts(electron, runtime, options);
|
||||
writeInstallLog('info', 'Wand runtime hooks installed.');
|
||||
return runtime;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeRuntime,
|
||||
ensureBridge,
|
||||
installWandRuntime,
|
||||
};
|
||||
@@ -50,15 +50,33 @@
|
||||
"uuid": "number-money",
|
||||
"target": "player_money",
|
||||
"type": "number",
|
||||
"name": "Money",
|
||||
"description": "Set the current money amount.",
|
||||
"name": "Runes",
|
||||
"description": "Set the current rune amount.",
|
||||
"instructions": null,
|
||||
"category": "inventory",
|
||||
"parent": null,
|
||||
"args": {
|
||||
"min": 0,
|
||||
"max": 999999,
|
||||
"step": 100
|
||||
"max": 9999999,
|
||||
"step": 1000
|
||||
}
|
||||
},
|
||||
{
|
||||
"uuid": "selection-spawn-item",
|
||||
"target": "spawn_item",
|
||||
"type": "selection",
|
||||
"name": "Spawn Item",
|
||||
"description": "Choose the item to spawn.",
|
||||
"instructions": null,
|
||||
"category": "inventory",
|
||||
"parent": null,
|
||||
"args": {
|
||||
"options": [
|
||||
{ "label": "Erdtree Greatshield", "value": "erdtree_greatshield" },
|
||||
{ "label": "Rivers of Blood", "value": "rivers_of_blood" },
|
||||
{ "label": "Moonveil Katana", "value": "moonveil_katana" },
|
||||
{ "label": "Blasphemous Blade", "value": "blasphemous_blade" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -93,15 +111,17 @@
|
||||
"uuid": "scalar-speed",
|
||||
"target": "game_speed",
|
||||
"type": "scalar",
|
||||
"name": "Game Speed",
|
||||
"description": "Scalar-style preset selector.",
|
||||
"name": "Time Scale",
|
||||
"description": "Tune simulation speed in real time.",
|
||||
"instructions": null,
|
||||
"category": "world",
|
||||
"parent": null,
|
||||
"args": {
|
||||
"min": 0,
|
||||
"max": 5,
|
||||
"step": 0.01,
|
||||
"postfix": "x",
|
||||
"default": 1,
|
||||
"options": [0.5, 1, 1.5, 2]
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -130,7 +150,8 @@
|
||||
"values": {
|
||||
"god_mode": false,
|
||||
"player_health": 83,
|
||||
"player_money": 15000,
|
||||
"player_money": 2400000,
|
||||
"spawn_item": "erdtree_greatshield",
|
||||
"restock_ammo": 0,
|
||||
"difficulty": "normal",
|
||||
"game_speed": 1,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:host": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build": "tsc --noEmit && vite build && pnpm run build:bridge",
|
||||
"build:bridge": "node ./bridge/build.mjs",
|
||||
"preview": "vite preview",
|
||||
"preview:host": "vite preview --host 0.0.0.0",
|
||||
"bridge": "node ./bridge/server.mjs"
|
||||
@@ -22,6 +23,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"esbuild": "0.27.7",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
@@ -32,4 +34,4 @@
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3
@@ -33,6 +33,9 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
esbuild:
|
||||
specifier: 0.27.7
|
||||
version: 0.27.7
|
||||
eslint:
|
||||
specifier: ^9.39.4
|
||||
version: 9.39.4(jiti@2.6.1)
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
Place user scripts here as plain `.js` files before running the Wand patch.
|
||||
|
||||
During patching, Wand Enhancer copies default scripts from `web-panel/scripts/default` and user scripts from this folder into `remote-panel/renderer-scripts` inside `app.asar`. Scripts are loaded in filename order on every Wand renderer startup.
|
||||
During patching, Wand Enhancer copies generated default scripts from `web-panel/dist/renderer-scripts` and user scripts from this folder into `remote-panel/renderer-scripts` inside `app.asar`. Default script sources live under `web-panel/bridge/scripts/default` and are generated by `pnpm run build:bridge`. Scripts are loaded in filename order on every Wand renderer startup.
|
||||
|
||||
Each script runs in the Wand renderer and receives a small global API:
|
||||
|
||||
```js
|
||||
(function (WandEnhancer) {
|
||||
WandEnhancer.log('custom script loaded', WandEnhancer.remoteUrl);
|
||||
})(globalThis.WandEnhancer);
|
||||
;(function (WandEnhancer) {
|
||||
WandEnhancer.log("custom script loaded", WandEnhancer.remoteUrl)
|
||||
})(globalThis.WandEnhancer)
|
||||
```
|
||||
|
||||
Use unique global guards for repeat-safe scripts because the renderer can be reinjected after navigation.
|
||||
|
||||
+239
-119
@@ -1,29 +1,44 @@
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { CategorySection } from '@/features/remote-panel/components/CategorySection';
|
||||
import { ConnectionPanel } from '@/features/remote-panel/components/ConnectionPanel';
|
||||
import { DeckHeader } from '@/features/remote-panel/components/DeckHeader';
|
||||
import { EmptyDeck } from '@/features/remote-panel/components/EmptyDeck';
|
||||
import { TrainerOverview } from '@/features/remote-panel/components/TrainerOverview';
|
||||
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from '@/features/remote-panel/category';
|
||||
import { handleProtocolMessage } from '@/features/remote-panel/message-handler';
|
||||
import { normalizeOutgoingValue, type CheatSchema, type TrainerMetaPayload } from '@/features/remote-panel/protocol';
|
||||
import {
|
||||
getPinnedStorageKey,
|
||||
loadPinnedTargets,
|
||||
savePinnedTargets,
|
||||
} from '@/features/remote-panel/pinned-storage';
|
||||
import { PanelSocketClient } from '@/features/remote-panel/socket-client';
|
||||
import { createInitialPanelState, panelReducer } from '@/features/remote-panel/state';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useEffect, useMemo, useReducer, useRef, useState, type UIEvent } from 'react';
|
||||
|
||||
export function App() {
|
||||
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from '@/features/remote-panel/category';
|
||||
import { CategorySection } from '@/features/remote-panel/components/CategorySection';
|
||||
import { Drawer } from '@/features/remote-panel/components/Drawer';
|
||||
import { FloatingDock } from '@/features/remote-panel/components/FloatingDock';
|
||||
import { LibraryDrawer } from '@/features/remote-panel/components/LibraryDrawer';
|
||||
import { PlaceholderState } from '@/features/remote-panel/components/PlaceholderState';
|
||||
import { QuickActions } from '@/features/remote-panel/components/QuickActions';
|
||||
import { SearchInput } from '@/features/remote-panel/components/SearchInput';
|
||||
import { SettingsDrawer } from '@/features/remote-panel/components/SettingsDrawer';
|
||||
import { TopBar } from '@/features/remote-panel/components/TopBar';
|
||||
import { TrainerHeader } from '@/features/remote-panel/components/TrainerHeader';
|
||||
import { buildLibraryGames, getCurrentGame, type LibraryGame } from '@/features/remote-panel/game-library';
|
||||
import { loadPinnedGameIds, savePinnedGameIds, togglePinnedGame } from '@/features/remote-panel/game-pin-storage';
|
||||
import { handleProtocolMessage } from '@/features/remote-panel/message-handler';
|
||||
import { getPinnedStorageKey, loadPinnedTargets, savePinnedTargets } from '@/features/remote-panel/pinned-storage';
|
||||
import { capturePresetValues, createPreset, getPresetStorageKey, loadPresets, savePresets, type RemotePreset } from '@/features/remote-panel/preset-storage';
|
||||
import { normalizeOutgoingValue, type CheatSchema, type InstalledAppSummary, type TrainerMetaPayload } from '@/features/remote-panel/protocol';
|
||||
import { ECheatType } from '@/features/remote-panel/protocol';
|
||||
import { PanelSocketClient } from '@/features/remote-panel/socket-client';
|
||||
import { createInitialPanelState, EConnectionStatus, panelReducer } from '@/features/remote-panel/state';
|
||||
|
||||
const SCROLL_HIDE_THRESHOLD_PX = 60;
|
||||
const SCROLL_REVEAL_DEAD_ZONE_PX = 4;
|
||||
|
||||
export const App = () => {
|
||||
const [state, dispatch] = useReducer(panelReducer, createInitialPanelState());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [cheatQuery, setCheatQuery] = useState('');
|
||||
const [gameQuery, setGameQuery] = useState('');
|
||||
const [leftOpen, setLeftOpen] = useState(false);
|
||||
const [rightOpen, setRightOpen] = useState(false);
|
||||
const [hideDock, setHideDock] = useState(false);
|
||||
const [pinnedGameIds, setPinnedGameIds] = useState<Record<string, true>>({});
|
||||
const [presets, setPresets] = useState<RemotePreset[]>([]);
|
||||
const lastScrollRef = useRef(0);
|
||||
const clientRef = useRef<PanelSocketClient | null>(null);
|
||||
const trainerMetaRef = useRef<TrainerMetaPayload | null>(state.trainerMeta);
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedGameIds(loadPinnedGameIds());
|
||||
return () => {
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
@@ -34,29 +49,41 @@ export function App() {
|
||||
trainerMetaRef.current = state.trainerMeta;
|
||||
}, [state.trainerMeta]);
|
||||
|
||||
const groups = useMemo(() => groupCheatsByCategory(state.trainerMeta), [state.trainerMeta]);
|
||||
const pinnedGroup = useMemo(
|
||||
() => buildPinnedGroup(state.trainerMeta, state.pinnedTargets),
|
||||
[state.trainerMeta, state.pinnedTargets],
|
||||
);
|
||||
const filteredGroups = useMemo(() => filterGroups(groups, searchQuery), [groups, searchQuery]);
|
||||
const filteredPinnedGroup = useMemo(
|
||||
() => (pinnedGroup ? filterGroups([pinnedGroup], searchQuery)[0] ?? null : null),
|
||||
[pinnedGroup, searchQuery],
|
||||
);
|
||||
const pinnedStorageKey = useMemo(
|
||||
() => getPinnedStorageKey(state.trainerMeta?.trainer ?? null),
|
||||
[state.trainerMeta?.trainer],
|
||||
);
|
||||
const activeTrainer = state.trainerMeta?.trainer ?? null;
|
||||
const cheatCount = state.trainerMeta?.schema.cheats.length ?? 0;
|
||||
const libraryGames = useMemo(
|
||||
() => buildLibraryGames(state.installedApps, state.gameStatus, activeTrainer, pinnedGameIds),
|
||||
[activeTrainer, pinnedGameIds, state.gameStatus, state.installedApps],
|
||||
);
|
||||
const currentGame = useMemo(() => getCurrentGame(libraryGames), [libraryGames]);
|
||||
const groups = useMemo(() => groupCheatsByCategory(state.trainerMeta), [state.trainerMeta]);
|
||||
const pinnedGroup = useMemo(() => buildPinnedGroup(state.trainerMeta, state.pinnedTargets), [state.trainerMeta, state.pinnedTargets]);
|
||||
const filteredGroups = useMemo(() => filterGroups(groups, cheatQuery), [cheatQuery, groups]);
|
||||
const filteredPinnedGroup = useMemo(
|
||||
() => (pinnedGroup ? filterGroups([pinnedGroup], cheatQuery)[0] ?? null : null),
|
||||
[cheatQuery, pinnedGroup],
|
||||
);
|
||||
const pinnedStorageKey = useMemo(() => getPinnedStorageKey(activeTrainer), [activeTrainer]);
|
||||
const presetStorageKey = useMemo(() => getPresetStorageKey(activeTrainer), [activeTrainer]);
|
||||
const socketReady = clientRef.current?.isOpen() ?? false;
|
||||
const connected = state.connectionStatus === EConnectionStatus.Connected;
|
||||
const controlsDisabled = Boolean(activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired);
|
||||
const totalVisibleCheats = filteredGroups.reduce((count, group) => count + group.cheats.length, filteredPinnedGroup?.cheats.length ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'setPinnedTargets', pinned: loadPinnedTargets(pinnedStorageKey) });
|
||||
}, [pinnedStorageKey]);
|
||||
|
||||
function connect(): void {
|
||||
useEffect(() => {
|
||||
setPresets(loadPresets(presetStorageKey));
|
||||
}, [presetStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.wsUrl.trim()) {
|
||||
handleConnect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleConnect(): void {
|
||||
clientRef.current?.disconnect();
|
||||
|
||||
const wsUrl = state.wsUrl.trim();
|
||||
@@ -77,12 +104,18 @@ export function App() {
|
||||
nextClient.connect();
|
||||
}
|
||||
|
||||
function handleDisconnect(): void {
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
dispatch({ type: 'disconnected' });
|
||||
}
|
||||
|
||||
function handleCheatChange(cheat: CheatSchema, nextValue: unknown): void {
|
||||
const normalizedValue = normalizeOutgoingValue(cheat, nextValue);
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: true });
|
||||
dispatch({ type: 'valueChanged', target: cheat.target, value: normalizedValue });
|
||||
|
||||
if (state.connectionStatus !== 'connected' || !state.trainerMeta || !clientRef.current) {
|
||||
if (state.connectionStatus !== EConnectionStatus.Connected || !state.trainerMeta || !clientRef.current) {
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: false });
|
||||
return;
|
||||
}
|
||||
@@ -94,128 +127,215 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePin(cheat: CheatSchema): void {
|
||||
function handleToggleCheatPin(cheat: CheatSchema): void {
|
||||
const next = { ...state.pinnedTargets };
|
||||
if (next[cheat.target]) {
|
||||
delete next[cheat.target];
|
||||
} else {
|
||||
next[cheat.target] = true;
|
||||
}
|
||||
|
||||
dispatch({ type: 'togglePinnedTarget', target: cheat.target });
|
||||
savePinnedTargets(pinnedStorageKey, next);
|
||||
}
|
||||
|
||||
async function loadDebugSession(): Promise<void> {
|
||||
if (!import.meta.env.DEV) {
|
||||
return;
|
||||
}
|
||||
|
||||
clientRef.current?.disconnect();
|
||||
const debugSession = await import('@/features/remote-panel/debug-session');
|
||||
debugSession.loadDebugSession(dispatch);
|
||||
function handleToggleGamePin(game: LibraryGame): void {
|
||||
const next = togglePinnedGame(game, pinnedGameIds);
|
||||
setPinnedGameIds(next);
|
||||
savePinnedGameIds(next);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
void import('@/features/remote-panel/debug-session').then((debugSession) => {
|
||||
if (debugSession.isDebugSessionRequested()) {
|
||||
debugSession.loadDebugSession(dispatch);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.wsUrl.trim()) {
|
||||
connect();
|
||||
}
|
||||
});
|
||||
function handleLaunchGame(app: InstalledAppSummary): void {
|
||||
const client = clientRef.current;
|
||||
if (!app.gameId) {
|
||||
dispatch({ type: 'error', message: 'This My Games entry does not expose a Wand game id.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.wsUrl.trim()) {
|
||||
if (!client?.isOpen()) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
|
||||
return;
|
||||
}
|
||||
|
||||
connect();
|
||||
}, []);
|
||||
if (!client.launchGame(app.gameId, app.titleId ?? undefined)) {
|
||||
dispatch({ type: 'error', message: 'Failed to send the launch command to the bridge.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setRightOpen(false);
|
||||
}
|
||||
|
||||
function handlePlayGame(game: LibraryGame): void {
|
||||
handleLaunchGame(game.app);
|
||||
}
|
||||
|
||||
function handleStopPlaying(): void {
|
||||
const client = clientRef.current;
|
||||
if (!client?.isOpen()) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeGameId = state.gameStatus?.session.gameId ?? state.gameStatus?.trainer.gameId ?? undefined;
|
||||
const activeTitleId = state.gameStatus?.session.titleId ?? state.gameStatus?.trainer.titleId ?? undefined;
|
||||
if (!client.stopPlaying(activeGameId ?? undefined, activeTitleId ?? undefined)) {
|
||||
dispatch({ type: 'error', message: 'Failed to send the stop command to the bridge.' });
|
||||
}
|
||||
}
|
||||
|
||||
function handlePanic(): void {
|
||||
if (!state.trainerMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cheat of state.trainerMeta.schema.cheats) {
|
||||
if (cheat.type === ECheatType.Toggle && Boolean(state.values[cheat.target])) {
|
||||
handleCheatChange(cheat, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddPreset(name: string): boolean {
|
||||
if (!state.trainerMeta) {
|
||||
dispatch({ type: 'error', message: 'No active trainer to save as a preset.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const values = capturePresetValues(state.trainerMeta.schema.cheats, state.values);
|
||||
if (Object.keys(values).length === 0) {
|
||||
dispatch({ type: 'error', message: 'There are no mod values to save yet.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextPresets = [...presets, createPreset(name, values)];
|
||||
setPresets(nextPresets);
|
||||
savePresets(presetStorageKey, nextPresets);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleApplyPreset(preset: RemotePreset): void {
|
||||
if (!state.trainerMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cheat of state.trainerMeta.schema.cheats) {
|
||||
if (!(cheat.target in preset.values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
handleCheatChange(cheat, preset.values[cheat.target]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeletePreset(presetId: string): void {
|
||||
const nextPresets = presets.filter((preset) => preset.id !== presetId);
|
||||
setPresets(nextPresets);
|
||||
savePresets(presetStorageKey, nextPresets);
|
||||
}
|
||||
|
||||
function handleScroll(event: UIEvent<HTMLDivElement>): void {
|
||||
const y = event.currentTarget.scrollTop;
|
||||
if (y > lastScrollRef.current && y > SCROLL_HIDE_THRESHOLD_PX) {
|
||||
setHideDock(true);
|
||||
} else if (y < lastScrollRef.current - SCROLL_REVEAL_DEAD_ZONE_PX) {
|
||||
setHideDock(false);
|
||||
}
|
||||
|
||||
lastScrollRef.current = y;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-svh overflow-hidden bg-background px-2 py-2 text-foreground sm:px-5 sm:py-3 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-3 sm:gap-4">
|
||||
<DeckHeader connectionStatus={state.connectionStatus} remoteUrl={state.remoteUrl} />
|
||||
|
||||
<div className="grid gap-3 sm:gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<aside className="space-y-3 sm:space-y-4">
|
||||
<ConnectionPanel
|
||||
status={state.connectionStatus}
|
||||
wsUrl={state.wsUrl}
|
||||
lastError={state.lastError}
|
||||
onConnect={connect}
|
||||
onDebugSession={import.meta.env.DEV ? loadDebugSession : undefined}
|
||||
onWsUrlChange={(wsUrl) => dispatch({ type: 'setWsUrl', wsUrl })}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section className="space-y-4 sm:space-y-5">
|
||||
{activeTrainer ? (
|
||||
<>
|
||||
<TrainerOverview trainer={activeTrainer} cheatCount={cheatCount} categoryCount={groups.length} />
|
||||
<div className="relative">
|
||||
<Icon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" name="search" />
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onInput={(event) => setSearchQuery((event.target as HTMLInputElement).value)}
|
||||
placeholder="Search cheats, categories, targets..."
|
||||
className="h-9 pl-8 pr-8 text-sm"
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:text-white"
|
||||
>
|
||||
<Icon className="size-3.5" name="x" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-4 sm:space-y-7">
|
||||
<main className="min-h-svh bg-[#050608] text-(--deck-fg)">
|
||||
<div className="flex min-h-svh w-full p-0">
|
||||
<section className="relative h-svh w-full overflow-hidden bg-(--deck-bg) shadow-[0_40px_100px_-20px_rgba(0,0,0,.7),0_0_0_1px_rgba(255,255,255,.06)]">
|
||||
<div className="pointer-events-none absolute -inset-12 z-0 bg-[radial-gradient(circle_at_30%_15%,color-mix(in_oklab,var(--deck-accent)_22%,transparent),transparent_45%),radial-gradient(circle_at_80%_85%,color-mix(in_oklab,var(--deck-accent)_16%,transparent),transparent_45%),radial-gradient(circle_at_20%_80%,color-mix(in_oklab,var(--deck-accent)_8%,transparent),transparent_50%)] blur-[50px]" />
|
||||
<div className="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(ellipse_100%_60%_at_50%_0%,rgba(255,255,255,0.025),transparent)]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<TopBar status={state.connectionStatus} currentGame={currentGame} runningTrainer={activeTrainer} onOpenSettings={() => setLeftOpen(true)} />
|
||||
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pb-[110px]" onScroll={handleScroll}>
|
||||
{!connected ? (
|
||||
<PlaceholderState icon="plug" title="Bridge offline" sub="Open Settings to point Wand at your trainer bridge over WebSocket." action="Open Settings" onAction={() => setLeftOpen(true)} />
|
||||
) : !activeTrainer ? (
|
||||
<PlaceholderState icon="gamepad-variant-outline" title="Select a game" sub="No game is running yet. Open the library and launch one to start tweaking." action="Browse library" onAction={() => setRightOpen(true)} />
|
||||
) : (
|
||||
<>
|
||||
<TrainerHeader trainer={activeTrainer} game={currentGame} isPinned={Boolean(currentGame && pinnedGameIds[currentGame.id])} onPin={() => currentGame && handleToggleGamePin(currentGame)} />
|
||||
<QuickActions presets={presets} onAddPreset={handleAddPreset} onApplyPreset={handleApplyPreset} onDeletePreset={handleDeletePreset} onPanic={handlePanic} />
|
||||
<div className="sticky top-0 z-10 -mx-3.5 mb-2.5 px-3.5 py-0.5">
|
||||
<SearchInput value={cheatQuery} placeholder="Search mods" onChange={setCheatQuery} />
|
||||
</div>
|
||||
{filteredPinnedGroup ? (
|
||||
<CategorySection
|
||||
key="__pinned__"
|
||||
forceOpen={Boolean(cheatQuery)}
|
||||
group={filteredPinnedGroup}
|
||||
values={state.values}
|
||||
pendingTargets={state.pendingTargets}
|
||||
pinnedTargets={state.pinnedTargets}
|
||||
disabled={controlsDisabled}
|
||||
onCheatChange={handleCheatChange}
|
||||
onTogglePin={handleTogglePin}
|
||||
onTogglePin={handleToggleCheatPin}
|
||||
/>
|
||||
) : null}
|
||||
{filteredGroups.map((group) => (
|
||||
{filteredGroups.map((group, index) => (
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
forceOpen={Boolean(cheatQuery)}
|
||||
group={group}
|
||||
openByDefault={index < 2}
|
||||
values={state.values}
|
||||
pendingTargets={state.pendingTargets}
|
||||
pinnedTargets={state.pinnedTargets}
|
||||
disabled={controlsDisabled}
|
||||
onCheatChange={handleCheatChange}
|
||||
onTogglePin={handleTogglePin}
|
||||
onTogglePin={handleToggleCheatPin}
|
||||
/>
|
||||
))}
|
||||
{searchQuery && filteredGroups.length === 0 && !filteredPinnedGroup ? (
|
||||
<p className="rounded-[8px] border border-white/10 bg-white/4.5 px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No cheats match "{searchQuery}".
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyDeck />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{cheatQuery && totalVisibleCheats === 0 ? <p className="px-8 py-8 text-center text-[13px] text-(--deck-fg-4)">No mods match "{cheatQuery}"</p> : null}
|
||||
<div className="mt-4 text-center font-mono text-[10px] uppercase tracking-[0.08em] text-(--deck-fg-4)">
|
||||
{cheatQuery ? `${totalVisibleCheats} matches` : `END · ${state.trainerMeta?.schema.cheats.length ?? 0} MODS`}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingDock
|
||||
status={state.connectionStatus}
|
||||
runningGameTitle={currentGame?.title ?? null}
|
||||
hidden={hideDock}
|
||||
leftHasBadge={!connected}
|
||||
rightHasBadge={connected && !currentGame}
|
||||
onOpenSettings={() => setLeftOpen(true)}
|
||||
onOpenLibrary={() => setRightOpen(true)}
|
||||
/>
|
||||
|
||||
<Drawer open={leftOpen} side="left" onClose={() => setLeftOpen(false)}>
|
||||
<SettingsDrawer
|
||||
status={state.connectionStatus}
|
||||
wsUrl={state.wsUrl}
|
||||
currentGame={currentGame}
|
||||
currentTrainer={activeTrainer}
|
||||
lastError={state.lastError}
|
||||
onClose={() => setLeftOpen(false)}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onWsUrlChange={(wsUrl) => dispatch({ type: 'setWsUrl', wsUrl })}
|
||||
/>
|
||||
</Drawer>
|
||||
<Drawer open={rightOpen} side="right" onClose={() => setRightOpen(false)}>
|
||||
<LibraryDrawer
|
||||
games={libraryGames}
|
||||
query={gameQuery}
|
||||
canLaunch={socketReady}
|
||||
onClose={() => setRightOpen(false)}
|
||||
onPin={handleToggleGamePin}
|
||||
onPlay={handlePlayGame}
|
||||
onStop={handleStopPlaying}
|
||||
onQueryChange={setGameQuery}
|
||||
/>
|
||||
</Drawer>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type BadgeVariant = "default" | "outline"
|
||||
|
||||
const BADGE_BASE = "inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap"
|
||||
|
||||
const BADGE_VARIANTS: Record<BadgeVariant, string> = {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline: "border-border bg-input/20 text-foreground",
|
||||
}
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ComponentProps<"span"> & { variant?: BadgeVariant }) {
|
||||
return (
|
||||
<span
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(BADGE_BASE, BADGE_VARIANTS[variant], className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge }
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ButtonVariant = "default" | "outline"
|
||||
type ButtonSize = "default" | "icon"
|
||||
|
||||
const BUTTON_BASE = "inline-flex shrink-0 items-center justify-center rounded-md border border-transparent text-xs font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50"
|
||||
|
||||
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline: "border-border hover:bg-input/50 hover:text-foreground",
|
||||
}
|
||||
|
||||
const BUTTON_SIZES: Record<ButtonSize, string> = {
|
||||
default: "h-7 gap-1 px-2",
|
||||
icon: "size-7",
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ComponentProps<"button"> & { variant?: ButtonVariant; size?: ButtonSize }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="button"
|
||||
className={cn(BUTTON_BASE, BUTTON_VARIANTS[variant], BUTTON_SIZES[size], className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button }
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-xs text-card-foreground ring-1 ring-foreground/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"grid auto-rows-min items-start gap-1 rounded-t-lg px-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("font-heading text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-xs/relaxed text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -1,76 +1,82 @@
|
||||
import type { ReactNode, SVGProps } from 'react';
|
||||
|
||||
export type IconName =
|
||||
| 'activity'
|
||||
| 'alert'
|
||||
| 'arrow-right'
|
||||
| 'atom'
|
||||
| 'backpack'
|
||||
| 'bolt'
|
||||
| 'box'
|
||||
| 'boxes'
|
||||
| 'car'
|
||||
| 'category'
|
||||
| 'chart'
|
||||
| 'chevron-down'
|
||||
| 'chevron-left'
|
||||
| 'chevron-right'
|
||||
| 'dot'
|
||||
| 'flame'
|
||||
| 'flask'
|
||||
| 'gamepad'
|
||||
| 'gamepad-variant-outline'
|
||||
| 'hammer'
|
||||
| 'heart-broken'
|
||||
| 'list'
|
||||
| 'loader'
|
||||
| 'map-pin'
|
||||
| 'menu'
|
||||
| 'minus'
|
||||
| 'package'
|
||||
| 'pin'
|
||||
| 'pin-off'
|
||||
| 'plug'
|
||||
| 'play'
|
||||
| 'radar'
|
||||
| 'refresh'
|
||||
| 'shield-bolt'
|
||||
| 'sparkles'
|
||||
| 'plus'
|
||||
| 'search'
|
||||
| 'settings'
|
||||
| 'sparkles'
|
||||
| 'star'
|
||||
| 'star-filled'
|
||||
| 'stop'
|
||||
| 'swords'
|
||||
| 'trophy'
|
||||
| 'user'
|
||||
| 'wifi'
|
||||
| 'world'
|
||||
| 'x';
|
||||
|
||||
const ICON_PATHS: Record<IconName, ReactNode> = {
|
||||
activity: <path d="M3 12h4l2-6 4 12 2-6h6" />,
|
||||
alert: <><path d="M12 3 2.8 20h18.4z" /><path d="M12 9v4" /><path d="M12 17h.01" /></>,
|
||||
'arrow-right': <><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></>,
|
||||
atom: <><circle cx="12" cy="12" r="1.5" /><path d="M4 12c2-4 14-4 16 0" /><path d="M4 12c2 4 14 4 16 0" /><path d="M12 4c4 2 4 14 0 16" /></>,
|
||||
backpack: <><path d="M8 8V7a4 4 0 0 1 8 0v1" /><path d="M6 9h12v11H6z" /><path d="M9 14h6" /></>,
|
||||
bolt: <path d="m13 2-8 12h6l-1 8 8-12h-6z" />,
|
||||
box: <><path d="m12 3 8 4.5v9L12 21l-8-4.5v-9z" /><path d="m4 7.5 8 4.5 8-4.5" /><path d="M12 12v9" /></>,
|
||||
boxes: <><path d="M4 7h7v7H4z" /><path d="M13 10h7v7h-7z" /><path d="M7 16h7v5H7z" /></>,
|
||||
car: <><path d="M5 13 7 7h10l2 6" /><path d="M5 13h14v5H5z" /><path d="M8 18v2" /><path d="M16 18v2" /></>,
|
||||
category: <><path d="M4 4h7v7H4z" /><path d="M13 4h7v7h-7z" /><path d="M4 13h7v7H4z" /><path d="M13 13h7v7h-7z" /></>,
|
||||
chart: <><path d="M4 19V5" /><path d="M4 19h16" /><path d="M8 15v-4" /><path d="M12 15V8" /><path d="M16 15v-6" /></>,
|
||||
'chevron-down': <path d="m6 9 6 6 6-6" />,
|
||||
'chevron-left': <path d="m15 6-6 6 6 6" />,
|
||||
'chevron-right': <path d="m9 6 6 6-6 6" />,
|
||||
dot: <circle cx="12" cy="12" r="4" fill="currentColor" stroke="none" />,
|
||||
flame: <path d="M12 22c4 0 7-3 7-7 0-3-2-5-5-8 0 3-2 4-4 5 0-3-1-5-3-7 0 5-3 7-3 10 0 4 3 7 8 7z" />,
|
||||
flask: <><path d="M9 3h6" /><path d="M10 3v5l-5 9a3 3 0 0 0 2.6 4h8.8a3 3 0 0 0 2.6-4l-5-9V3" /><path d="M8 15h8" /></>,
|
||||
gamepad: <><path d="M6 11h12a4 4 0 0 1 4 4v1a3 3 0 0 1-5.2 2L15 16H9l-1.8 2A3 3 0 0 1 2 16v-1a4 4 0 0 1 4-4z" /><path d="M7 15h4" /><path d="M9 13v4" /><path d="M16.5 14.5h.01" /><path d="M18.5 16.5h.01" /></>,
|
||||
'gamepad-variant-outline': <path fill="currentColor" stroke="none" d="M6,9H8V11H10V13H8V15H6V13H4V11H6V9M18.5,9A1.5,1.5 0 0,1 20,10.5A1.5,1.5 0 0,1 18.5,12A1.5,1.5 0 0,1 17,10.5A1.5,1.5 0 0,1 18.5,9M15.5,12A1.5,1.5 0 0,1 17,13.5A1.5,1.5 0 0,1 15.5,15A1.5,1.5 0 0,1 14,13.5A1.5,1.5 0 0,1 15.5,12M17,5A7,7 0 0,1 24,12A7,7 0 0,1 17,19C15.04,19 13.27,18.2 12,16.9C10.73,18.2 8.96,19 7,19A7,7 0 0,1 0,12A7,7 0 0,1 7,5H17M7,7A5,5 0 0,0 2,12A5,5 0 0,0 7,17C8.64,17 10.09,16.21 11,15H13C13.91,16.21 15.36,17 17,17A5,5 0 0,0 22,12A5,5 0 0,0 17,7H7Z" />,
|
||||
hammer: <><path d="M14 5 5 14" /><path d="m4 15 5 5" /><path d="M12 3h5l4 4-3 3-4-4" /></>,
|
||||
'heart-broken': <path d="M20 8.5c0 6-8 11.5-8 11.5S4 14.5 4 8.5A4.5 4.5 0 0 1 12 6a4.5 4.5 0 0 1 8 2.5zM12 6l-2 4 4 2-2 4" />,
|
||||
list: <><path d="M4 6h16" /><path d="M4 12h16" /><path d="M4 18h10" /><circle cx="3" cy="6" r=".6" fill="currentColor" /><circle cx="3" cy="12" r=".6" fill="currentColor" /><circle cx="3" cy="18" r=".6" fill="currentColor" /></>,
|
||||
loader: <><path d="M12 3a9 9 0 1 0 9 9" /><path d="M21 12a9 9 0 0 0-9-9" /></>,
|
||||
'map-pin': <><path d="M12 21s7-5.2 7-11a7 7 0 1 0-14 0c0 5.8 7 11 7 11z" /><circle cx="12" cy="10" r="2" /></>,
|
||||
menu: <><path d="M4 7h12" /><path d="M4 12h16" /><path d="M4 17h8" /></>,
|
||||
minus: <path d="M5 12h14" />,
|
||||
package: <><path d="M5 8h14v11H5z" /><path d="m8 8 2-4h4l2 4" /><path d="M12 8v11" /></>,
|
||||
pin: <path fill="currentColor" stroke="none" d="M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12Z" />,
|
||||
'pin-off': <path fill="currentColor" stroke="none" d="M2,5.27L3.28,4L20,20.72L18.73,22L12.8,16.07V22H11.2V16H6V14L8,12V11.27L2,5.27M16,12L18,14V16H17.82L8,6.18V4H7V2H17V4H16V12Z" />,
|
||||
plug: <><path d="M8 2v6" /><path d="M16 2v6" /><path d="M7 8h10v4a5 5 0 0 1-10 0z" /><path d="M12 17v5" /></>,
|
||||
play: <path d="m8 5 11 7-11 7z" />,
|
||||
radar: <><circle cx="12" cy="12" r="2" /><path d="M12 4a8 8 0 0 1 8 8" /><path d="M4 12a8 8 0 0 1 8-8" /><path d="M12 20a8 8 0 0 1-8-8" /><path d="M12 12l6-6" /></>,
|
||||
refresh: <><path d="M20 6v5h-5" /><path d="M4 18v-5h5" /><path d="M18 11a6 6 0 0 0-10-4L4 11" /><path d="M6 13a6 6 0 0 0 10 4l4-4" /></>,
|
||||
'shield-bolt': <><path d="M12 3 20 6v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" /><path d="m13 7-4 6h3l-1 4 4-6h-3z" /></>,
|
||||
sparkles: <><path d="m12 3 1.6 5.4L19 10l-5.4 1.6L12 17l-1.6-5.4L5 10l5.4-1.6z" /><path d="m5 16 .8 2.2L8 19l-2.2.8L5 22l-.8-2.2L2 19l2.2-.8z" /></>,
|
||||
plus: <><path d="M12 5v14" /><path d="M5 12h14" /></>,
|
||||
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" /></>,
|
||||
sparkles: <><path d="m12 3 1.6 5.4L19 10l-5.4 1.6L12 17l-1.6-5.4L5 10l5.4-1.6z" /><path d="m5 16 .8 2.2L8 19l-2.2.8L5 22l-.8-2.2L2 19l2.2-.8z" /></>,
|
||||
star: <path d="m12 3 2.7 5.5 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" />,
|
||||
'star-filled': <path fill="currentColor" stroke="none" d="m12 3 2.7 5.5 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" />,
|
||||
stop: <rect x="6" y="6" width="12" height="12" rx="1.5" fill="currentColor" stroke="none" />,
|
||||
swords: <><path d="M14 6 20 0" /><path d="m14 6 4 4" /><path d="M4 20 14 10" /><path d="M10 6 4 0" /><path d="m10 6-4 4" /><path d="M20 20 10 10" /></>,
|
||||
trophy: <><path d="M8 4h8v4a4 4 0 0 1-8 0z" /><path d="M8 6H4a4 4 0 0 0 4 4" /><path d="M16 6h4a4 4 0 0 1-4 4" /><path d="M12 12v5" /><path d="M8 21h8" /></>,
|
||||
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>,
|
||||
wifi: <><path d="M4 9a12 12 0 0 1 16 0" /><path d="M7 12a7 7 0 0 1 10 0" /><path d="M10 15a3 3 0 0 1 4 0" /><path d="M12 19h.01" /></>,
|
||||
world: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18" /><path d="M12 3a15 15 0 0 1 0 18" /><path d="M12 3a15 15 0 0 0 0 18" /></>,
|
||||
x: <><path d="M6 6l12 12" /><path d="M18 6 6 18" /></>,
|
||||
};
|
||||
@@ -96,4 +102,4 @@ export function Icon({ name, className, stroke = 1.8, ...props }: IconProps) {
|
||||
{ICON_PATHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-7 w-full min-w-0 rounded-md border border-input bg-input/20 px-2 py-0.5 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs/relaxed leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
disabled,
|
||||
onValueChange,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
...props
|
||||
}: Omit<ComponentProps<"input">, "defaultValue" | "onChange" | "type" | "value"> & {
|
||||
defaultValue?: number
|
||||
onValueChange?: (value: number) => void
|
||||
value?: number
|
||||
}) {
|
||||
const currentValue = Number(value ?? defaultValue ?? min)
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
disabled={disabled}
|
||||
className={cn("h-2 w-full cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(event) => onValueChange?.(Number(event.currentTarget.value))}
|
||||
{...props}
|
||||
aria-valuenow={currentValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
checked = false,
|
||||
className,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
size = "default",
|
||||
...props
|
||||
}: Omit<ComponentProps<"button">, "onChange"> & {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-checked={checked}
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
disabled={disabled}
|
||||
role="switch"
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
size === "sm" ? "h-3.5 w-6" : "h-4 w-7",
|
||||
checked ? "bg-primary" : "bg-input",
|
||||
className
|
||||
)}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full bg-background ring-0 transition-transform",
|
||||
size === "sm" ? "size-3" : "size-3.5",
|
||||
checked ? "translate-x-[calc(100%-2px)]" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,42 @@
|
||||
import { loadJson, saveJson } from './storage';
|
||||
|
||||
export const DEFAULT_ACCENT_COLOR = '#00ffd5';
|
||||
|
||||
const ACCENT_COLOR_STORAGE_KEY = 'wand-remote.accent-color.v1';
|
||||
const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
export function applySavedAccentColor(): string {
|
||||
return applyAccentColor(loadAccentColor());
|
||||
}
|
||||
|
||||
export function loadAccentColor(): string {
|
||||
return loadJson<string>(ACCENT_COLOR_STORAGE_KEY, reviveAccentColor, DEFAULT_ACCENT_COLOR);
|
||||
}
|
||||
|
||||
export function setAccentColor(value: string): string {
|
||||
const nextColor = normalizeAccentColor(value) ?? DEFAULT_ACCENT_COLOR;
|
||||
applyAccentColor(nextColor);
|
||||
saveJson(ACCENT_COLOR_STORAGE_KEY, nextColor, (storedValue) => storedValue === DEFAULT_ACCENT_COLOR);
|
||||
return nextColor;
|
||||
}
|
||||
|
||||
function applyAccentColor(value: string): string {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.setProperty('--deck-accent', value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function reviveAccentColor(raw: unknown): string | null {
|
||||
return normalizeAccentColor(raw);
|
||||
}
|
||||
|
||||
function normalizeAccentColor(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
import { formatHumanLabel } from '@/lib/utils';
|
||||
import type { CheatSchema, TrainerMetaPayload, TrainerSummary } from './protocol';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -41,19 +42,6 @@ const CATEGORY_ICONS: Record<string, IconName> = {
|
||||
world: 'world',
|
||||
};
|
||||
|
||||
const CATEGORY_ACCENTS: Record<string, string> = {
|
||||
challenge: 'text-orange-300 bg-orange-500/12 ring-orange-300/30',
|
||||
enemies: 'text-red-300 bg-red-500/12 ring-red-300/30',
|
||||
inventory: 'text-amber-200 bg-amber-500/12 ring-amber-200/30',
|
||||
physics: 'text-cyan-200 bg-cyan-500/12 ring-cyan-200/30',
|
||||
player: 'text-emerald-200 bg-emerald-500/12 ring-emerald-200/30',
|
||||
stats: 'text-fuchsia-200 bg-fuchsia-500/12 ring-fuchsia-200/30',
|
||||
teleport: 'text-sky-200 bg-sky-500/12 ring-sky-200/30',
|
||||
vehicles: 'text-lime-200 bg-lime-500/12 ring-lime-200/30',
|
||||
weapons: 'text-rose-200 bg-rose-500/12 ring-rose-200/30',
|
||||
world: 'text-teal-200 bg-teal-500/12 ring-teal-200/30',
|
||||
};
|
||||
|
||||
export type CategoryGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -61,16 +49,7 @@ export type CategoryGroup = {
|
||||
};
|
||||
|
||||
export function formatCategoryName(category: string): string {
|
||||
const key = category.toLowerCase();
|
||||
if (CATEGORY_LABELS[key]) {
|
||||
return CATEGORY_LABELS[key];
|
||||
}
|
||||
|
||||
return category
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
return CATEGORY_LABELS[category.toLowerCase()] ?? formatHumanLabel(category);
|
||||
}
|
||||
|
||||
export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): CategoryGroup[] {
|
||||
@@ -146,10 +125,6 @@ export function getTrainerDisplayName(trainer: TrainerSummary): string {
|
||||
return trainer.displayName?.trim() || trainer.gameId || trainer.titleId || trainer.trainerId;
|
||||
}
|
||||
|
||||
export function getCategoryAccent(category: string): string {
|
||||
return CATEGORY_ACCENTS[category.toLowerCase()] ?? 'text-emerald-200 bg-emerald-500/12 ring-emerald-200/30';
|
||||
}
|
||||
|
||||
export function CategoryIcon({ category, className }: { category: string; className?: string }) {
|
||||
return <Icon className={className} name={CATEGORY_ICONS[category.toLowerCase()] ?? 'gamepad'} stroke={1.8} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CategoryIcon, type CategoryGroup, getCategoryAccent } from '../category';
|
||||
import { CategoryIcon, type CategoryGroup } from '../category';
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { ECheatType } from '../protocol';
|
||||
import { CheatTile } from './CheatTile';
|
||||
|
||||
type CategorySectionProps = {
|
||||
@@ -10,38 +14,49 @@ type CategorySectionProps = {
|
||||
pendingTargets: Record<string, boolean>;
|
||||
pinnedTargets: Record<string, true>;
|
||||
disabled: boolean;
|
||||
openByDefault?: boolean;
|
||||
forceOpen?: boolean;
|
||||
onCheatChange: (cheat: CheatSchema, nextValue: unknown) => void;
|
||||
onTogglePin: (cheat: CheatSchema) => void;
|
||||
};
|
||||
|
||||
export function CategorySection({
|
||||
export const CategorySection = ({
|
||||
group,
|
||||
values,
|
||||
pendingTargets,
|
||||
pinnedTargets,
|
||||
disabled,
|
||||
openByDefault = true,
|
||||
forceOpen = false,
|
||||
onCheatChange,
|
||||
onTogglePin,
|
||||
}: CategorySectionProps) {
|
||||
return (
|
||||
<section className="space-y-2 sm:space-y-3">
|
||||
<header className="flex items-center justify-between gap-2 sm:gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className={cn('flex size-8 items-center justify-center rounded-[8px] ring-1 sm:size-10', getCategoryAccent(group.id))}>
|
||||
<CategoryIcon category={group.id} className="size-4 sm:size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white sm:text-xl">{group.label}</h3>
|
||||
<p className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground sm:text-xs">{group.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">
|
||||
{group.cheats.length} nodes
|
||||
</Badge>
|
||||
</header>
|
||||
}: CategorySectionProps) => {
|
||||
const [open, setOpen] = useState(openByDefault);
|
||||
const enabledCount = useMemo(() => getEnabledToggleCount(group.cheats, values), [group.cheats, values]);
|
||||
const toggleCount = useMemo(() => getToggleCount(group.cheats), [group.cheats]);
|
||||
const handleToggle = () => setOpen((current) => !current);
|
||||
|
||||
<div className="grid gap-2 sm:gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{group.cheats.map((cheat) => (
|
||||
useEffect(() => {
|
||||
if (forceOpen) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [forceOpen]);
|
||||
|
||||
return (
|
||||
<section className="mb-2.5 overflow-hidden rounded-[14px] border border-white/10 bg-white/[0.035] shadow-[inset_0_1px_0_rgba(255,255,255,.05)] backdrop-blur-2xl">
|
||||
<button type="button" className="flex w-full items-center gap-2.5 px-3.5 py-3 text-left text-(--deck-fg)" onClick={handleToggle}>
|
||||
<span className="flex size-[30px] shrink-0 items-center justify-center rounded-[8px] border border-[color-mix(in_oklab,var(--deck-accent)_22%,transparent)] bg-white/[0.04] text-(--deck-accent)">
|
||||
<CategoryIcon category={group.id} className="size-[15px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">{group.label}</span>
|
||||
<span className="mt-0.5 block font-mono text-[10.5px] text-(--deck-fg-4)">{formatSummary(group.cheats.length, enabledCount, toggleCount)}</span>
|
||||
</span>
|
||||
{enabledCount > 0 ? <span className="inline-flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full bg-(--deck-accent) px-1.5 text-center font-mono text-[10px] font-bold leading-none tabular-nums text-black">{enabledCount}</span> : null}
|
||||
<Icon className={cn('size-4 text-(--deck-fg-3) transition-transform', open ? 'rotate-0' : '-rotate-90')} name="chevron-down" />
|
||||
</button>
|
||||
<div className={cn('overflow-hidden transition-[max-height] duration-300', open ? 'max-h-[4000px]' : 'max-h-0')}>
|
||||
{group.cheats.map((cheat, index) => (
|
||||
<CheatTile
|
||||
key={cheat.uuid}
|
||||
cheat={cheat}
|
||||
@@ -49,6 +64,7 @@ export function CategorySection({
|
||||
pending={Boolean(pendingTargets[cheat.target])}
|
||||
pinned={Boolean(pinnedTargets[cheat.target])}
|
||||
disabled={disabled}
|
||||
first={index === 0}
|
||||
onChange={(nextValue) => onCheatChange(cheat, nextValue)}
|
||||
onTogglePin={() => onTogglePin(cheat)}
|
||||
/>
|
||||
@@ -56,4 +72,20 @@ export function CategorySection({
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
function getEnabledToggleCount(cheats: CheatSchema[], values: Record<string, unknown>): number {
|
||||
return cheats.filter((cheat) => cheat.type === ECheatType.Toggle && Boolean(values[cheat.target])).length;
|
||||
}
|
||||
|
||||
function getToggleCount(cheats: CheatSchema[]): number {
|
||||
return cheats.filter((cheat) => cheat.type === ECheatType.Toggle).length;
|
||||
}
|
||||
|
||||
function formatSummary(cheatCount: number, enabledCount: number, toggleCount: number): string {
|
||||
if (toggleCount <= 0) {
|
||||
return `${cheatCount} mods`;
|
||||
}
|
||||
|
||||
return `${cheatCount} mods · ${enabledCount}/${toggleCount} on`;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { ECheatType } from '../protocol';
|
||||
import { CheatControl } from '../controls/CheatControl';
|
||||
|
||||
type CheatTileProps = {
|
||||
@@ -11,43 +13,154 @@ type CheatTileProps = {
|
||||
pending: boolean;
|
||||
disabled: boolean;
|
||||
pinned: boolean;
|
||||
first: boolean;
|
||||
onChange: (nextValue: unknown) => void;
|
||||
onTogglePin: () => void;
|
||||
};
|
||||
|
||||
export function CheatTile({ cheat, value, pending, disabled, pinned, onChange, onTogglePin }: CheatTileProps) {
|
||||
const SWIPE_REVEAL = 80;
|
||||
const SWIPE_TRIGGER = 56;
|
||||
const SWIPE_DEAD_ZONE = 8;
|
||||
const SWIPE_ANIMATION_MS = 220;
|
||||
|
||||
export const CheatTile = ({ cheat, value, pending, disabled, pinned, first, onChange, onTogglePin }: CheatTileProps) => {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [armed, setArmed] = useState(false);
|
||||
const dragRef = useRef<{ id: number; startX: number; startY: number; locked: boolean | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
setArmed(false);
|
||||
}, [pinned]);
|
||||
|
||||
const settle = (target: number) => {
|
||||
setAnimating(true);
|
||||
setOffset(target);
|
||||
window.setTimeout(() => setAnimating(false), SWIPE_ANIMATION_MS);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
if (isInteractiveTarget(event.target)) return;
|
||||
dragRef.current = { id: event.pointerId, startX: event.clientX, startY: event.clientY, locked: null };
|
||||
setAnimating(false);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.id !== event.pointerId) return;
|
||||
|
||||
const dx = event.clientX - drag.startX;
|
||||
const dy = event.clientY - drag.startY;
|
||||
|
||||
if (drag.locked === null) {
|
||||
if (Math.abs(dx) < SWIPE_DEAD_ZONE && Math.abs(dy) < SWIPE_DEAD_ZONE) return;
|
||||
drag.locked = Math.abs(dx) > Math.abs(dy) && dx < 0;
|
||||
if (!drag.locked) {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const next = clamp(dx, -SWIPE_REVEAL * 1.2, 0);
|
||||
setOffset(next);
|
||||
setArmed(-next >= SWIPE_TRIGGER);
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.id !== event.pointerId) return;
|
||||
dragRef.current = null;
|
||||
|
||||
if (drag.locked !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const triggered = -offset >= SWIPE_TRIGGER;
|
||||
settle(0);
|
||||
setArmed(false);
|
||||
if (triggered) {
|
||||
onTogglePin();
|
||||
}
|
||||
};
|
||||
|
||||
const stacked = isStackedControl(cheat);
|
||||
const showReveal = offset < 0;
|
||||
|
||||
return (
|
||||
<Card className="rounded-[8px] border-white/10 bg-white/4.5 shadow-xl shadow-black/20 transition-colors hover:border-emerald-300/25">
|
||||
<CardHeader className="gap-1.5 p-3 sm:gap-2 sm:p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-[0.95rem] font-bold text-white sm:text-base">{cheat.name}</CardTitle>
|
||||
{cheat.description ? <p className="mt-0.5 line-clamp-2 text-[0.78rem] text-muted-foreground sm:text-sm">{cheat.description}</p> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{pending ? <Icon className="size-4 animate-spin text-emerald-200" name="loader" /> : null}
|
||||
<Badge className="hidden border-white/10 bg-black/20 text-white sm:inline-flex" variant="outline">{cheat.type}</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePin}
|
||||
aria-label={pinned ? 'Unpin cheat' : 'Pin cheat'}
|
||||
title={pinned ? 'Unpin' : 'Pin to top'}
|
||||
className={cn(
|
||||
'flex size-7 items-center justify-center rounded-md border border-white/10 transition-colors',
|
||||
pinned
|
||||
? 'bg-amber-300/20 text-amber-200 hover:bg-amber-300/30'
|
||||
: 'bg-white/5 text-muted-foreground hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" name={pinned ? 'pin-off' : 'pin'} />
|
||||
</button>
|
||||
<div className={cn('relative overflow-hidden', first ? '' : 'border-t border-white/[0.06]')}>
|
||||
{showReveal ? <PinReveal pinned={pinned} armed={armed} /> : null}
|
||||
<div
|
||||
className={cn('relative', animating ? 'transition-transform duration-200 ease-out' : '')}
|
||||
style={{ transform: `translate3d(${offset}px, 0, 0)`, touchAction: 'pan-y' }}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-3.5 py-3">
|
||||
<div className={cn('flex gap-3', stacked ? 'flex-col items-stretch' : 'items-start justify-between')}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="truncate text-[13.5px] font-medium leading-tight text-(--deck-fg)">{cheat.name}</h4>
|
||||
{pending ? <Icon className="size-3.5 shrink-0 animate-spin text-(--deck-accent)" name="loader" /> : null}
|
||||
</div>
|
||||
{cheat.description ? <p className="mt-1 line-clamp-2 text-[11.5px] leading-snug text-(--deck-fg-3)">{cheat.description}</p> : null}
|
||||
</div>
|
||||
<CheatControl cheat={cheat} disabled={disabled} pending={pending} value={value} onChange={onChange} />
|
||||
</div>
|
||||
{cheat.instructions ? (
|
||||
<div className="flex gap-2 rounded-[8px] border border-amber-300/25 bg-amber-400/10 px-2.5 py-2 text-[11.5px] leading-5 text-amber-200">
|
||||
<Icon className="mt-0.5 size-3.5 shrink-0" name="alert" />
|
||||
<span>{cheat.instructions}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{cheat.instructions ? <p className="rounded-[8px] border border-amber-300/20 bg-amber-300/10 p-2 text-[0.72rem] text-amber-100 sm:text-xs">{cheat.instructions}</p> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-0 sm:p-4 sm:pt-0">
|
||||
<CheatControl cheat={cheat} value={value} pending={pending} disabled={disabled} onChange={onChange} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PinReveal = ({ pinned, armed }: { pinned: boolean; armed: boolean }) => {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center justify-center px-4">
|
||||
<span className={cn('flex size-9 items-center justify-center rounded-full border transition-all duration-150', armed ? 'scale-110 border-(--deck-accent) bg-[color-mix(in_oklab,var(--deck-accent)_28%,transparent)] text-(--deck-accent) shadow-[0_0_0_4px_color-mix(in_oklab,var(--deck-accent)_18%,transparent)]' : 'border-white/10 bg-white/[0.06] text-(--deck-fg-3)')}>
|
||||
<Icon className="size-4" name={pinned ? 'pin-off' : 'pin'} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function isStackedControl(cheat: CheatSchema): boolean {
|
||||
if (
|
||||
cheat.type === ECheatType.Slider ||
|
||||
cheat.type === ECheatType.Scalar ||
|
||||
cheat.type === ECheatType.Number ||
|
||||
cheat.type === ECheatType.Incremental ||
|
||||
cheat.type === ECheatType.Button
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cheat.type === ECheatType.Selection) {
|
||||
const optionCount = cheat.args.options?.length ?? 0;
|
||||
return optionCount > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function isInteractiveTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(target.closest('input, button, select, textarea, a, [role="slider"], [role="button"]'));
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { DEFAULT_REMOTE_PORT } from '../constants';
|
||||
import type { ConnectionStatus } from '../state';
|
||||
|
||||
type ConnectionPanelProps = {
|
||||
status: ConnectionStatus;
|
||||
wsUrl: string;
|
||||
lastError: string | null;
|
||||
onConnect: () => void;
|
||||
onDebugSession?: () => void;
|
||||
onWsUrlChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function ConnectionPanel({ status, wsUrl, lastError, onConnect, onDebugSession, onWsUrlChange }: ConnectionPanelProps) {
|
||||
return (
|
||||
<Card className="rounded-[8px] border-white/10 bg-white/4.5 shadow-xl shadow-black/25">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold text-white">Bridge uplink</CardTitle>
|
||||
<CardDescription className="mt-1 text-muted-foreground">Default relay port {DEFAULT_REMOTE_PORT}</CardDescription>
|
||||
</div>
|
||||
<Badge className="border border-white/10 bg-white/5 text-white" variant="outline">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ws-url" className="text-[0.7rem] uppercase tracking-[0.12em] text-muted-foreground">
|
||||
WebSocket endpoint
|
||||
</Label>
|
||||
<Input
|
||||
id="ws-url"
|
||||
type="url"
|
||||
value={wsUrl}
|
||||
placeholder={`ws://127.0.0.1:${DEFAULT_REMOTE_PORT}/remote/ws`}
|
||||
className="h-9 rounded-[8px] border-white/10 bg-black/25 font-mono text-[0.8rem] text-white placeholder:text-white/30"
|
||||
onChange={(event) => onWsUrlChange(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button className="h-9 rounded-[8px] bg-emerald-300 text-black hover:bg-emerald-200" onClick={onConnect}>
|
||||
<Icon className="size-4" name="plug" />
|
||||
Connect
|
||||
</Button>
|
||||
{import.meta.env.DEV && onDebugSession ? (
|
||||
<Button className="h-9 rounded-[8px] border-white/10 bg-white/5 text-white hover:bg-white/10" variant="outline" onClick={onDebugSession}>
|
||||
<Icon className="size-4" name="flask" />
|
||||
Debug session
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{lastError ? (
|
||||
<div className="flex items-start gap-2 rounded-[8px] border border-red-300/25 bg-red-500/10 p-3 text-sm text-red-100">
|
||||
<Icon className="mt-0.5 size-4 shrink-0" name="alert" />
|
||||
<span>{lastError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ConnectionStatus } from '../state';
|
||||
|
||||
const STATUS_LABELS: Record<ConnectionStatus, string> = {
|
||||
idle: 'Standby',
|
||||
connecting: 'Linking',
|
||||
connected: 'Live',
|
||||
error: 'Fault',
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<ConnectionStatus, string> = {
|
||||
idle: 'border-amber-300/30 bg-amber-500/10 text-amber-200',
|
||||
connecting: 'border-sky-300/30 bg-sky-500/10 text-sky-200',
|
||||
connected: 'border-emerald-300/30 bg-emerald-500/10 text-emerald-200',
|
||||
error: 'border-red-300/30 bg-red-500/10 text-red-200',
|
||||
};
|
||||
|
||||
export function DeckHeader({ connectionStatus, remoteUrl }: { connectionStatus: ConnectionStatus; remoteUrl: string }) {
|
||||
return (
|
||||
<header className="flex flex-col gap-4 rounded-[8px] border border-white/10 bg-white/[0.035] p-4 shadow-2xl shadow-black/30 backdrop-blur md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-[8px] border border-emerald-300/30 bg-emerald-300/10 text-emerald-200 shadow-lg shadow-emerald-500/10">
|
||||
<Icon className="size-6" name="shield-bolt" stroke={1.7} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-normal text-white">Wand Control Deck</h1>
|
||||
<Badge className="border border-lime-300/30 bg-lime-300/10 text-lime-200" variant="outline">
|
||||
beta
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1"><Icon className="size-3.5" name="wifi" /> local link</span>
|
||||
<span className="text-white/20">/</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1"><Icon className="size-3.5" name="plug" /> <span className="truncate">{remoteUrl.replace(/\/$/, '')}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn('flex w-fit items-center gap-2 rounded-[8px] border px-3 py-2 text-xs font-semibold uppercase tracking-[0.08em]', STATUS_CLASSES[connectionStatus])}>
|
||||
<Icon className="size-4" name="activity" />
|
||||
{STATUS_LABELS[connectionStatus]}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DRAWER_SIDE_CLASSES = {
|
||||
left: 'left-0 border-r',
|
||||
right: 'right-0 border-l',
|
||||
} as const;
|
||||
|
||||
const DRAWER_CLOSED_CLASSES = {
|
||||
left: '-translate-x-full',
|
||||
right: 'translate-x-full',
|
||||
} as const;
|
||||
|
||||
const DRAWER_OVERLAY_CLASS = 'remote-drawer-overlay absolute inset-0 z-30 transition-opacity duration-200 ease-out motion-reduce:transition-none';
|
||||
const DRAWER_PANEL_CLASS = 'remote-drawer-panel absolute bottom-0 top-0 z-40 flex w-[88%] max-w-90 flex-col border-white/10 transition-transform duration-300 ease-out motion-reduce:transition-none';
|
||||
|
||||
type DrawerProps = {
|
||||
open: boolean;
|
||||
side: 'left' | 'right';
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const Drawer = ({ open, side, children, onClose }: DrawerProps) => {
|
||||
const sideClassName = DRAWER_SIDE_CLASSES[side];
|
||||
const closedClassName = DRAWER_CLOSED_CLASSES[side];
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close drawer"
|
||||
className={cn(DRAWER_OVERLAY_CLASS, open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside aria-hidden={!open} className={cn(DRAWER_PANEL_CLASS, sideClassName, open ? 'translate-x-0' : closedClassName)} data-open={open ? 'true' : 'false'}>
|
||||
{children}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
|
||||
export function EmptyDeck() {
|
||||
return (
|
||||
<Card className="rounded-[8px] border-dashed border-white/15 bg-white/[0.035]">
|
||||
<CardContent className="flex min-h-64 flex-col items-center justify-center gap-3 py-10 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-[8px] border border-cyan-300/25 bg-cyan-500/10 text-cyan-200">
|
||||
<Icon className="size-8" name="radar" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">No trainer signal</h2>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">Connect the local bridge to stream trainer controls.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EConnectionStatus } from '../state';
|
||||
|
||||
type FloatingDockProps = {
|
||||
status: EConnectionStatus;
|
||||
runningGameTitle: string | null;
|
||||
hidden: boolean;
|
||||
leftHasBadge: boolean;
|
||||
rightHasBadge: boolean;
|
||||
onOpenSettings: () => void;
|
||||
onOpenLibrary: () => void;
|
||||
};
|
||||
|
||||
export const FloatingDock = ({
|
||||
status,
|
||||
runningGameTitle,
|
||||
hidden,
|
||||
leftHasBadge,
|
||||
rightHasBadge,
|
||||
onOpenSettings,
|
||||
onOpenLibrary,
|
||||
}: FloatingDockProps) => {
|
||||
const live = status === EConnectionStatus.Connected;
|
||||
|
||||
return (
|
||||
<div className={cn('absolute bottom-4.5 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-white/10 bg-[#0e1016]/80 p-1.5 shadow-[0_12px_40px_-10px_rgba(0,0,0,.65),inset_0_1px_0_rgba(255,255,255,.05)] backdrop-blur-2xl transition duration-300', hidden ? 'translate-y-20 opacity-0' : 'translate-y-0 opacity-100')}>
|
||||
<DockButton badge={leftHasBadge} icon="settings" label="Settings" onClick={onOpenSettings} />
|
||||
<div className="flex h-9.5 items-center gap-2 border-x border-white/10 px-3">
|
||||
<span className={cn('size-1.5 rounded-full', live ? 'bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)] motion-safe:animate-[breathe_2s_ease-in-out_infinite]' : 'bg-(--deck-fg-4)')} />
|
||||
<span className="max-w-30 truncate text-[11px] font-semibold text-(--deck-fg-2)">
|
||||
{runningGameTitle || 'No session'}
|
||||
</span>
|
||||
</div>
|
||||
<DockButton badge={rightHasBadge} icon="list" label="Library" onClick={onOpenLibrary} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type DockButtonProps = {
|
||||
badge: boolean;
|
||||
icon: IconName;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const DockButton = ({ badge, icon, label, onClick }: DockButtonProps) => {
|
||||
return (
|
||||
<button type="button" aria-label={label} className="relative flex h-9.5 w-11 items-center justify-center rounded-full text-(--deck-fg) hover:bg-white/6" onClick={onClick}>
|
||||
<Icon className="size-4.5" name={icon} stroke={1.7} />
|
||||
{badge ? <span className="absolute right-2 top-1.5 size-1.5 rounded-full bg-(--deck-accent) shadow-[0_0_4px_var(--deck-accent)]" /> : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getGameCoverLabel, type LibraryGame } from '../game-library';
|
||||
|
||||
type GameCoverProps = {
|
||||
game: LibraryGame;
|
||||
size?: 'sm' | 'lg';
|
||||
};
|
||||
|
||||
const SIZE_CLASSES: Record<NonNullable<GameCoverProps['size']>, string> = {
|
||||
lg: 'size-16 rounded-[10px] text-[8px]',
|
||||
sm: 'size-11 rounded-[9px] text-[7px]',
|
||||
};
|
||||
|
||||
export const GameCover = ({ game, size = 'sm' }: GameCoverProps) => {
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
||||
const sizeClass = SIZE_CLASSES[size];
|
||||
const imageUrl = game.imageUrl && game.imageUrl !== failedUrl ? game.imageUrl : null;
|
||||
|
||||
const handleImageError = () => setFailedUrl(game.imageUrl ?? null);
|
||||
|
||||
return (
|
||||
<div className={`relative shrink-0 overflow-hidden border border-white/6 bg-[linear-gradient(135deg,rgba(238,0,255,0.28),rgba(0,0,0,0.48))] shadow-[inset_0_0_0_1px_rgba(255,255,255,.04),0_2px_6px_rgba(0,0,0,.35)] ${sizeClass}`}>
|
||||
{imageUrl ? <img alt="" className="absolute inset-0 size-full object-cover" src={imageUrl} loading="lazy" onError={handleImageError} /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,.14),transparent_35%,rgba(0,0,0,.42))]" />
|
||||
{game.running ? <span className="absolute right-1 top-1 size-1.5 rounded-full bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)]" /> : null}
|
||||
<div className="absolute inset-x-1 bottom-1 truncate font-mono font-bold uppercase tracking-wider text-white/85 drop-shadow">
|
||||
{getGameCoverLabel(game)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../game-library';
|
||||
import { GameCover } from './GameCover';
|
||||
import { SearchInput } from './SearchInput';
|
||||
|
||||
type LibraryDrawerProps = {
|
||||
games: LibraryGame[];
|
||||
query: string;
|
||||
canLaunch: boolean;
|
||||
onClose: () => void;
|
||||
onPin: (game: LibraryGame) => void;
|
||||
onPlay: (game: LibraryGame) => void;
|
||||
onStop: () => void;
|
||||
onQueryChange: (query: string) => void;
|
||||
};
|
||||
|
||||
export const LibraryDrawer = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => {
|
||||
const filteredGames = filterLibraryGames(games, query);
|
||||
const sections = getLibrarySections(filteredGames);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="remote-glass-header flex items-center gap-2.5 border-b px-3.5 py-3.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-bold text-(--deck-fg)">Library</h2>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">{games.length} games detected</p>
|
||||
</div>
|
||||
<button type="button" aria-label="Close library" className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
|
||||
<Icon className="size-4" name="x" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="border-b border-white/6 px-3.5 py-2.5">
|
||||
<SearchInput value={query} placeholder="Search games" onChange={onQueryChange} />
|
||||
</div>
|
||||
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain pb-6">
|
||||
{sections.running ? (
|
||||
<GameSection accent count={1} icon="dot" title="Now Playing">
|
||||
<GameRow game={sections.running} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />
|
||||
</GameSection>
|
||||
) : null}
|
||||
{sections.pinned.length > 0 ? (
|
||||
<GameSection count={sections.pinned.length} icon="star-filled" title="Favorites">
|
||||
{sections.pinned.map((game) => <GameRow key={game.id} game={game} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />)}
|
||||
</GameSection>
|
||||
) : null}
|
||||
{sections.rest.length > 0 ? (
|
||||
<GameSection count={sections.rest.length} title="All Games">
|
||||
{sections.rest.map((game) => <GameRow key={game.id} game={game} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />)}
|
||||
</GameSection>
|
||||
) : null}
|
||||
{filteredGames.length === 0 ? (
|
||||
<p className="px-8 py-10 text-center text-[13px] text-(--deck-fg-4)">No games match "{query}"</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type GameSectionProps = {
|
||||
title: string;
|
||||
count?: number;
|
||||
icon?: IconName;
|
||||
accent?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const GameSection = ({ title, count, icon, accent = false, children }: GameSectionProps) => {
|
||||
return (
|
||||
<section className="mt-2">
|
||||
<div className="flex items-center gap-2 px-3.5 pb-1.5 pt-3.5">
|
||||
{icon ? <Icon className={cn('size-3', accent ? 'text-(--deck-accent)' : 'text-(--deck-fg-4)')} name={icon} stroke={2} /> : null}
|
||||
<h3 className={cn('font-mono text-[10px] font-bold uppercase tracking-[0.18em]', accent ? 'text-(--deck-accent)' : 'text-(--deck-fg-4)')}>{title}</h3>
|
||||
<div className="h-px flex-1 bg-white/6" />
|
||||
{typeof count === 'number' ? <span className="font-mono text-[10px] text-(--deck-fg-4)">{count}</span> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
type GameRowProps = {
|
||||
game: LibraryGame;
|
||||
canLaunch: boolean;
|
||||
query: string;
|
||||
onPin: (game: LibraryGame) => void;
|
||||
onPlay: (game: LibraryGame) => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps) => {
|
||||
const hours = formatHours(game.hours);
|
||||
const handlePin = () => onPin(game);
|
||||
const handlePlay = () => onPlay(game);
|
||||
|
||||
return (
|
||||
<article className={cn('mx-2 mb-1 flex items-center gap-2.5 rounded-[10px] border px-2.5 py-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]', game.running ? 'border-[color-mix(in_oklab,var(--deck-accent)_24%,transparent)] bg-[color-mix(in_oklab,var(--deck-accent)_7%,transparent)]' : 'border-white/[0.07] bg-white/2.5')}>
|
||||
<GameCover game={game} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate text-[13.5px] font-semibold text-(--deck-fg)">{highlightTitle(game.title, query)}</h4>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 font-mono text-[10.5px] tracking-[0.02em] text-(--deck-fg-3)">
|
||||
<span className="shrink-0">{game.platform.toUpperCase()}</span>
|
||||
{hours ? <span className="shrink-0">· {hours}</span> : null}
|
||||
{game.path ? <span className="min-w-0 truncate opacity-60">· {shortPath(game.path)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<IconButton active={game.pinned} label={game.pinned ? 'Remove favorite' : 'Favorite game'} icon={game.pinned ? 'star-filled' : 'star'} onClick={handlePin} />
|
||||
{game.running ? (
|
||||
<IconButton danger label="Stop playing" icon="stop" onClick={onStop} />
|
||||
) : (
|
||||
<IconButton disabled={!canLaunch || !game.gameId} play label="Play" icon="play" onClick={handlePlay} />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
type IconButtonProps = {
|
||||
icon: IconName;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
play?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const IconButton = ({ icon, label, active = false, danger = false, disabled = false, play = false, onClick }: IconButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'remote-glass-control flex size-7.5 items-center justify-center rounded-[7px] border text-(--deck-fg-2) disabled:cursor-not-allowed disabled:opacity-40',
|
||||
active ? 'border-[color-mix(in_oklab,var(--deck-accent)_38%,transparent)] bg-[color-mix(in_oklab,var(--deck-accent)_16%,transparent)] text-(--deck-accent) shadow-[0_0_14px_-6px_var(--deck-accent)]' : '',
|
||||
play ? 'bg-[color-mix(in_oklab,var(--deck-accent)_15%,transparent)] text-(--deck-accent) ring-1 ring-[color-mix(in_oklab,var(--deck-accent)_35%,transparent)]' : '',
|
||||
danger ? 'bg-red-500/15 text-red-300 ring-1 ring-red-400/30' : '',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className={cn('size-3.5', active ? 'drop-shadow-[0_0_5px_var(--deck-accent)]' : '')} name={icon} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
function highlightTitle(title: string, query: string): ReactNode {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return title;
|
||||
}
|
||||
|
||||
const index = title.toLowerCase().indexOf(normalized);
|
||||
if (index < 0) {
|
||||
return title;
|
||||
}
|
||||
|
||||
const before = title.slice(0, index);
|
||||
const match = title.slice(index, index + query.length);
|
||||
const after = title.slice(index + query.length);
|
||||
return <>{before}<span className="rounded-[3px] bg-[color-mix(in_oklab,var(--deck-accent)_25%,transparent)] px-0.5 text-(--deck-fg)">{match}</span>{after}</>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
|
||||
type PlaceholderStateProps = {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
sub: string;
|
||||
action: string;
|
||||
onAction: () => void;
|
||||
};
|
||||
|
||||
export const PlaceholderState = ({ icon, title, sub, action, onAction }: PlaceholderStateProps) => {
|
||||
return (
|
||||
<div className="flex min-h-145 flex-col items-center justify-center px-10 py-12 text-center">
|
||||
<div className="mb-4 flex size-18 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.05] text-(--deck-accent) shadow-[0_0_0_8px_color-mix(in_oklab,var(--deck-accent)_6%,transparent),0_0_40px_-8px_color-mix(in_oklab,var(--deck-accent)_30%,transparent)] backdrop-blur-xl">
|
||||
<Icon className="size-7" name={icon} stroke={1.6} />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-(--deck-fg)">{title}</h2>
|
||||
<p className="mt-1.5 max-w-65 text-[13px] leading-6 text-(--deck-fg-3)">{sub}</p>
|
||||
<button type="button" className="mt-5 inline-flex items-center gap-2 rounded-[10px] bg-(--deck-accent) px-4 py-2.5 text-[13px] font-bold text-black shadow-[0_8px_24px_-8px_var(--deck-accent)]" onClick={onAction}>
|
||||
{action}
|
||||
<Icon className="size-3.5" name="arrow-right" stroke={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { RemotePreset } from '../preset-storage';
|
||||
|
||||
type QuickActionsProps = {
|
||||
presets: RemotePreset[];
|
||||
onPanic: () => void;
|
||||
onAddPreset: (name: string) => boolean;
|
||||
onApplyPreset: (preset: RemotePreset) => void;
|
||||
onDeletePreset: (presetId: string) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_PRESET_NAME = 'New preset';
|
||||
|
||||
export const QuickActions = ({ presets, onPanic, onAddPreset, onApplyPreset, onDeletePreset }: QuickActionsProps) => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setDraftName('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => setModalOpen(false);
|
||||
|
||||
const handleSubmitPreset = (name: string): boolean => {
|
||||
const saved = onAddPreset(name);
|
||||
if (!saved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setModalOpen(false);
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="remote-scrollbar-hidden mb-3 flex gap-1.5 overflow-x-auto pb-0.5">
|
||||
<Chip icon="bolt" label="Panic Off" variant="danger" onClick={onPanic} />
|
||||
{presets.map((preset) => (
|
||||
<PresetChip key={preset.id} preset={preset} onApply={onApplyPreset} onDelete={onDeletePreset} />
|
||||
))}
|
||||
<Chip icon="plus" label="Add" variant="add" onClick={handleOpenModal} />
|
||||
</div>
|
||||
{modalOpen ? (
|
||||
<PresetModal
|
||||
draftName={draftName}
|
||||
onClose={handleCloseModal}
|
||||
onDraftNameChange={setDraftName}
|
||||
onSubmit={handleSubmitPreset}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ChipProps = {
|
||||
icon: 'bolt' | 'plus';
|
||||
label: string;
|
||||
variant: 'add' | 'danger';
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const Chip = ({ icon, label, variant, onClick }: ChipProps) => {
|
||||
return (
|
||||
<button type="button" className={cn('inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[11.5px] font-semibold backdrop-blur-xl', getChipVariantClass(variant))} onClick={onClick}>
|
||||
<Icon className="size-3" name={icon} stroke={2} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
type PresetChipProps = {
|
||||
preset: RemotePreset;
|
||||
onApply: (preset: RemotePreset) => void;
|
||||
onDelete: (presetId: string) => void;
|
||||
};
|
||||
|
||||
const PresetChip = ({ preset, onApply, onDelete }: PresetChipProps) => {
|
||||
const handleApply = () => onApply(preset);
|
||||
const handleDelete = () => onDelete(preset.id);
|
||||
|
||||
return (
|
||||
<span className="inline-flex shrink-0 overflow-hidden rounded-full border border-white/10 bg-white/[0.055] text-[11.5px] font-semibold text-(--deck-fg-2) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] backdrop-blur-xl">
|
||||
<button type="button" className="inline-flex h-[30px] max-w-[140px] items-center gap-1.5 px-3" onClick={handleApply}>
|
||||
<Icon className="size-3 shrink-0 text-(--deck-accent)" name="sparkles" stroke={2} />
|
||||
<span className="truncate">{preset.name}</span>
|
||||
</button>
|
||||
<button type="button" aria-label={`Delete preset ${preset.name}`} className="flex h-[30px] w-7 items-center justify-center border-l border-white/10 text-(--deck-fg-4) hover:text-(--deck-fg)" onClick={handleDelete}>
|
||||
<Icon className="size-3" name="x" stroke={2.1} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
type PresetModalProps = {
|
||||
draftName: string;
|
||||
onClose: () => void;
|
||||
onDraftNameChange: (name: string) => void;
|
||||
onSubmit: (name: string) => boolean;
|
||||
};
|
||||
|
||||
const PresetModal = ({ draftName, onClose, onDraftNameChange, onSubmit }: PresetModalProps) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const trimmedName = draftName.trim();
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onDraftNameChange(event.currentTarget.value);
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const nextName = trimmedName || DEFAULT_PRESET_NAME;
|
||||
onSubmit(nextName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/45 p-3 backdrop-blur-[6px] sm:items-center">
|
||||
<button type="button" aria-label="Close preset modal" className="absolute inset-0" onClick={onClose} />
|
||||
<form className="remote-glass-drawer relative w-full max-w-[362px] rounded-[18px] border border-white/10 p-4" onSubmit={handleSubmit}>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[15px] font-bold text-(--deck-fg)">Add Preset</h3>
|
||||
</div>
|
||||
<button type="button" aria-label="Close preset modal" className="remote-glass-control flex size-8 shrink-0 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
|
||||
<Icon className="size-4" name="x" />
|
||||
</button>
|
||||
</div>
|
||||
<label className="mb-3 block">
|
||||
<span className="mb-1.5 block font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-(--deck-fg-4)">Name</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftName}
|
||||
placeholder="Preset name"
|
||||
maxLength={32}
|
||||
spellCheck={false}
|
||||
className="remote-glass-control h-11 w-full rounded-[10px] border px-3 text-[13px] font-semibold text-(--deck-fg) outline-none placeholder:text-(--deck-fg-4)"
|
||||
onInput={handleInput}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" className="remote-glass-control h-10 rounded-[10px] border text-[12px] font-semibold text-(--deck-fg-2)" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="h-10 rounded-[10px] bg-(--deck-accent) text-[12px] font-bold text-black shadow-[0_8px_24px_-8px_var(--deck-accent)]">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getChipVariantClass(variant: ChipProps['variant']): string {
|
||||
if (variant === 'danger') {
|
||||
return 'border-red-400/30 bg-red-500/10 text-red-300 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]';
|
||||
}
|
||||
|
||||
return 'border-white/10 bg-white/[0.055] text-(--deck-fg-2) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]';
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SearchInputProps = {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const SearchInput = ({ value, placeholder, className, onChange }: SearchInputProps) => {
|
||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onChange(event.currentTarget.value);
|
||||
const handleClear = () => onChange('');
|
||||
|
||||
return (
|
||||
<div className={cn('remote-glass-control flex h-9.5 items-center gap-2 rounded-[10px] border px-2.5', className)}>
|
||||
<Icon className="size-3.5 shrink-0 text-(--deck-fg-3)" name="search" />
|
||||
<input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
className="min-w-0 flex-1 bg-transparent text-[13px] text-(--deck-fg) outline-none placeholder:text-(--deck-fg-4)"
|
||||
onInput={handleInput}
|
||||
/>
|
||||
{value ? (
|
||||
<button type="button" aria-label="Clear search" className="flex size-6 items-center justify-center rounded-[7px] text-(--deck-fg-3) hover:bg-white/6 hover:text-(--deck-fg)" onClick={handleClear}>
|
||||
<Icon className="size-3.5" name="x" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { DEFAULT_ACCENT_COLOR, loadAccentColor, setAccentColor } from '../accent-storage';
|
||||
import { DEFAULT_REMOTE_PORT } from '../constants';
|
||||
import type { LibraryGame } from '../game-library';
|
||||
import type { TrainerSummary } from '../protocol';
|
||||
import { EConnectionStatus } from '../state';
|
||||
import { StatusPill } from './StatusPill';
|
||||
|
||||
const ACCENT_OPTIONS = [
|
||||
{ value: '#3B82F6', label: 'Cobalt', swatchClass: 'bg-[#3B82F6]' },
|
||||
{ value: DEFAULT_ACCENT_COLOR, label: 'Cyan', swatchClass: 'bg-[#00FFD5]' },
|
||||
{ value: '#FF2E63', label: 'Crimson', swatchClass: 'bg-[#FF2E63]' },
|
||||
{ value: '#A78BFA', label: 'Violet', swatchClass: 'bg-[#A78BFA]' },
|
||||
{ value: '#7CFF5B', label: 'Lime', swatchClass: 'bg-[#7CFF5B]' },
|
||||
{ value: '#FFB12E', label: 'Amber', swatchClass: 'bg-[#FFB12E]' },
|
||||
{ value: '#ee00ff', label: 'Magenta', swatchClass: 'bg-[#ee00ff]' },
|
||||
];
|
||||
|
||||
type SettingsDrawerProps = {
|
||||
status: EConnectionStatus;
|
||||
wsUrl: string;
|
||||
currentGame: LibraryGame | null;
|
||||
currentTrainer: TrainerSummary | null;
|
||||
lastError: string | null;
|
||||
onClose: () => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
onWsUrlChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const SettingsDrawer = ({
|
||||
status,
|
||||
wsUrl,
|
||||
currentGame,
|
||||
currentTrainer,
|
||||
lastError,
|
||||
onClose,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onWsUrlChange,
|
||||
}: SettingsDrawerProps) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="remote-glass-header flex items-center justify-between border-b px-3.5 py-3.5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-(--deck-fg)">Settings</h2>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">wand remote · port {DEFAULT_REMOTE_PORT}</p>
|
||||
</div>
|
||||
<button type="button" aria-label="Close settings" className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
|
||||
<Icon className="size-4" name="x" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain p-3.5">
|
||||
<BridgeControl status={status} wsUrl={wsUrl} onConnect={onConnect} onDisconnect={onDisconnect} onWsUrlChange={onWsUrlChange} />
|
||||
{lastError ? <ErrorPanel message={lastError} /> : null}
|
||||
|
||||
<SectionHeader title="Session" />
|
||||
<SessionPanel currentGame={currentGame} currentTrainer={currentTrainer} />
|
||||
|
||||
<SectionHeader title="Accent Color" />
|
||||
<AccentPicker />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type BridgeControlProps = {
|
||||
status: EConnectionStatus;
|
||||
wsUrl: string;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
onWsUrlChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }: BridgeControlProps) => {
|
||||
const live = status === EConnectionStatus.Connected;
|
||||
const connecting = status === EConnectionStatus.Connecting;
|
||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onWsUrlChange(event.currentTarget.value);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-(--deck-fg-4)">Bridge</h3>
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
<div className="remote-glass-control flex h-10 items-stretch overflow-hidden rounded-[10px] border">
|
||||
<input
|
||||
value={wsUrl}
|
||||
placeholder={`ws://127.0.0.1:${DEFAULT_REMOTE_PORT}/remote/ws`}
|
||||
spellCheck={false}
|
||||
className="min-w-0 flex-1 bg-transparent px-3 font-mono text-[12.5px] text-(--deck-fg) outline-none placeholder:text-(--deck-fg-4)"
|
||||
onInput={handleInput}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={connecting}
|
||||
className={cn('px-4 text-[11px] font-bold tracking-[0.08em] disabled:cursor-wait disabled:opacity-70', live ? 'bg-red-500/15 text-red-300' : 'bg-(--deck-accent) text-black')}
|
||||
onClick={live ? onDisconnect : onConnect}
|
||||
>
|
||||
{getBridgeButtonLabel(status)}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorPanel = ({ message }: { message: string }) => {
|
||||
return (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-[10px] border border-red-400/25 bg-red-500/10 p-3 text-[12px] leading-5 text-red-100">
|
||||
<Icon className="mt-0.5 size-3.5 shrink-0" name="alert" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGame | null; currentTrainer: TrainerSummary | null }) => {
|
||||
if (!currentGame) {
|
||||
return <div className="remote-glass-control rounded-[10px] border p-3 text-[12px] text-(--deck-fg-3)">No active game session.</div>;
|
||||
}
|
||||
|
||||
const subtitleBase = currentTrainer?.displayName ?? currentGame.platform;
|
||||
const subtitleVersion = currentTrainer?.gameVersion ? ` · v${currentTrainer.gameVersion}` : '';
|
||||
const sessionSubtitle = `${subtitleBase}${subtitleVersion}`;
|
||||
|
||||
return (
|
||||
<div className="remote-glass-control rounded-[10px] border p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="size-1.5 rounded-full bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)]" />
|
||||
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.12em] text-(--deck-accent)">Active Session</span>
|
||||
</div>
|
||||
<h3 className="truncate text-sm font-semibold text-(--deck-fg)">{currentGame.title}</h3>
|
||||
<p className="mt-0.5 truncate font-mono text-[11px] text-(--deck-fg-3)">
|
||||
{sessionSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccentPicker = () => {
|
||||
const [current, setCurrent] = useState(loadAccentColor);
|
||||
|
||||
const applyAccent = (value: string) => {
|
||||
setCurrent(setAccentColor(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{ACCENT_OPTIONS.map((option) => {
|
||||
const active = current.toLowerCase() === option.value.toLowerCase();
|
||||
return (
|
||||
<button key={option.value} type="button" className={cn('remote-glass-control flex items-center gap-1.5 rounded-[9px] border px-2 py-2 text-[12px] font-medium', active ? 'border-(--deck-accent) text-(--deck-fg)' : 'text-(--deck-fg-3)')} onClick={() => applyAccent(option.value)}>
|
||||
<span className={cn('size-3.5 shrink-0 rounded-lg border border-white/10', option.swatchClass)} />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="remote-glass-control flex h-9.5 items-center gap-2 rounded-[9px] border px-2.5">
|
||||
<span className="flex-1 font-mono text-[11px] font-semibold uppercase tracking-[0.08em] text-(--deck-fg-3)">Custom</span>
|
||||
<span className="font-mono text-[11px] text-(--deck-fg-4)">{current}</span>
|
||||
<input type="color" value={current} className="size-5 rounded border-0 bg-transparent p-0" onChange={(event) => applyAccent(event.currentTarget.value)} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pb-1.5 pt-4">
|
||||
<h3 className="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-(--deck-fg-4)">{title}</h3>
|
||||
<div className="h-px flex-1 bg-white/6" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getBridgeButtonLabel(status: EConnectionStatus): string {
|
||||
if (status === EConnectionStatus.Connected) {
|
||||
return 'STOP';
|
||||
}
|
||||
|
||||
if (status === EConnectionStatus.Connecting) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
return 'GO';
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EConnectionStatus } from '../state';
|
||||
|
||||
const STATUS_LABELS: Record<EConnectionStatus, string> = {
|
||||
[EConnectionStatus.Connected]: 'LIVE',
|
||||
[EConnectionStatus.Connecting]: 'LINKING',
|
||||
[EConnectionStatus.Error]: 'OFFLINE',
|
||||
[EConnectionStatus.Idle]: 'OFFLINE',
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<EConnectionStatus, string> = {
|
||||
[EConnectionStatus.Connected]: 'border-[color-mix(in_oklab,var(--deck-accent)_30%,transparent)] text-(--deck-accent)',
|
||||
[EConnectionStatus.Connecting]: 'border-amber-300/30 text-amber-300',
|
||||
[EConnectionStatus.Error]: 'border-white/10 text-(--deck-fg-4)',
|
||||
[EConnectionStatus.Idle]: 'border-white/10 text-(--deck-fg-4)',
|
||||
};
|
||||
|
||||
export const StatusPill = ({ status }: { status: EConnectionStatus }) => {
|
||||
const live = status === EConnectionStatus.Connected || status === EConnectionStatus.Connecting;
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1.5 rounded-full border bg-white/[0.04] px-2.5 py-1 font-mono text-[9.5px] font-bold tracking-[0.12em] backdrop-blur-md', STATUS_CLASSES[status])}>
|
||||
{live ? <span className="size-1.5 rounded-full bg-current shadow-[0_0_6px_currentColor] motion-safe:animate-[breathe_1.6s_ease-in-out_infinite]" /> : null}
|
||||
{STATUS_LABELS[status]}
|
||||
{status === EConnectionStatus.Error ? <Icon className="size-3" name="alert" /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
|
||||
import type { LibraryGame } from '../game-library';
|
||||
import type { TrainerSummary } from '../protocol';
|
||||
import type { EConnectionStatus } from '../state';
|
||||
import { StatusPill } from './StatusPill';
|
||||
|
||||
type TopBarProps = {
|
||||
status: EConnectionStatus;
|
||||
currentGame: LibraryGame | null;
|
||||
runningTrainer: TrainerSummary | null;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
export const TopBar = ({ status, currentGame, runningTrainer, onOpenSettings }: TopBarProps) => {
|
||||
return (
|
||||
<header className="remote-glass-header sticky top-0 z-20 border-b px-3.5 pb-2.5 pt-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button type="button" aria-label="Settings" className="remote-glass-control flex size-[34px] shrink-0 items-center justify-center rounded-[9px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onOpenSettings}>
|
||||
<Icon className="size-[18px]" name="menu" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-[9.5px] font-bold tracking-[0.16em] text-(--deck-fg-4)">WAND · REMOTE DECK</div>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-(--deck-fg)">
|
||||
{currentGame ? currentGame.title : 'Idle · no game'}
|
||||
</span>
|
||||
{currentGame && runningTrainer?.gameVersion ? (
|
||||
<span className="shrink-0 rounded-[4px] bg-[color-mix(in_oklab,var(--deck-accent)_12%,transparent)] px-1.5 py-0.5 font-mono text-[9.5px] font-bold tracking-[0.06em] text-(--deck-accent)">
|
||||
v{runningTrainer.gameVersion}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { getTrainerDisplayName } from '../category';
|
||||
import type { LibraryGame } from '../game-library';
|
||||
import type { TrainerSummary } from '../protocol';
|
||||
import { GameCover } from './GameCover';
|
||||
|
||||
type TrainerHeaderProps = {
|
||||
trainer: TrainerSummary;
|
||||
game: LibraryGame | null;
|
||||
isPinned: boolean;
|
||||
onPin: () => void;
|
||||
};
|
||||
|
||||
export const TrainerHeader = ({ trainer, game, isPinned, onPin }: TrainerHeaderProps) => {
|
||||
return (
|
||||
<section className="relative mb-3 mt-3.5 overflow-hidden rounded-[14px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.015))] p-3.5 shadow-[0_8px_32px_-12px_rgba(0,0,0,.5),inset_0_1px_0_rgba(255,255,255,.06)]">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_90%_-20%,color-mix(in_oklab,var(--deck-accent)_28%,transparent),transparent_55%)]" />
|
||||
<div className="relative flex items-center gap-3">
|
||||
{game ? <GameCover game={game} size="lg" /> : <FallbackCover />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-1.5 rounded-full bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)] motion-safe:animate-[breathe_2s_ease-in-out_infinite]" />
|
||||
<span className="font-mono text-[9.5px] font-bold uppercase tracking-[0.16em] text-(--deck-accent)">Trainer Active</span>
|
||||
</div>
|
||||
<h2 className="mt-1 truncate text-base font-bold leading-tight text-(--deck-fg)">{getTrainerDisplayName(trainer)}</h2>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2.5 font-mono text-[11px] text-(--deck-fg-3)">
|
||||
<span className="truncate uppercase">{game?.platform ?? 'Wand'}</span>
|
||||
{trainer.gameVersion ? <span className="shrink-0">· v{trainer.gameVersion}</span> : null}
|
||||
<span className="min-w-0 truncate">· #{trainer.trainerId}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" aria-label={isPinned ? 'Remove favorite' : 'Favorite game'} disabled={!game} className="flex size-8.5 shrink-0 items-center justify-center rounded-[9px] border border-white/10 bg-white/5 text-(--deck-fg-3) backdrop-blur-xl hover:text-(--deck-fg) disabled:cursor-not-allowed disabled:opacity-35 data-[active=true]:border-[color-mix(in_oklab,var(--deck-accent)_35%,transparent)] data-[active=true]:bg-[color-mix(in_oklab,var(--deck-accent)_18%,transparent)] data-[active=true]:text-(--deck-accent) data-[active=true]:shadow-[0_0_18px_-8px_var(--deck-accent)]" data-active={isPinned} onClick={onPin}>
|
||||
<Icon className={cn('size-4', isPinned ? 'drop-shadow-[0_0_5px_var(--deck-accent)]' : '')} name={isPinned ? 'star-filled' : 'star'} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const FallbackCover = () => {
|
||||
return (
|
||||
<div className="relative size-16 shrink-0 overflow-hidden rounded-[10px] border border-white/6 bg-[linear-gradient(135deg,rgba(238,0,255,0.28),rgba(0,0,0,0.48))] shadow-[inset_0_0_0_1px_rgba(255,255,255,.04),0_2px_6px_rgba(0,0,0,.35)]">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,.14),transparent_35%,rgba(0,0,0,.42))]" />
|
||||
<div className="absolute inset-x-1 bottom-1 truncate font-mono text-[8px] font-bold uppercase tracking-wider text-white/85 drop-shadow">WAND</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { getTrainerDisplayName } from '../category';
|
||||
import type { TrainerSummary } from '../protocol';
|
||||
|
||||
type TrainerOverviewProps = {
|
||||
trainer: TrainerSummary;
|
||||
cheatCount: number;
|
||||
categoryCount: number;
|
||||
};
|
||||
|
||||
export function TrainerOverview({ trainer, cheatCount, categoryCount }: TrainerOverviewProps) {
|
||||
return (
|
||||
<section className="grid gap-2 sm:gap-3 lg:grid-cols-[minmax(0,1.6fr)_repeat(2,minmax(160px,0.7fr))]">
|
||||
<Card className="rounded-[8px] border-emerald-300/20 bg-emerald-300/8 shadow-xl shadow-emerald-950/20">
|
||||
<CardContent className="flex items-center gap-2.5 px-3 py-3 sm:gap-3 sm:px-4 sm:py-4">
|
||||
<div className="flex size-10 items-center justify-center rounded-[8px] border border-emerald-300/25 bg-black/25 text-emerald-200 sm:size-12">
|
||||
<Icon className="size-5 sm:size-7" name="gamepad" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-emerald-100/70 sm:text-[0.68rem]">Active trainer</p>
|
||||
<h2 className="truncate text-lg font-bold text-white sm:text-2xl">{getTrainerDisplayName(trainer)}</h2>
|
||||
<div className="mt-1 flex flex-wrap gap-1 sm:gap-1.5">
|
||||
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">{trainer.gameVersion ?? 'unknown build'}</Badge>
|
||||
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">{trainer.language ?? 'n/a'}</Badge>
|
||||
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">#{trainer.trainerId}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3 lg:contents">
|
||||
<StatCard icon={<Icon className="size-5" name="boxes" />} label="Cheats" value={cheatCount} />
|
||||
<StatCard icon={<Icon className="size-5" name="category" />} label="Loadouts" value={categoryCount} />
|
||||
</div>
|
||||
|
||||
{trainer.needsCompatibilityWarning ? (
|
||||
<Card className="rounded-[8px] border-orange-300/25 bg-orange-500/10 lg:col-span-3">
|
||||
<CardContent className="flex items-center gap-2 px-3 py-2.5 text-orange-100 sm:px-4 sm:py-3">
|
||||
<Icon className="size-4" name="trophy" />
|
||||
Compatibility warning active
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
|
||||
return (
|
||||
<Card className="rounded-[8px] border-white/10 bg-white/4.5">
|
||||
<CardContent className="flex items-center justify-between gap-2 px-3 py-3 sm:gap-3 sm:px-4 sm:py-4">
|
||||
<div>
|
||||
<p className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground sm:text-[0.68rem]">{label}</p>
|
||||
<p className="text-2xl font-bold text-white sm:text-3xl">{value}</p>
|
||||
</div>
|
||||
<div className="flex size-9 items-center justify-center rounded-[8px] border border-amber-300/20 bg-amber-300/10 text-amber-200 sm:size-10">
|
||||
{icon}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
|
||||
import type { ControlInternalProps } from './shared';
|
||||
|
||||
export const ActionButton = ({ cheat, disabled, onChange }: ControlInternalProps) => {
|
||||
const handleClick = () => onChange(1);
|
||||
const label = typeof cheat.args.button === 'string' ? cheat.args.button : 'Apply';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] bg-(--deck-accent) px-3 text-[13px] font-semibold text-black shadow-[0_8px_24px_-8px_color-mix(in_oklab,var(--deck-accent)_50%,transparent)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon className="size-3.5" name="bolt" stroke={2} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CheatSchema, CheatOption } from '../protocol';
|
||||
import { resolveOption } from '../protocol';
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { ECheatType } from '../protocol';
|
||||
import { ActionButton } from './ActionButton';
|
||||
import { IncrementalControl } from './IncrementalControl';
|
||||
import { NumberControl } from './NumberControl';
|
||||
import { ScalarControl } from './ScalarControl';
|
||||
import { SelectionControl } from './SelectionControl';
|
||||
import { SliderControl } from './SliderControl';
|
||||
import { ToggleControl } from './ToggleControl';
|
||||
import type { ControlInternalProps } from './shared';
|
||||
|
||||
type CheatControlProps = {
|
||||
cheat: CheatSchema;
|
||||
@@ -15,168 +19,21 @@ type CheatControlProps = {
|
||||
onChange: (nextValue: unknown) => void;
|
||||
};
|
||||
|
||||
function renderValue(value: unknown, postfix?: string): string {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'On' : 'Off';
|
||||
const CONTROL_BY_TYPE: Record<ECheatType, (props: ControlInternalProps) => ReactElement> = {
|
||||
[ECheatType.Toggle]: (props) => <ToggleControl {...props} />,
|
||||
[ECheatType.Slider]: (props) => <SliderControl {...props} />,
|
||||
[ECheatType.Number]: (props) => <NumberControl {...props} />,
|
||||
[ECheatType.Button]: (props) => <ActionButton {...props} />,
|
||||
[ECheatType.Selection]: (props) => <SelectionControl {...props} />,
|
||||
[ECheatType.Scalar]: (props) => <ScalarControl {...props} />,
|
||||
[ECheatType.Incremental]: (props) => <IncrementalControl {...props} />,
|
||||
};
|
||||
|
||||
export const CheatControl = ({ cheat, value, pending, disabled, onChange }: CheatControlProps) => {
|
||||
const Renderer = CONTROL_BY_TYPE[cheat.type];
|
||||
if (!Renderer) {
|
||||
return <span className="text-[12px] text-red-200">Unsupported: {cheat.type}</span>;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return `${String(value)}${postfix ?? ''}`;
|
||||
}
|
||||
|
||||
function numericValue(value: unknown, fallback: number): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function optionKey(option: CheatOption): string {
|
||||
return String(option.value);
|
||||
}
|
||||
|
||||
function findOption(options: CheatOption[], value: string): CheatOption | undefined {
|
||||
return options.find((option) => String(option.value) === value);
|
||||
}
|
||||
|
||||
function isSameOption(left: unknown, right: unknown): boolean {
|
||||
return String(left) === String(right);
|
||||
}
|
||||
|
||||
export function CheatControl({ cheat, value, pending, disabled, onChange }: CheatControlProps) {
|
||||
const commonDisabled = disabled || pending;
|
||||
const options = cheat.args.options?.map(resolveOption) ?? [];
|
||||
|
||||
if (cheat.type === 'toggle') {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-[8px] border border-white/10 bg-black/20 p-3">
|
||||
<span className="text-sm font-semibold text-white">{renderValue(value)}</span>
|
||||
<Switch checked={Boolean(value)} disabled={commonDisabled} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'slider') {
|
||||
const min = cheat.args.min ?? 0;
|
||||
const max = cheat.args.max ?? 100;
|
||||
const currentValue = numericValue(value, min);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-[8px] border border-white/10 bg-black/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">Range</span>
|
||||
<span className="rounded-[6px] border border-emerald-300/25 bg-emerald-300/10 px-2 py-1 font-mono text-sm text-emerald-100">
|
||||
{renderValue(currentValue, cheat.args.postfix)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={cheat.args.step ?? 1}
|
||||
value={currentValue}
|
||||
disabled={commonDisabled}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'number') {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 rounded-[8px] border border-white/10 bg-black/20 p-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={cheat.args.min}
|
||||
max={cheat.args.max}
|
||||
step={cheat.args.step ?? 1}
|
||||
value={String(value ?? '')}
|
||||
disabled={commonDisabled}
|
||||
className="h-9 rounded-[8px] border-white/10 bg-white/5 text-white"
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
/>
|
||||
<span className="flex min-w-16 items-center justify-center rounded-[8px] border border-amber-300/25 bg-amber-300/10 px-2 font-mono text-sm text-amber-100">
|
||||
{renderValue(value, cheat.args.postfix)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'button') {
|
||||
return (
|
||||
<Button className="h-10 w-full rounded-[8px] bg-amber-300 text-black hover:bg-amber-200" disabled={commonDisabled} onClick={() => onChange(1)}>
|
||||
<Icon className="size-4" name="play" />
|
||||
{typeof cheat.args.button === 'string' ? cheat.args.button : 'Apply'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'selection') {
|
||||
const selectedValue = String(value ?? options[0]?.value ?? '');
|
||||
|
||||
return (
|
||||
<select
|
||||
value={selectedValue}
|
||||
disabled={commonDisabled}
|
||||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onChange={(event) => onChange(findOption(options, event.currentTarget.value)?.value ?? event.currentTarget.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={`${cheat.uuid}-${optionKey(option)}`} value={optionKey(option)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'scalar') {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 rounded-[8px] border border-white/10 bg-black/20 p-2 sm:grid-cols-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={`${cheat.uuid}-${optionKey(option)}`}
|
||||
className={cn('h-9 rounded-[8px] border-white/10', isSameOption(value, option.value) ? 'bg-emerald-300 text-black hover:bg-emerald-200' : 'bg-white/5 text-white hover:bg-white/10')}
|
||||
disabled={commonDisabled}
|
||||
variant={isSameOption(value, option.value) ? 'default' : 'outline'}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'incremental') {
|
||||
const currentIndex = options.findIndex((option) => isSameOption(option.value, value));
|
||||
const previous = currentIndex > 0 ? options[currentIndex - 1] : null;
|
||||
const next = currentIndex >= 0 && currentIndex < options.length - 1 ? options[currentIndex + 1] : null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-2 rounded-[8px] border border-white/10 bg-black/20 p-2">
|
||||
<Button size="icon" variant="outline" className="rounded-[8px] border-white/10 bg-white/5 text-white" disabled={commonDisabled || !previous} onClick={() => previous && onChange(previous.value)}>
|
||||
<Icon className="size-4" name="chevron-left" />
|
||||
</Button>
|
||||
<span className="truncate text-center text-sm font-semibold text-white">{renderValue(options[currentIndex]?.label ?? value)}</span>
|
||||
<Button size="icon" variant="outline" className="rounded-[8px] border-white/10 bg-white/5 text-white" disabled={commonDisabled || !next} onClick={() => next && onChange(next.value)}>
|
||||
<Icon className="size-4" name="chevron-right" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-[8px] border border-red-300/25 bg-red-500/10 p-3 text-sm text-red-100">
|
||||
<Icon className="size-4" name="refresh" /> Unsupported cheat type: {cheat.type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Renderer cheat={cheat} disabled={disabled || pending} value={value} onChange={onChange} />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { resolveOption } from '../protocol';
|
||||
import { ActionButton } from './ActionButton';
|
||||
import { StepButton, type ControlInternalProps } from './shared';
|
||||
|
||||
const INCREMENTAL_STEP_GRID = 'grid-cols-[46px_minmax(0,1fr)_46px]';
|
||||
|
||||
export const IncrementalControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => {
|
||||
const options = (cheat.args.options ?? []).map(resolveOption);
|
||||
if (options.length === 0) {
|
||||
return <ActionButton cheat={cheat} disabled={disabled} value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
const currentIndex = options.findIndex((option) => isSameOption(option.value, value));
|
||||
const previous = currentIndex > 0 ? options[currentIndex - 1] : null;
|
||||
const next = currentIndex >= 0 && currentIndex < options.length - 1 ? options[currentIndex + 1] : null;
|
||||
const currentLabel = options[currentIndex]?.label ?? String(value ?? '--');
|
||||
|
||||
return (
|
||||
<div className={cn('grid h-[38px] w-full items-stretch overflow-hidden rounded-[10px] border border-white/10 bg-white/[0.055] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] backdrop-blur-xl', INCREMENTAL_STEP_GRID)}>
|
||||
<StepButton border="right" disabled={disabled || !previous} icon="chevron-left" onClick={() => previous && onChange(previous.value)} />
|
||||
<span className="flex min-w-0 items-center justify-center truncate px-2 text-center font-mono text-[12.5px] font-semibold tabular-nums text-(--deck-fg)">
|
||||
{currentLabel}{cheat.args.postfix ?? ''}
|
||||
</span>
|
||||
<StepButton border="left" disabled={disabled || !next} icon="chevron-right" onClick={() => next && onChange(next.value)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function isSameOption(left: unknown, right: unknown): boolean {
|
||||
return String(left) === String(right);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type FormEvent } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { formatInputNumber, numericValue, stripNumberGrouping } from './format-number';
|
||||
import { StepButton, type ControlInternalProps } from './shared';
|
||||
|
||||
const NUMBER_STEP_GRID = 'grid-cols-[48px_minmax(0,1fr)_48px]';
|
||||
|
||||
export const NumberControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => {
|
||||
const step = cheat.args.step ?? 1;
|
||||
const currentValue = numericValue(value, 0);
|
||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onChange(stripNumberGrouping(event.currentTarget.value));
|
||||
const decrement = () => onChange(Math.max(cheat.args.min ?? Number.NEGATIVE_INFINITY, currentValue - step));
|
||||
const increment = () => onChange(Math.min(cheat.args.max ?? Number.POSITIVE_INFINITY, currentValue + step));
|
||||
|
||||
return (
|
||||
<div className={cn('grid h-[38px] w-full items-stretch overflow-hidden rounded-[10px] border border-white/10 bg-white/[0.055] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] backdrop-blur-xl', NUMBER_STEP_GRID)}>
|
||||
<StepButton border="right" disabled={disabled} icon="minus" onClick={decrement} />
|
||||
<input
|
||||
type="text"
|
||||
value={formatInputNumber(value)}
|
||||
disabled={disabled}
|
||||
className="min-w-0 bg-transparent px-2 text-center font-mono text-[15px] font-semibold tabular-nums text-(--deck-fg) outline-none disabled:opacity-50"
|
||||
onInput={handleInput}
|
||||
/>
|
||||
<StepButton border="left" disabled={disabled} icon="plus" onClick={increment} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type FormEvent } from 'react';
|
||||
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { resolveOption } from '../protocol';
|
||||
import { formatNumber, numericValue } from './format-number';
|
||||
import { SliderTrack, type ControlInternalProps } from './shared';
|
||||
|
||||
export const ScalarControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => {
|
||||
const numericOptions = getNumericOptions(cheat.args.options ?? []);
|
||||
const min = cheat.args.min ?? numericOptions[0] ?? 0;
|
||||
const max = cheat.args.max ?? numericOptions[numericOptions.length - 1] ?? 100;
|
||||
const step = cheat.args.step ?? inferStep(numericOptions) ?? 1;
|
||||
const currentValue = numericValue(value, min);
|
||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onChange(Number(event.currentTarget.value));
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-1 flex justify-end font-mono text-[12.5px] tabular-nums text-(--deck-accent)">
|
||||
{formatNumber(currentValue, step)}{cheat.args.postfix ?? ''}
|
||||
</div>
|
||||
<SliderTrack disabled={disabled} max={max} min={min} step={step} value={currentValue} onInput={handleInput} />
|
||||
<div className="mt-1 flex justify-between font-mono text-[10px] text-(--deck-fg-4)">
|
||||
<span>{min}{cheat.args.postfix ?? ''}</span>
|
||||
<span>{max}{cheat.args.postfix ?? ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getNumericOptions(options: NonNullable<CheatSchema['args']['options']>): number[] {
|
||||
return options
|
||||
.map(resolveOption)
|
||||
.map((option) => numericValue(option.value, Number.NaN))
|
||||
.filter((option) => Number.isFinite(option))
|
||||
.sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function inferStep(options: number[]): number | null {
|
||||
if (options.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = options
|
||||
.slice(1)
|
||||
.map((option, index) => Math.abs(option - options[index]))
|
||||
.filter((option) => option > 0);
|
||||
|
||||
return steps.length > 0 ? Math.min(...steps) : null;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { CheatOption } from '../protocol';
|
||||
import { resolveOption } from '../protocol';
|
||||
import type { ControlInternalProps } from './shared';
|
||||
|
||||
export const SelectionControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => {
|
||||
const options = (cheat.args.options ?? []).map(resolveOption);
|
||||
const [open, setOpen] = useState(false);
|
||||
if (options.length === 0) {
|
||||
return <span className="text-[12px] text-(--deck-fg-4)">No options</span>;
|
||||
}
|
||||
|
||||
const selectedOption = findOption(options, String(value ?? options[0].value)) ?? options[0];
|
||||
const handleToggle = () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen((current) => !current);
|
||||
};
|
||||
const handleSelect = (option: CheatOption) => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className="flex h-[38px] w-full items-center justify-between gap-3 rounded-[10px] border border-white/10 bg-white/[0.055] px-3 text-left text-[13px] font-semibold text-(--deck-fg) shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none backdrop-blur-xl disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className="min-w-0 truncate">{selectedOption.label}</span>
|
||||
<Icon className={cn('size-4 shrink-0 text-(--deck-fg-3) transition-transform', open ? 'rotate-180' : '')} name="chevron-down" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="overflow-hidden rounded-[10px] border border-white/10 bg-white/[0.055] p-1 shadow-[0_18px_40px_rgba(0,0,0,0.36),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-xl">
|
||||
{options.map((option) => {
|
||||
const active = isSameOption(selectedOption.value, option.value);
|
||||
return (
|
||||
<button
|
||||
key={`${cheat.uuid}-${optionKey(option)}`}
|
||||
type="button"
|
||||
className={cn('flex h-[32px] w-full items-center justify-between rounded-[7px] px-2.5 text-left text-[12.5px] font-semibold transition-colors', active ? 'bg-white/[0.055] text-(--deck-accent)' : 'text-(--deck-fg) hover:bg-white/[0.055]')}
|
||||
onClick={() => handleSelect(option)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{option.label}</span>
|
||||
{active ? <span className="ml-3 size-1.5 shrink-0 rounded-full bg-(--deck-accent) shadow-[0_0_6px_var(--deck-accent)]" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function optionKey(option: CheatOption): string {
|
||||
return String(option.value);
|
||||
}
|
||||
|
||||
function findOption(options: CheatOption[], value: string): CheatOption | undefined {
|
||||
return options.find((option) => String(option.value) === value);
|
||||
}
|
||||
|
||||
function isSameOption(left: unknown, right: unknown): boolean {
|
||||
return String(left) === String(right);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type FormEvent } from 'react';
|
||||
|
||||
import { formatNumber, numericValue } from './format-number';
|
||||
import { SliderTrack, type ControlInternalProps } from './shared';
|
||||
|
||||
export const SliderControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => {
|
||||
const min = cheat.args.min ?? 0;
|
||||
const max = cheat.args.max ?? 100;
|
||||
const step = cheat.args.step ?? 1;
|
||||
const currentValue = numericValue(value, min);
|
||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onChange(Number(event.currentTarget.value));
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-1 flex justify-end font-mono text-[12.5px] tabular-nums text-(--deck-accent)">
|
||||
{formatNumber(currentValue, step)}{cheat.args.postfix ?? ''}
|
||||
</div>
|
||||
<SliderTrack disabled={disabled} max={max} min={min} step={step} value={currentValue} onInput={handleInput} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { ControlInternalProps } from './shared';
|
||||
|
||||
export const ToggleControl = ({ value, disabled, onChange }: ControlInternalProps) => {
|
||||
const checked = Boolean(value);
|
||||
const handleClick = () => onChange(!checked);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={checked}
|
||||
disabled={disabled}
|
||||
className={cn('relative h-[26px] w-11 shrink-0 self-center rounded-full border transition-all disabled:cursor-not-allowed disabled:opacity-50', checked ? 'border-(--deck-accent) bg-[color-mix(in_oklab,var(--deck-accent)_22%,transparent)] shadow-[0_0_0_4px_color-mix(in_oklab,var(--deck-accent)_12%,transparent)]' : 'border-white/10 bg-white/[0.05]')}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className={cn('absolute top-0.5 size-5 rounded-full transition-all', checked ? 'left-5 bg-(--deck-accent) shadow-[0_0_8px_color-mix(in_oklab,var(--deck-accent)_50%,transparent)]' : 'left-0.5 bg-(--deck-fg-3)')} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
const NUMBER_FORMAT_LOCALE = 'en-US';
|
||||
const NUMBER_MAX_FRACTION_DIGITS = 6;
|
||||
const NUMBER_GROUP_SEPARATOR_PATTERN = /[,\s]/g;
|
||||
|
||||
export const groupedNumberFormat = new Intl.NumberFormat(NUMBER_FORMAT_LOCALE, { maximumFractionDigits: NUMBER_MAX_FRACTION_DIGITS });
|
||||
|
||||
export function formatNumber(value: number, step: number): string {
|
||||
if (step >= 1) {
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
const decimals = step.toString().split('.')[1]?.length ?? 1;
|
||||
return value.toFixed(decimals);
|
||||
}
|
||||
|
||||
export function formatInputNumber(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const numeric = numericValue(value, Number.NaN);
|
||||
return Number.isFinite(numeric) ? groupedNumberFormat.format(numeric) : String(value);
|
||||
}
|
||||
|
||||
export function numericValue(value: unknown, fallback: number): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(stripNumberGrouping(value));
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function stripNumberGrouping(value: string): string {
|
||||
return value.replace(NUMBER_GROUP_SEPARATOR_PATTERN, '');
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type FormEvent } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { CheatSchema } from '../protocol';
|
||||
|
||||
export type ControlInternalProps = {
|
||||
cheat: CheatSchema;
|
||||
value: unknown;
|
||||
disabled: boolean;
|
||||
onChange: (nextValue: unknown) => void;
|
||||
};
|
||||
|
||||
type SliderTrackProps = {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
onInput: (event: FormEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export const SliderTrack = ({ min, max, step, value, disabled, onInput }: SliderTrackProps) => {
|
||||
const pct = max === min ? 0 : Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
|
||||
|
||||
return (
|
||||
<div className="relative flex h-5 w-full items-center">
|
||||
<div className="pointer-events-none absolute inset-x-0 h-1 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div className="h-full rounded-full bg-[linear-gradient(90deg,color-mix(in_oklab,var(--deck-accent)_60%,transparent),var(--deck-accent))]" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className="remote-range w-full"
|
||||
onInput={onInput}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type StepButtonProps = {
|
||||
icon: 'minus' | 'plus' | 'chevron-left' | 'chevron-right';
|
||||
border: 'left' | 'right';
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const StepButton = ({ icon, border, disabled, onClick }: StepButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn('flex items-center justify-center bg-white/[0.025] text-(--deck-fg-2) transition-colors hover:bg-white/[0.06] hover:text-(--deck-fg) disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:bg-white/[0.025] disabled:hover:text-(--deck-fg-2)', border === 'right' ? 'border-r border-white/10' : 'border-l border-white/10')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="size-4" name={icon} stroke={2} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { mockTrainerMeta, mockTrainerValues } from './mock-data';
|
||||
import type { PanelAction } from './state';
|
||||
|
||||
const MOCK_QUERY_PARAM = 'mock';
|
||||
|
||||
type PanelDispatch = (action: PanelAction) => void;
|
||||
|
||||
export function isDebugSessionRequested(): boolean {
|
||||
return new URLSearchParams(window.location.search).get(MOCK_QUERY_PARAM) === '1';
|
||||
}
|
||||
|
||||
export function loadDebugSession(dispatch: PanelDispatch): void {
|
||||
dispatch({ type: 'trainerMeta', payload: mockTrainerMeta });
|
||||
dispatch({ type: 'trainerValues', payload: mockTrainerValues.values });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { formatHumanLabel } from '@/lib/utils';
|
||||
import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from './protocol';
|
||||
|
||||
export type LibraryGame = {
|
||||
id: string;
|
||||
title: string;
|
||||
platform: string;
|
||||
hours: number | null;
|
||||
path: string;
|
||||
imageUrl: string | null;
|
||||
app: InstalledAppSummary;
|
||||
gameId: string | null;
|
||||
titleId: string | null;
|
||||
pinned: boolean;
|
||||
running: boolean;
|
||||
};
|
||||
|
||||
export type LibrarySections = {
|
||||
running: LibraryGame | null;
|
||||
pinned: LibraryGame[];
|
||||
rest: LibraryGame[];
|
||||
};
|
||||
|
||||
export function buildLibraryGames(
|
||||
apps: InstalledAppSummary[],
|
||||
status: GameStatusPayload | null,
|
||||
trainer: TrainerSummary | null,
|
||||
pinnedGameIds: Record<string, true>,
|
||||
): LibraryGame[] {
|
||||
const activeGameId = status?.session.gameId ?? status?.trainer.gameId ?? trainer?.gameId ?? null;
|
||||
const activeTitleId = status?.session.titleId ?? status?.trainer.titleId ?? trainer?.titleId ?? null;
|
||||
|
||||
return apps.map((app) => {
|
||||
const id = getInstalledAppId(app);
|
||||
return {
|
||||
id,
|
||||
title: app.displayName,
|
||||
platform: formatHumanLabel(app.platform),
|
||||
hours: minutesToHours(app.platformTotalPlaytimeMinutes),
|
||||
path: app.location,
|
||||
imageUrl: app.imageUrl ?? null,
|
||||
app,
|
||||
gameId: app.gameId ?? null,
|
||||
titleId: app.titleId ?? null,
|
||||
pinned: Boolean(pinnedGameIds[id]),
|
||||
running: isActiveInstalledApp(app, activeGameId, activeTitleId),
|
||||
};
|
||||
}).sort(compareLibraryGames);
|
||||
}
|
||||
|
||||
export function getCurrentGame(games: LibraryGame[]): LibraryGame | null {
|
||||
return games.find((game) => game.running) ?? null;
|
||||
}
|
||||
|
||||
export function getLibrarySections(games: LibraryGame[]): LibrarySections {
|
||||
const running = getCurrentGame(games);
|
||||
return {
|
||||
running,
|
||||
pinned: games.filter((game) => game.pinned && game.id !== running?.id),
|
||||
rest: games.filter((game) => !game.pinned && game.id !== running?.id),
|
||||
};
|
||||
}
|
||||
|
||||
export function filterLibraryGames(games: LibraryGame[], query: string): LibraryGame[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return games;
|
||||
}
|
||||
|
||||
return games.filter((game) => {
|
||||
if (game.title.toLowerCase().includes(normalized)) return true;
|
||||
if (game.id.toLowerCase().includes(normalized)) return true;
|
||||
if (game.platform.toLowerCase().includes(normalized)) return true;
|
||||
if (game.path.toLowerCase().includes(normalized)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatHours(hours: number | null): string | null {
|
||||
if (hours === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hours < 10) {
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
return `${Math.round(hours)}h`;
|
||||
}
|
||||
|
||||
export function shortPath(path: string): string {
|
||||
if (!path.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
if (parts.length <= 2) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
}
|
||||
|
||||
export function getGameCoverLabel(game: LibraryGame): string {
|
||||
return game.title.split(/[:\s]/).filter(Boolean)[0]?.slice(0, 8).toUpperCase() || 'GAME';
|
||||
}
|
||||
|
||||
export function getInstalledAppId(app: InstalledAppSummary): string {
|
||||
return app.gameId?.trim() || app.titleId?.trim() || app.correlationId;
|
||||
}
|
||||
|
||||
function minutesToHours(minutes: number | null | undefined): number | null {
|
||||
if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return minutes / 60;
|
||||
}
|
||||
|
||||
function compareLibraryGames(left: LibraryGame, right: LibraryGame): number {
|
||||
const runningDiff = Number(right.running) - Number(left.running);
|
||||
if (runningDiff !== 0) {
|
||||
return runningDiff;
|
||||
}
|
||||
|
||||
const pinnedDiff = Number(right.pinned) - Number(left.pinned);
|
||||
if (pinnedDiff !== 0) {
|
||||
return pinnedDiff;
|
||||
}
|
||||
|
||||
return left.title.localeCompare(right.title);
|
||||
}
|
||||
|
||||
function isActiveInstalledApp(app: InstalledAppSummary, activeGameId: string | null, activeTitleId: string | null): boolean {
|
||||
if (activeGameId && app.gameId === activeGameId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (activeTitleId && app.titleId === activeTitleId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { loadStringSet, saveStringSet } from './storage';
|
||||
import type { LibraryGame } from './game-library';
|
||||
|
||||
const STORAGE_KEY = 'wand-remote.pinned-games.v1';
|
||||
|
||||
export function loadPinnedGameIds(): Record<string, true> {
|
||||
return loadStringSet(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function savePinnedGameIds(pinnedGameIds: Record<string, true>): void {
|
||||
saveStringSet(STORAGE_KEY, pinnedGameIds);
|
||||
}
|
||||
|
||||
export function togglePinnedGame(game: LibraryGame, pinnedGameIds: Record<string, true>): Record<string, true> {
|
||||
const next = { ...pinnedGameIds };
|
||||
if (next[game.id]) {
|
||||
delete next[game.id];
|
||||
return next;
|
||||
}
|
||||
|
||||
next[game.id] = true;
|
||||
return next;
|
||||
}
|
||||
@@ -5,46 +5,57 @@ import type { PanelAction } from './state';
|
||||
type Dispatch = (action: PanelAction) => void;
|
||||
|
||||
export function handleProtocolMessage(dispatch: Dispatch, message: IncomingMessage, trainerMeta: TrainerMetaPayload | null): void {
|
||||
switch (message.type) {
|
||||
case 'hello_ack':
|
||||
handleHelloAck(dispatch, message.payload.accepted, message.payload.remoteUrl);
|
||||
return;
|
||||
case 'trainer_meta':
|
||||
dispatch({ type: 'trainerMeta', payload: message.payload });
|
||||
return;
|
||||
case 'trainer_values':
|
||||
dispatch({ type: 'trainerValues', payload: message.payload.values });
|
||||
return;
|
||||
case 'value_changed':
|
||||
handleValueChanged(dispatch, message, trainerMeta);
|
||||
return;
|
||||
case 'trainer_changed':
|
||||
dispatch({ type: 'trainerChanged' });
|
||||
return;
|
||||
case 'set_value_result':
|
||||
if (!message.payload.ok) {
|
||||
dispatch({ type: 'error', message: message.payload.error?.message ?? 'The trainer rejected the requested value.' });
|
||||
}
|
||||
return;
|
||||
case 'error':
|
||||
dispatch({ type: 'error', message: message.payload.message });
|
||||
return;
|
||||
}
|
||||
switch (message.type) {
|
||||
case 'hello_ack':
|
||||
handleHelloAck(dispatch, message.payload.accepted, message.payload.remoteUrl);
|
||||
return;
|
||||
case 'trainer_meta':
|
||||
dispatch({ type: 'trainerMeta', payload: message.payload });
|
||||
return;
|
||||
case 'game_status':
|
||||
dispatch({ type: 'gameStatus', payload: message.payload });
|
||||
return;
|
||||
case 'installed_apps':
|
||||
dispatch({ type: 'installedApps', payload: message.payload });
|
||||
return;
|
||||
case 'trainer_values':
|
||||
dispatch({ type: 'trainerValues', payload: message.payload.values });
|
||||
return;
|
||||
case 'value_changed':
|
||||
handleValueChanged(dispatch, message, trainerMeta);
|
||||
return;
|
||||
case 'trainer_changed':
|
||||
dispatch({ type: 'trainerChanged' });
|
||||
return;
|
||||
case 'set_value_result':
|
||||
if (!message.payload.ok) {
|
||||
dispatch({ type: 'error', message: message.payload.error?.message ?? 'The trainer rejected the requested value.' });
|
||||
}
|
||||
return;
|
||||
case 'remote_command_result':
|
||||
if (!message.payload.ok) {
|
||||
dispatch({ type: 'error', message: message.payload.error?.message ?? 'The remote game command was rejected.' });
|
||||
}
|
||||
return;
|
||||
case 'error':
|
||||
dispatch({ type: 'error', message: message.payload.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleHelloAck(dispatch: Dispatch, accepted: boolean, remoteUrl?: string): void {
|
||||
if (!accepted) {
|
||||
dispatch({ type: 'error', message: 'The desktop bridge rejected the connection.' });
|
||||
return;
|
||||
}
|
||||
if (!accepted) {
|
||||
dispatch({ type: 'error', message: 'The desktop bridge rejected the connection.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (remoteUrl) {
|
||||
dispatch({ type: 'setRemoteUrl', remoteUrl });
|
||||
}
|
||||
if (remoteUrl) {
|
||||
dispatch({ type: 'setRemoteUrl', remoteUrl });
|
||||
}
|
||||
}
|
||||
|
||||
function handleValueChanged(dispatch: Dispatch, message: Extract<IncomingMessage, { type: 'value_changed' }>, trainerMeta: TrainerMetaPayload | null): void {
|
||||
const cheat = trainerMeta?.schema.cheats.find((item) => item.target === message.payload.target || item.uuid === message.payload.cheatId);
|
||||
const nextValue = cheat ? normalizeIncomingValue(cheat, message.payload.value) : message.payload.value;
|
||||
dispatch({ type: 'valueChanged', target: message.payload.target, value: nextValue });
|
||||
const cheat = trainerMeta?.schema.cheats.find((item) => item.target === message.payload.target || item.uuid === message.payload.cheatId);
|
||||
const nextValue = cheat ? normalizeIncomingValue(cheat, message.payload.value) : message.payload.value;
|
||||
dispatch({ type: 'valueChanged', target: message.payload.target, value: nextValue });
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import demoSession from '../../../fixtures/demo-session.json';
|
||||
import type { TrainerMetaPayload, TrainerValuesPayload } from './protocol';
|
||||
|
||||
export const mockTrainerMeta = demoSession.trainerMeta as TrainerMetaPayload;
|
||||
export const mockTrainerValues = demoSession.trainerValues as TrainerValuesPayload;
|
||||
@@ -1,59 +1,17 @@
|
||||
import { getTrainerStorageId, loadStringSet, saveStringSet } from './storage';
|
||||
import type { TrainerSummary } from './protocol';
|
||||
|
||||
const STORAGE_PREFIX = 'wand-remote.pinned-cheats.v1:';
|
||||
|
||||
export function getPinnedStorageKey(trainer: TrainerSummary | null | undefined): string | null {
|
||||
if (!trainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = trainer.gameId?.trim() || trainer.titleId?.trim() || trainer.trainerId?.trim();
|
||||
return id ? `${STORAGE_PREFIX}${id}` : null;
|
||||
const id = getTrainerStorageId(trainer);
|
||||
return id ? `${STORAGE_PREFIX}${id}` : null;
|
||||
}
|
||||
|
||||
export function loadPinnedTargets(storageKey: string | null): Record<string, true> {
|
||||
if (!storageKey || typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const next: Record<string, true> = {};
|
||||
for (const target of parsed) {
|
||||
if (typeof target === 'string' && target.length > 0) {
|
||||
next[target] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return loadStringSet(storageKey);
|
||||
}
|
||||
|
||||
export function savePinnedTargets(storageKey: string | null, pinned: Record<string, true>): void {
|
||||
if (!storageKey || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const targets = Object.keys(pinned);
|
||||
if (targets.length === 0) {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(targets));
|
||||
} catch {
|
||||
// Ignore quota / serialization errors – pinning is a non-critical UX nicety.
|
||||
}
|
||||
saveStringSet(storageKey, pinned);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { getTrainerStorageId, loadJson, saveJson } from './storage';
|
||||
import type { CheatSchema, TrainerSummary } from './protocol';
|
||||
import { ECheatType } from './protocol';
|
||||
|
||||
export type RemotePreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
values: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'wand-remote.presets.v1:';
|
||||
const PRESET_COMPATIBLE_TYPES = new Set<ECheatType>([
|
||||
ECheatType.Toggle,
|
||||
ECheatType.Slider,
|
||||
ECheatType.Number,
|
||||
ECheatType.Selection,
|
||||
ECheatType.Scalar,
|
||||
ECheatType.Incremental,
|
||||
]);
|
||||
|
||||
export function getPresetStorageKey(trainer: TrainerSummary | null): string {
|
||||
return `${STORAGE_KEY_PREFIX}${getTrainerStorageId(trainer) ?? 'global'}`;
|
||||
}
|
||||
|
||||
export function loadPresets(storageKey: string): RemotePreset[] {
|
||||
return loadJson<RemotePreset[]>(
|
||||
storageKey,
|
||||
(raw) => (Array.isArray(raw) ? raw.map(normalizePreset).filter((preset): preset is RemotePreset => Boolean(preset)) : null),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export function savePresets(storageKey: string, presets: RemotePreset[]): void {
|
||||
saveJson(storageKey, presets, (value) => Array.isArray(value) && value.length === 0);
|
||||
}
|
||||
|
||||
export function createPreset(name: string, values: Record<string, unknown>): RemotePreset {
|
||||
return {
|
||||
id: createPresetId(),
|
||||
name: name.trim(),
|
||||
values,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function capturePresetValues(cheats: CheatSchema[], currentValues: Record<string, unknown>): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const cheat of cheats) {
|
||||
if (!PRESET_COMPATIBLE_TYPES.has(cheat.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(cheat.target in currentValues)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
values[cheat.target] = currentValues[cheat.target];
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function normalizePreset(value: unknown): RemotePreset | null {
|
||||
if (!isRecord(value) || !isRecord(value.values)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : createPresetId();
|
||||
const name = typeof value.name === 'string' && value.name.trim() ? value.name.trim() : null;
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
values: value.values,
|
||||
createdAt: typeof value.createdAt === 'string' ? value.createdAt : new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function createPresetId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `preset_${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
@@ -1,143 +1,216 @@
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
const NUMBER_GROUP_SEPARATOR_PATTERN = /[,\s]/g;
|
||||
|
||||
export type CheatType =
|
||||
| 'slider'
|
||||
| 'number'
|
||||
| 'toggle'
|
||||
| 'button'
|
||||
| 'selection'
|
||||
| 'scalar'
|
||||
| 'incremental';
|
||||
// String values mirror the wire protocol; do not rename the right-hand side.
|
||||
export enum ECheatType {
|
||||
Slider = 'slider',
|
||||
Number = 'number',
|
||||
Toggle = 'toggle',
|
||||
Button = 'button',
|
||||
Selection = 'selection',
|
||||
Scalar = 'scalar',
|
||||
Incremental = 'incremental',
|
||||
}
|
||||
|
||||
export interface CheatOption {
|
||||
label?: string;
|
||||
value: string | number;
|
||||
label?: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export type CheatOptionLike = CheatOption | string | number;
|
||||
|
||||
export interface CheatArgs {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
options?: CheatOptionLike[];
|
||||
button?: string | boolean;
|
||||
postfix?: string;
|
||||
default?: string | number | boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
options?: CheatOptionLike[];
|
||||
button?: string | boolean;
|
||||
postfix?: string;
|
||||
default?: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface CheatSchema {
|
||||
uuid: string;
|
||||
target: string;
|
||||
type: CheatType;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
instructions?: string | null;
|
||||
category: string;
|
||||
parent?: string | null;
|
||||
flags?: number;
|
||||
hotkeys?: string[][];
|
||||
args: CheatArgs;
|
||||
uuid: string;
|
||||
target: string;
|
||||
type: ECheatType;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
instructions?: string | null;
|
||||
category: string;
|
||||
parent?: string | null;
|
||||
flags?: number;
|
||||
hotkeys?: string[][];
|
||||
args: CheatArgs;
|
||||
}
|
||||
|
||||
export interface InstalledAppSummary {
|
||||
platform: string;
|
||||
sku: string;
|
||||
correlationId: string;
|
||||
displayName: string;
|
||||
gameId?: string | null;
|
||||
titleId?: string | null;
|
||||
location: string;
|
||||
alternateLocations: string[];
|
||||
imageUrl?: string | null;
|
||||
platformLastPlayedTimestamp?: number | null;
|
||||
platformTotalPlaytimeMinutes?: number | null;
|
||||
}
|
||||
|
||||
export interface InstalledAppsPayload {
|
||||
instanceId: string;
|
||||
updatedAt: string;
|
||||
apps: InstalledAppSummary[];
|
||||
}
|
||||
|
||||
export interface GameSessionStatus {
|
||||
state: 'idle' | 'running';
|
||||
event: string;
|
||||
processId?: number | null;
|
||||
gameId?: string | null;
|
||||
titleId?: string | null;
|
||||
titleName?: string | null;
|
||||
sessionDurationSeconds?: number | null;
|
||||
startedAt?: string | null;
|
||||
endedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface RunningTrainerStatus {
|
||||
state: 'idle' | 'running';
|
||||
event: string;
|
||||
trainerId?: string | null;
|
||||
displayName?: string | null;
|
||||
gameId?: string | null;
|
||||
titleId?: string | null;
|
||||
}
|
||||
|
||||
export interface GameStatusPayload {
|
||||
instanceId: string;
|
||||
updatedAt: string;
|
||||
session: GameSessionStatus;
|
||||
trainer: RunningTrainerStatus;
|
||||
}
|
||||
|
||||
export interface TrainerSummary {
|
||||
trainerId: string;
|
||||
gameId: string;
|
||||
displayName?: string | null;
|
||||
titleId?: string | null;
|
||||
gameVersion?: string | null;
|
||||
trainerLoading: boolean;
|
||||
gameInstalled: boolean;
|
||||
needsCompatibilityWarning: boolean;
|
||||
language?: string;
|
||||
themeId?: string;
|
||||
isTimeLimitExpired: boolean;
|
||||
notesReadHash?: string | null;
|
||||
trainerId: string;
|
||||
gameId: string;
|
||||
displayName?: string | null;
|
||||
titleId?: string | null;
|
||||
gameVersion?: string | null;
|
||||
trainerLoading: boolean;
|
||||
gameInstalled: boolean;
|
||||
needsCompatibilityWarning: boolean;
|
||||
language?: string;
|
||||
themeId?: string;
|
||||
isTimeLimitExpired: boolean;
|
||||
notesReadHash?: string | null;
|
||||
}
|
||||
|
||||
export interface TrainerMetaPayload {
|
||||
session: {
|
||||
instanceId: string;
|
||||
};
|
||||
trainer: TrainerSummary;
|
||||
schema: {
|
||||
categories: string[];
|
||||
cheats: CheatSchema[];
|
||||
};
|
||||
session: {
|
||||
instanceId: string;
|
||||
};
|
||||
trainer: TrainerSummary;
|
||||
schema: {
|
||||
categories: string[];
|
||||
cheats: CheatSchema[];
|
||||
};
|
||||
}
|
||||
|
||||
export type TrainerValuesPayload = {
|
||||
trainerId: string;
|
||||
values: Record<string, unknown>;
|
||||
trainerId: string;
|
||||
values: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ValueChangedPayload = {
|
||||
trainerId: string;
|
||||
target: string;
|
||||
value: unknown;
|
||||
oldValue?: unknown;
|
||||
source?: string;
|
||||
cheatId?: string;
|
||||
trainerId: string;
|
||||
target: string;
|
||||
value: unknown;
|
||||
oldValue?: unknown;
|
||||
source?: string;
|
||||
cheatId?: string;
|
||||
};
|
||||
|
||||
export type TrainerChangedPayload = {
|
||||
previousTrainerId?: string | null;
|
||||
trainerId: string;
|
||||
previousTrainerId?: string | null;
|
||||
trainerId: string;
|
||||
};
|
||||
|
||||
export type InstalledAppsMessage = MessageEnvelope<'installed_apps', InstalledAppsPayload>;
|
||||
export type GameStatusMessage = MessageEnvelope<'game_status', GameStatusPayload>;
|
||||
|
||||
export type RemoteCommandAction = 'launch' | 'stop';
|
||||
|
||||
export type SetValuePayload = {
|
||||
trainerId: string;
|
||||
target: string;
|
||||
value: unknown;
|
||||
cheatId?: string;
|
||||
trainerId: string;
|
||||
target: string;
|
||||
value: unknown;
|
||||
cheatId?: string;
|
||||
};
|
||||
|
||||
export type RemoteCommandPayload = {
|
||||
action: RemoteCommandAction;
|
||||
gameId?: string | null;
|
||||
titleId?: string | null;
|
||||
};
|
||||
|
||||
export type SetValueResultPayload = {
|
||||
ok: boolean;
|
||||
trainerId: string;
|
||||
target: string;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
ok: boolean;
|
||||
trainerId: string;
|
||||
target: string;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type RemoteCommandResultPayload = {
|
||||
ok: boolean;
|
||||
action: RemoteCommandAction;
|
||||
gameId?: string | null;
|
||||
titleId?: string | null;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ErrorPayload = {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface MessageEnvelope<TType extends string, TPayload> {
|
||||
type: TType;
|
||||
version: number;
|
||||
requestId: string | null;
|
||||
payload: TPayload;
|
||||
type: TType;
|
||||
version: number;
|
||||
requestId: string | null;
|
||||
payload: TPayload;
|
||||
}
|
||||
|
||||
export type HelloMessage = MessageEnvelope<
|
||||
'hello',
|
||||
{
|
||||
client: 'mobile-web';
|
||||
clientVersion: string;
|
||||
pairingToken?: string;
|
||||
capabilities: {
|
||||
supportsDeltaValues: boolean;
|
||||
supportsTrainerSwitch: boolean;
|
||||
};
|
||||
}
|
||||
'hello',
|
||||
{
|
||||
client: 'mobile-web';
|
||||
clientVersion: string;
|
||||
pairingToken?: string;
|
||||
capabilities: {
|
||||
supportsDeltaValues: boolean;
|
||||
supportsTrainerSwitch: boolean;
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
export type HelloAckMessage = MessageEnvelope<
|
||||
'hello_ack',
|
||||
{
|
||||
sessionId: string;
|
||||
accepted: boolean;
|
||||
serverVersion: string;
|
||||
protocolVersion: number;
|
||||
remoteUrl?: string;
|
||||
advertisedUrls?: string[];
|
||||
}
|
||||
'hello_ack',
|
||||
{
|
||||
sessionId: string;
|
||||
accepted: boolean;
|
||||
serverVersion: string;
|
||||
protocolVersion: number;
|
||||
remoteUrl?: string;
|
||||
advertisedUrls?: string[];
|
||||
}
|
||||
>;
|
||||
|
||||
export type TrainerMetaMessage = MessageEnvelope<'trainer_meta', TrainerMetaPayload>;
|
||||
@@ -146,63 +219,68 @@ export type ValueChangedMessage = MessageEnvelope<'value_changed', ValueChangedP
|
||||
export type TrainerChangedMessage = MessageEnvelope<'trainer_changed', TrainerChangedPayload>;
|
||||
export type SetValueMessage = MessageEnvelope<'set_value', SetValuePayload>;
|
||||
export type SetValueResultMessage = MessageEnvelope<'set_value_result', SetValueResultPayload>;
|
||||
export type RemoteCommandMessage = MessageEnvelope<'remote_command', RemoteCommandPayload>;
|
||||
export type RemoteCommandResultMessage = MessageEnvelope<'remote_command_result', RemoteCommandResultPayload>;
|
||||
export type ErrorMessage = MessageEnvelope<'error', ErrorPayload>;
|
||||
|
||||
export type IncomingMessage =
|
||||
| HelloAckMessage
|
||||
| TrainerMetaMessage
|
||||
| TrainerValuesMessage
|
||||
| ValueChangedMessage
|
||||
| TrainerChangedMessage
|
||||
| SetValueResultMessage
|
||||
| ErrorMessage;
|
||||
| HelloAckMessage
|
||||
| TrainerMetaMessage
|
||||
| TrainerValuesMessage
|
||||
| GameStatusMessage
|
||||
| InstalledAppsMessage
|
||||
| ValueChangedMessage
|
||||
| TrainerChangedMessage
|
||||
| SetValueResultMessage
|
||||
| RemoteCommandResultMessage
|
||||
| ErrorMessage;
|
||||
|
||||
export type OutgoingMessage = HelloMessage | SetValueMessage;
|
||||
export type OutgoingMessage = HelloMessage | SetValueMessage | RemoteCommandMessage;
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export function isIncomingMessage(value: unknown): value is IncomingMessage {
|
||||
return isRecord(value) && typeof value.type === 'string' && typeof value.version === 'number' && 'payload' in value;
|
||||
return isRecord(value) && typeof value.type === 'string' && typeof value.version === 'number' && 'payload' in value;
|
||||
}
|
||||
|
||||
export function resolveOption(option: CheatOptionLike): CheatOption {
|
||||
if (typeof option === 'string' || typeof option === 'number') {
|
||||
return { label: String(option), value: option };
|
||||
}
|
||||
if (typeof option === 'string' || typeof option === 'number') {
|
||||
return { label: String(option), value: option };
|
||||
}
|
||||
|
||||
return {
|
||||
label: option.label ?? String(option.value),
|
||||
value: option.value,
|
||||
};
|
||||
return {
|
||||
label: option.label ?? String(option.value),
|
||||
value: option.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeIncomingValue(cheat: CheatSchema, value: unknown): unknown {
|
||||
if (cheat.type === 'toggle') {
|
||||
return Boolean(value);
|
||||
}
|
||||
if (cheat.type === ECheatType.Toggle) {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeOutgoingValue(cheat: CheatSchema, value: unknown): unknown {
|
||||
if (cheat.type === 'toggle') {
|
||||
return Boolean(value);
|
||||
}
|
||||
if (cheat.type === ECheatType.Toggle) {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
if (cheat.type !== 'slider' && cheat.type !== 'number') {
|
||||
return value;
|
||||
}
|
||||
if (cheat.type !== ECheatType.Slider && cheat.type !== ECheatType.Number) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
return value;
|
||||
}
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Number(trimmedValue);
|
||||
return Number(trimmedValue.replace(NUMBER_GROUP_SEPARATOR_PATTERN, ''));
|
||||
}
|
||||
|
||||
@@ -1,119 +1,155 @@
|
||||
import { CLIENT_VERSION } from './constants';
|
||||
import {
|
||||
type HelloMessage,
|
||||
type IncomingMessage,
|
||||
type OutgoingMessage,
|
||||
PROTOCOL_VERSION,
|
||||
type SetValueMessage,
|
||||
isIncomingMessage,
|
||||
type HelloMessage,
|
||||
type IncomingMessage,
|
||||
type OutgoingMessage,
|
||||
PROTOCOL_VERSION,
|
||||
type RemoteCommandMessage,
|
||||
type SetValueMessage,
|
||||
isIncomingMessage,
|
||||
} from './protocol';
|
||||
|
||||
type SocketHandlers = {
|
||||
onConnecting: () => void;
|
||||
onOpen: () => void;
|
||||
onMessage: (message: IncomingMessage) => void;
|
||||
onClose: () => void;
|
||||
onError: (message: string) => void;
|
||||
onConnecting: () => void;
|
||||
onOpen: () => void;
|
||||
onMessage: (message: IncomingMessage) => void;
|
||||
onClose: () => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
export class PanelSocketClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private intentionalDisconnect = false;
|
||||
private socket: WebSocket | null = null;
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly handlers: SocketHandlers,
|
||||
) {}
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly handlers: SocketHandlers,
|
||||
) { }
|
||||
|
||||
connect(pairingToken?: string): void {
|
||||
this.disconnect();
|
||||
this.intentionalDisconnect = false;
|
||||
this.handlers.onConnecting();
|
||||
connect(pairingToken?: string): void {
|
||||
this.disconnect();
|
||||
this.intentionalDisconnect = false;
|
||||
this.handlers.onConnecting();
|
||||
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.handlers.onOpen();
|
||||
this.send(this.createHelloMessage(pairingToken));
|
||||
});
|
||||
socket.addEventListener('open', () => {
|
||||
this.handlers.onOpen();
|
||||
this.send(this.createHelloMessage(pairingToken));
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.socket === socket) {
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
if (!this.intentionalDisconnect) {
|
||||
this.handlers.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
this.handlers.onError('WebSocket connection failed.');
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.intentionalDisconnect = true;
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
if (!this.intentionalDisconnect) {
|
||||
this.handlers.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
this.handlers.onError('WebSocket connection failed.');
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.intentionalDisconnect = true;
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
send(message: OutgoingMessage): boolean {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.socket.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
setValue(trainerId: string, target: string, value: unknown, cheatId?: string): boolean {
|
||||
const message: SetValueMessage = {
|
||||
type: 'set_value',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `set_${target}_${Date.now()}`,
|
||||
payload: {
|
||||
trainerId,
|
||||
target,
|
||||
value,
|
||||
cheatId,
|
||||
},
|
||||
};
|
||||
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
private createHelloMessage(pairingToken?: string): HelloMessage {
|
||||
return {
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `hello_${Date.now()}`,
|
||||
payload: {
|
||||
client: 'mobile-web',
|
||||
clientVersion: CLIENT_VERSION,
|
||||
pairingToken,
|
||||
capabilities: {
|
||||
supportsDeltaValues: true,
|
||||
supportsTrainerSwitch: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const parsed = JSON.parse(String(event.data)) as unknown;
|
||||
if (!isIncomingMessage(parsed)) {
|
||||
this.handlers.onError('Received an invalid protocol message.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlers.onMessage(parsed);
|
||||
} catch (error) {
|
||||
this.handlers.onError(error instanceof Error ? error.message : 'Failed to parse websocket message.');
|
||||
isOpen(): boolean {
|
||||
return Boolean(this.socket && this.socket.readyState === WebSocket.OPEN);
|
||||
}
|
||||
|
||||
send(message: OutgoingMessage): boolean {
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
setValue(trainerId: string, target: string, value: unknown, cheatId?: string): boolean {
|
||||
const message: SetValueMessage = {
|
||||
type: 'set_value',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `set_${target}_${Date.now()}`,
|
||||
payload: {
|
||||
trainerId,
|
||||
target,
|
||||
value,
|
||||
cheatId,
|
||||
},
|
||||
};
|
||||
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
launchGame(gameId: string, titleId?: string): boolean {
|
||||
const message: RemoteCommandMessage = {
|
||||
type: 'remote_command',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `command_launch_${Date.now()}`,
|
||||
payload: {
|
||||
action: 'launch',
|
||||
gameId,
|
||||
titleId,
|
||||
},
|
||||
};
|
||||
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
stopPlaying(gameId?: string, titleId?: string): boolean {
|
||||
const message: RemoteCommandMessage = {
|
||||
type: 'remote_command',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `command_stop_${Date.now()}`,
|
||||
payload: {
|
||||
action: 'stop',
|
||||
gameId,
|
||||
titleId,
|
||||
},
|
||||
};
|
||||
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
private createHelloMessage(pairingToken?: string): HelloMessage {
|
||||
return {
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `hello_${Date.now()}`,
|
||||
payload: {
|
||||
client: 'mobile-web',
|
||||
clientVersion: CLIENT_VERSION,
|
||||
pairingToken,
|
||||
capabilities: {
|
||||
supportsDeltaValues: true,
|
||||
supportsTrainerSwitch: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const parsed = JSON.parse(String(event.data)) as unknown;
|
||||
if (!isIncomingMessage(parsed)) {
|
||||
this.handlers.onError('Received an invalid protocol message.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlers.onMessage(parsed);
|
||||
} catch (error) {
|
||||
this.handlers.onError(error instanceof Error ? error.message : 'Failed to parse websocket message.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +1,166 @@
|
||||
import { readInitialRemoteUrl, readInitialWebSocketUrl } from './constants';
|
||||
import type { TrainerMetaPayload } from './protocol';
|
||||
import type { GameStatusPayload, InstalledAppSummary, InstalledAppsPayload, TrainerMetaPayload } from './protocol';
|
||||
|
||||
export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error';
|
||||
export enum EConnectionStatus {
|
||||
Idle = 'idle',
|
||||
Connecting = 'connecting',
|
||||
Connected = 'connected',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type PanelState = {
|
||||
connectionStatus: ConnectionStatus;
|
||||
wsUrl: string;
|
||||
remoteUrl: string;
|
||||
trainerMeta: TrainerMetaPayload | null;
|
||||
values: Record<string, unknown>;
|
||||
pendingTargets: Record<string, boolean>;
|
||||
pinnedTargets: Record<string, true>;
|
||||
lastError: string | null;
|
||||
connectionStatus: EConnectionStatus;
|
||||
wsUrl: string;
|
||||
remoteUrl: string;
|
||||
trainerMeta: TrainerMetaPayload | null;
|
||||
gameStatus: GameStatusPayload | null;
|
||||
installedApps: InstalledAppSummary[];
|
||||
installedAppsUpdatedAt: string | null;
|
||||
values: Record<string, unknown>;
|
||||
pendingTargets: Record<string, boolean>;
|
||||
pinnedTargets: Record<string, true>;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type PanelAction =
|
||||
| { type: 'setWsUrl'; wsUrl: string }
|
||||
| { type: 'setRemoteUrl'; remoteUrl: string }
|
||||
| { type: 'connecting' }
|
||||
| { type: 'connected' }
|
||||
| { type: 'trainerMeta'; payload: TrainerMetaPayload }
|
||||
| { type: 'trainerValues'; payload: Record<string, unknown> }
|
||||
| { type: 'valueChanged'; target: string; value: unknown }
|
||||
| { type: 'setPending'; target: string; pending: boolean }
|
||||
| { type: 'trainerChanged' }
|
||||
| { type: 'setPinnedTargets'; pinned: Record<string, true> }
|
||||
| { type: 'togglePinnedTarget'; target: string }
|
||||
| { type: 'error'; message: string | null };
|
||||
| { type: 'setWsUrl'; wsUrl: string }
|
||||
| { type: 'setRemoteUrl'; remoteUrl: string }
|
||||
| { type: 'connecting' }
|
||||
| { type: 'connected' }
|
||||
| { type: 'disconnected' }
|
||||
| { type: 'trainerMeta'; payload: TrainerMetaPayload }
|
||||
| { type: 'gameStatus'; payload: GameStatusPayload }
|
||||
| { type: 'installedApps'; payload: InstalledAppsPayload }
|
||||
| { type: 'trainerValues'; payload: Record<string, unknown> }
|
||||
| { type: 'valueChanged'; target: string; value: unknown }
|
||||
| { type: 'setPending'; target: string; pending: boolean }
|
||||
| { type: 'trainerChanged' }
|
||||
| { type: 'setPinnedTargets'; pinned: Record<string, true> }
|
||||
| { type: 'togglePinnedTarget'; target: string }
|
||||
| { type: 'error'; message: string | null };
|
||||
|
||||
export function createInitialPanelState(): PanelState {
|
||||
return {
|
||||
connectionStatus: 'idle',
|
||||
wsUrl: readInitialWebSocketUrl(),
|
||||
remoteUrl: readInitialRemoteUrl(),
|
||||
trainerMeta: null,
|
||||
values: {},
|
||||
pendingTargets: {},
|
||||
pinnedTargets: {},
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function panelReducer(state: PanelState, action: PanelAction): PanelState {
|
||||
switch (action.type) {
|
||||
case 'setWsUrl':
|
||||
return {
|
||||
...state,
|
||||
wsUrl: action.wsUrl,
|
||||
};
|
||||
case 'setRemoteUrl':
|
||||
return {
|
||||
...state,
|
||||
remoteUrl: action.remoteUrl,
|
||||
};
|
||||
case 'connecting':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: 'connecting',
|
||||
lastError: null,
|
||||
};
|
||||
case 'connected':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: 'connected',
|
||||
lastError: null,
|
||||
};
|
||||
case 'trainerMeta':
|
||||
return {
|
||||
...state,
|
||||
trainerMeta: action.payload,
|
||||
pendingTargets: {},
|
||||
};
|
||||
case 'trainerValues':
|
||||
return {
|
||||
...state,
|
||||
values: action.payload,
|
||||
};
|
||||
case 'valueChanged':
|
||||
return {
|
||||
...state,
|
||||
values: {
|
||||
...state.values,
|
||||
[action.target]: action.value,
|
||||
},
|
||||
pendingTargets: {
|
||||
...state.pendingTargets,
|
||||
[action.target]: false,
|
||||
},
|
||||
};
|
||||
case 'setPending':
|
||||
return {
|
||||
...state,
|
||||
pendingTargets: {
|
||||
...state.pendingTargets,
|
||||
[action.target]: action.pending,
|
||||
},
|
||||
};
|
||||
case 'trainerChanged':
|
||||
return {
|
||||
...state,
|
||||
return {
|
||||
connectionStatus: EConnectionStatus.Idle,
|
||||
wsUrl: readInitialWebSocketUrl(),
|
||||
remoteUrl: readInitialRemoteUrl(),
|
||||
trainerMeta: null,
|
||||
gameStatus: null,
|
||||
installedApps: [],
|
||||
installedAppsUpdatedAt: null,
|
||||
values: {},
|
||||
pendingTargets: {},
|
||||
pinnedTargets: {},
|
||||
};
|
||||
case 'setPinnedTargets':
|
||||
return {
|
||||
...state,
|
||||
pinnedTargets: action.pinned,
|
||||
};
|
||||
case 'togglePinnedTarget': {
|
||||
const next = { ...state.pinnedTargets };
|
||||
if (next[action.target]) {
|
||||
delete next[action.target];
|
||||
} else {
|
||||
next[action.target] = true;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
pinnedTargets: next,
|
||||
};
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: action.message ? 'error' : state.connectionStatus,
|
||||
lastError: action.message,
|
||||
};
|
||||
}
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function panelReducer(state: PanelState, action: PanelAction): PanelState {
|
||||
switch (action.type) {
|
||||
case 'setWsUrl':
|
||||
return {
|
||||
...state,
|
||||
wsUrl: action.wsUrl,
|
||||
};
|
||||
case 'setRemoteUrl':
|
||||
return {
|
||||
...state,
|
||||
remoteUrl: action.remoteUrl,
|
||||
};
|
||||
case 'connecting':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: EConnectionStatus.Connecting,
|
||||
lastError: null,
|
||||
};
|
||||
case 'connected':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: EConnectionStatus.Connected,
|
||||
lastError: null,
|
||||
};
|
||||
case 'disconnected':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: EConnectionStatus.Idle,
|
||||
trainerMeta: null,
|
||||
gameStatus: null,
|
||||
values: {},
|
||||
pendingTargets: {},
|
||||
lastError: null,
|
||||
};
|
||||
case 'trainerMeta':
|
||||
return {
|
||||
...state,
|
||||
trainerMeta: action.payload,
|
||||
pendingTargets: {},
|
||||
};
|
||||
case 'gameStatus':
|
||||
return {
|
||||
...state,
|
||||
gameStatus: action.payload,
|
||||
};
|
||||
case 'installedApps':
|
||||
return {
|
||||
...state,
|
||||
installedApps: action.payload.apps,
|
||||
installedAppsUpdatedAt: action.payload.updatedAt,
|
||||
};
|
||||
case 'trainerValues':
|
||||
return {
|
||||
...state,
|
||||
values: action.payload,
|
||||
};
|
||||
case 'valueChanged':
|
||||
return {
|
||||
...state,
|
||||
values: {
|
||||
...state.values,
|
||||
[action.target]: action.value,
|
||||
},
|
||||
pendingTargets: {
|
||||
...state.pendingTargets,
|
||||
[action.target]: false,
|
||||
},
|
||||
};
|
||||
case 'setPending':
|
||||
return {
|
||||
...state,
|
||||
pendingTargets: {
|
||||
...state.pendingTargets,
|
||||
[action.target]: action.pending,
|
||||
},
|
||||
};
|
||||
case 'trainerChanged':
|
||||
return {
|
||||
...state,
|
||||
trainerMeta: null,
|
||||
values: {},
|
||||
pendingTargets: {},
|
||||
pinnedTargets: {},
|
||||
};
|
||||
case 'setPinnedTargets':
|
||||
return {
|
||||
...state,
|
||||
pinnedTargets: action.pinned,
|
||||
};
|
||||
case 'togglePinnedTarget': {
|
||||
const next = { ...state.pinnedTargets };
|
||||
if (next[action.target]) {
|
||||
delete next[action.target];
|
||||
} else {
|
||||
next[action.target] = true;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
pinnedTargets: next,
|
||||
};
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: action.message && state.connectionStatus !== EConnectionStatus.Connected ? EConnectionStatus.Error : state.connectionStatus,
|
||||
lastError: action.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { TrainerSummary } from './protocol';
|
||||
|
||||
type Reviver<T> = (raw: unknown) => T | null;
|
||||
|
||||
function getStore(): Storage | null {
|
||||
return typeof window === 'undefined' ? null : window.localStorage;
|
||||
}
|
||||
|
||||
export function getTrainerStorageId(trainer: TrainerSummary | null | undefined): string | null {
|
||||
if (!trainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = trainer.gameId?.trim() || trainer.titleId?.trim() || trainer.trainerId?.trim();
|
||||
return id || null;
|
||||
}
|
||||
|
||||
export function loadJson<T>(key: string | null, revive: Reviver<T>, fallback: T): T {
|
||||
const store = getStore();
|
||||
if (!key || !store) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = store.getItem(key);
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return revive(JSON.parse(raw) as unknown) ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveJson(key: string | null, value: unknown, isEmpty: (value: unknown) => boolean): void {
|
||||
const store = getStore();
|
||||
if (!key || !store) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEmpty(value)) {
|
||||
store.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
store.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// localStorage quota / serialization errors are non-critical for this UI.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStringSet(key: string | null): Record<string, true> {
|
||||
return loadJson<Record<string, true>>(
|
||||
key,
|
||||
(raw) => {
|
||||
if (!Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: Record<string, true> = {};
|
||||
for (const value of raw) {
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
result[value] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
export function saveStringSet(key: string | null, value: Record<string, true>): void {
|
||||
const ids = Object.keys(value);
|
||||
saveJson(key, ids, () => ids.length === 0);
|
||||
}
|
||||
+134
-8
@@ -1,8 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--font-heading: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-heading: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
@@ -29,6 +29,16 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--deck-bg: #07080B;
|
||||
--deck-surface-0: rgba(20, 23, 31, 0.88);
|
||||
--deck-surface-1: rgba(28, 32, 42, 0.85);
|
||||
--deck-line: rgba(255, 255, 255, 0.06);
|
||||
--deck-line-2: rgba(255, 255, 255, 0.10);
|
||||
--deck-fg: #F2F4F8;
|
||||
--deck-fg-2: #B6BCCB;
|
||||
--deck-fg-3: #7F8699;
|
||||
--deck-fg-4: #535A6B;
|
||||
--deck-accent: #00ffd5;
|
||||
--background: oklch(0.122 0.022 255);
|
||||
--foreground: oklch(0.968 0.016 96);
|
||||
--card: oklch(0.18 0.028 252);
|
||||
@@ -51,13 +61,129 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply min-h-svh bg-background text-foreground antialiased;
|
||||
|
||||
body {
|
||||
@apply min-h-svh bg-background text-foreground antialiased;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.remote-scrollbar-hidden {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.remote-scrollbar-hidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.remote-range {
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 999px;
|
||||
height: 4px;
|
||||
outline: none;
|
||||
accent-color: var(--deck-accent);
|
||||
}
|
||||
|
||||
.remote-range::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.remote-range::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.remote-glass-header {
|
||||
background: linear-gradient(180deg, rgba(18, 20, 27, 0.72), rgba(10, 12, 17, 0.48));
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 14px 34px rgba(0, 0, 0, 0.22);
|
||||
backdrop-filter: blur(24px) saturate(160%);
|
||||
}
|
||||
|
||||
.remote-glass-drawer {
|
||||
background: linear-gradient(180deg, rgba(17, 19, 26, 0.70), rgba(10, 12, 17, 0.52));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 30px 80px rgba(0, 0, 0, 0.46);
|
||||
backdrop-filter: blur(38px) saturate(165%);
|
||||
}
|
||||
|
||||
.remote-drawer-overlay {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.remote-drawer-panel {
|
||||
background: linear-gradient(180deg, rgba(17, 19, 26, 0.90), rgba(10, 12, 17, 0.82));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 30px 80px rgba(0, 0, 0, 0.46);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(155%);
|
||||
backdrop-filter: blur(22px) saturate(155%);
|
||||
transform: translateZ(0);
|
||||
contain: layout paint style;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.remote-drawer-panel[data-open="true"] {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.remote-drawer-panel[data-open="false"] {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.remote-drawer-overlay {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.remote-drawer-panel {
|
||||
background: linear-gradient(180deg, rgba(17, 19, 26, 0.97), rgba(10, 12, 17, 0.95));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055), 0 18px 48px rgba(0, 0, 0, 0.40);
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.remote-drawer-panel .remote-glass-header,
|
||||
.remote-drawer-panel .remote-glass-control {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.remote-glass-control {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.070), rgba(255, 255, 255, 0.036));
|
||||
border-color: rgba(255, 255, 255, 0.105);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055), 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
backdrop-filter: blur(18px) saturate(150%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
type ClassValue = string | number | false | null | undefined | ClassValue[] | Record<string, boolean | null | undefined>
|
||||
|
||||
export function formatHumanLabel(value: string): string {
|
||||
return value
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
const classes: string[] = []
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { applySavedAccentColor } from '@/features/remote-panel/accent-storage';
|
||||
|
||||
import { App } from './app';
|
||||
import './index.css';
|
||||
|
||||
@@ -9,6 +12,8 @@ if (!root) {
|
||||
throw new Error('App root not found.');
|
||||
}
|
||||
|
||||
applySavedAccentColor();
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
Reference in New Issue
Block a user