mirror of
https://github.com/luanslimadev/Wand-Enhancer.git
synced 2026-08-28 22:01:17 +00:00
refactor(web-panel): update architecture + cheat i18n
Reorganize the panel around product capabilities and integrate the remote i18n feature from origin/master into the new structure. - Restructure src into capability features (app, trainer, library, remote-session, appearance, shared); move the bridge to bridge/src. - Add lingui-based UI localization and wrap remaining user-facing strings (Trans / msg macros); fix the 'END В·' mojibake. - Port WeMod cheat-metadata i18n: capture the access token from the snapshot's renderer in the bridge and fetch localized trainer_meta in remote-session.i18n; guarantee snapshot sync on token failure. - Wire vitest to the app's lingui/preact pipeline (mergeConfig).
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
Use these rules as defaults, not as a reason to add ceremonial folders or wrapper layers.
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Organize code around product capabilities, not framework vocabulary.
|
||||
- Keep related UI, state, rules, and data access close until a real boundary justifies moving
|
||||
them apart.
|
||||
- Dependencies point from composition and UI toward stable rules and narrow capabilities.
|
||||
- Protect rendering code from business, state-management, and infrastructure complexity.
|
||||
- Keep one source of truth and derive everything else.
|
||||
- Apply KISS, YAGNI, and DRY together. Remove duplicated knowledge, not merely similar syntax.
|
||||
- Prefer explicit, readable flow over clever abstractions and hidden behavior.
|
||||
|
||||
## Screaming Architecture
|
||||
|
||||
The repository structure and public APIs should reveal what the product does.
|
||||
|
||||
Prefer:
|
||||
|
||||
```text
|
||||
features/
|
||||
checkout/
|
||||
search/
|
||||
account-security/
|
||||
```
|
||||
|
||||
Avoid making the application read primarily as:
|
||||
|
||||
```text
|
||||
components/
|
||||
hooks/
|
||||
services/
|
||||
stores/
|
||||
utils/
|
||||
```
|
||||
|
||||
Technical folders are useful inside a capability, where their owner is clear. Generic top-level
|
||||
folders easily become dependency magnets with unclear ownership.
|
||||
|
||||
Names should use product language. Prefer `useCheckoutSummary`, `reserveStock`, and
|
||||
`AccountSecurityPanel` over `useData`, `processItems`, and `GenericPanel`.
|
||||
|
||||
## Suggested Structure
|
||||
|
||||
Start with the smallest structure that makes ownership obvious:
|
||||
|
||||
```text
|
||||
src/
|
||||
app/ startup, providers, router, global composition
|
||||
pages/ route-level composition
|
||||
features/
|
||||
<capability>/
|
||||
index.ts optional public API
|
||||
ui/ optional rendering components
|
||||
model/ optional state, view models, decisions
|
||||
api/ optional external data access
|
||||
lib/ optional feature-local pure helpers
|
||||
domains/ optional shared product rules and types
|
||||
shared/
|
||||
ui/ domain-free visual primitives
|
||||
api/ generic transport/query infrastructure
|
||||
lib/ genuinely generic pure helpers
|
||||
```
|
||||
|
||||
Folders are created when they contain a real responsibility. A small feature may be one cohesive
|
||||
file. Do not create empty layers in anticipation of future complexity.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
- `app` installs providers, constructs dependencies, and composes the application.
|
||||
- `pages` compose capabilities for a route. They do not own business rules or data protocols.
|
||||
- A feature owns one user-recognizable capability end to end.
|
||||
- Feature UI consumes its own model/view-model API, not raw infrastructure.
|
||||
- Shared domain code contains reusable product rules and stays independent of React and I/O.
|
||||
- `shared` contains only domain-free code. Product-specific code is not shared merely because
|
||||
two files use it.
|
||||
- Avoid feature-to-feature imports. Compose features in a page, promote truly shared rules to a
|
||||
domain module, or introduce a named workflow when coordination is the actual responsibility.
|
||||
- Cyclic imports are an architecture problem, not something to solve with a tooling workaround.
|
||||
|
||||
For a simple feature, direct `ui -> model -> api` dependencies are sufficient. Introduce ports,
|
||||
facades, dependency injection, or workflows only when they hide real complexity, enable
|
||||
important tests, or separate unstable infrastructure.
|
||||
|
||||
## Make Composition Read Like The Product
|
||||
|
||||
Pages and other composition boundaries should use capability-level APIs.
|
||||
|
||||
Prefer:
|
||||
|
||||
```tsx
|
||||
<CheckoutSummary />
|
||||
<PlaceOrderButton />
|
||||
```
|
||||
|
||||
Over:
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<Select options={paymentOptions} onChange={handlePaymentChange} />
|
||||
<Button onClick={handleSubmit}>Submit</Button>
|
||||
</Card>
|
||||
```
|
||||
|
||||
The second version makes the page understand checkout behavior and low-level UI configuration.
|
||||
That knowledge belongs to the checkout capability.
|
||||
|
||||
This does not mean wrapping every native element or design-system primitive. Semantic HTML and
|
||||
visual primitives are correct inside feature UI. Create a capability component when it hides
|
||||
product behavior or gives composition code a clearer product-level API.
|
||||
|
||||
Avoid "raw components" whose consumers must know internal options, state transitions, query
|
||||
shapes, or protocol details. Avoid generic configuration-driven components that combine
|
||||
unrelated product modes behind dozens of props.
|
||||
|
||||
## UI Boundary
|
||||
|
||||
- Components render data and translate DOM events into named user intents.
|
||||
- Keep business decisions, data mapping, persistence, protocol handling, and multi-step async
|
||||
flows outside rendering components.
|
||||
- UI receives render-ready values. It should not reconstruct domain meaning from raw DTOs.
|
||||
- Prefer intent props and commands such as `onApprove`, `renameProject`, or `submitOrder` over
|
||||
generic `onChange`, `setState`, or `patch` APIs at capability boundaries.
|
||||
- Keep ephemeral visual state local: focus, hover, open/closed, and uncommitted input usually
|
||||
belong in the component.
|
||||
- Split components by responsibility and API clarity, not by arbitrary line limits.
|
||||
- Prefer slots and composition over components with many layout modes and boolean props.
|
||||
- Use semantic HTML and preserve accessibility behavior.
|
||||
|
||||
A view-model hook is useful when it protects UI from state shape, async coordination, or business
|
||||
decisions. Do not create a pass-through hook that only renames one value to satisfy a diagram.
|
||||
|
||||
## State Ownership
|
||||
|
||||
Choose the smallest correct owner:
|
||||
|
||||
| State | Preferred owner |
|
||||
| --- | --- |
|
||||
| Ephemeral visual state | local component state |
|
||||
| Uncommitted form state | the form or feature |
|
||||
| URL/shareable navigation state | the router/URL |
|
||||
| Remote server resource and cache | a query/cache layer |
|
||||
| Shared capability state | that feature's model/store |
|
||||
| Cross-capability process | a named workflow or app-level model |
|
||||
|
||||
- A store is not a bucket for every value used by several components.
|
||||
- Split state by capability and lifecycle, not by data type.
|
||||
- Expose narrow selectors, hooks, or commands. Do not expose a complete mutable store to all UI.
|
||||
- Store transitions should express user or domain intent, not generic object mutation.
|
||||
- Derive values instead of storing synchronized copies.
|
||||
- Do not use effects to keep two pieces of application state synchronized.
|
||||
- React Context is suitable for dependency injection or stable scoped state. Avoid one broad
|
||||
app context whose every update rerenders unrelated consumers.
|
||||
|
||||
State-library choice is an implementation detail. Architecture should survive replacing it
|
||||
without rewriting pages and rendering components.
|
||||
|
||||
## Effects And Async Work
|
||||
|
||||
- Use effects to synchronize with external systems, not to calculate render data or handle user
|
||||
events.
|
||||
- Start event-driven work from the event or model command that owns it.
|
||||
- Every subscription, timer, listener, or in-flight operation must have a clear owner and
|
||||
cleanup path.
|
||||
- The owning feature/model defines pending, success, empty, error, retry, and cancellation
|
||||
semantics.
|
||||
- Prevent stale async results and race conditions where users can trigger overlapping work.
|
||||
- Do not hide failures with broad `catch` blocks or silently convert errors into empty data.
|
||||
|
||||
## Data And Infrastructure
|
||||
|
||||
- Treat network responses, storage, URL input, files, and third-party SDK output as untrusted.
|
||||
- Validate and normalize data at the boundary where it enters the application.
|
||||
- Map transport DTOs and external errors into product-oriented values before they reach UI.
|
||||
- Keep raw `fetch`, storage APIs, SDK calls, and protocol details out of rendering components.
|
||||
- Keep a feature-specific API adapter inside the feature until it has a real shared consumer.
|
||||
- Introduce a client, repository, gateway, service, or facade only when its responsibility is
|
||||
distinct and useful.
|
||||
- Avoid wrapper chains that only forward calls. One clear adapter is better than
|
||||
`Client -> Service -> Facade` without separate responsibilities.
|
||||
- Inject infrastructure when tests, multiple implementations, lifecycle, or unstable external
|
||||
APIs justify it. Do not introduce dependency injection for every pure helper.
|
||||
|
||||
## Component And Hook APIs
|
||||
|
||||
- Component and hook APIs describe product intent, not internal implementation.
|
||||
- Avoid boolean prop combinations that create unclear or invalid modes. Prefer explicit variants
|
||||
or separate components.
|
||||
- Avoid passing raw query results, stores, SDK clients, or large configuration objects through
|
||||
component trees.
|
||||
- Keep public props small and cohesive. A component that needs unrelated groups of props likely
|
||||
owns too many responsibilities.
|
||||
- Custom hooks encapsulate React state, lifecycle, or reusable reactive behavior. Pure
|
||||
calculations remain plain functions.
|
||||
- Do not use `useEffect`, `useMemo`, `useCallback`, or `memo` by habit. Use them for correctness
|
||||
or measured performance needs.
|
||||
- Do not duplicate server or domain state into component state merely to make it editable.
|
||||
Create an explicit draft only when the UX requires commit/cancel semantics.
|
||||
|
||||
## Public Boundaries
|
||||
|
||||
- Export the smallest useful public surface of a feature.
|
||||
- Consumers should use a feature's public components, hooks, commands, and types, not deep
|
||||
internal paths.
|
||||
- Keep implementation-only state, DTOs, adapters, and helpers private.
|
||||
- Do not create barrel files everywhere. Use a public entry point only where a real boundary
|
||||
exists.
|
||||
- A reusable abstraction should have a clear owner and at least one current reason to exist.
|
||||
- Avoid generic `core`, `common`, `helpers`, `services`, or `utils` modules that collect
|
||||
unrelated responsibilities.
|
||||
|
||||
## Growing The Architecture
|
||||
|
||||
Start local and promote code only after pressure appears:
|
||||
|
||||
- A second consumer may justify shared domain code, but similar code is not automatically the
|
||||
same knowledge.
|
||||
- Repeated external integration logic may justify a shared adapter.
|
||||
- A process coordinating several capabilities may justify a named workflow.
|
||||
- A large feature may split into smaller capabilities when they have distinct responsibilities
|
||||
and lifecycles.
|
||||
- Separate packages are useful when an enforceable boundary, independent reuse, or independent
|
||||
lifecycle outweighs their maintenance cost.
|
||||
|
||||
Do not begin a small application with every possible layer, package, provider, repository,
|
||||
facade, and design pattern. Strong architecture makes growth cheaper; it does not predict every
|
||||
future requirement.
|
||||
|
||||
## Testing
|
||||
|
||||
- Test product behavior and public contracts, not implementation trivia.
|
||||
- Test pure rules with unit tests.
|
||||
- Test feature models and async transitions without rendering where practical.
|
||||
- Test components through accessible user behavior.
|
||||
- Test infrastructure mapping and validation at external boundaries.
|
||||
- Keep end-to-end tests for critical user journeys.
|
||||
- Mock external systems and unstable boundaries, not every internal function.
|
||||
- Add tests proportional to risk, especially for validation, permissions, races, retries,
|
||||
cancellation, and regressions.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Before finishing a change, ask:
|
||||
|
||||
- Does the file location make its owner obvious?
|
||||
- Does composition code read in product language?
|
||||
- Is UI protected from raw state, DTOs, infrastructure, and business decisions?
|
||||
- Is there one source of truth?
|
||||
- Are effects only synchronizing external systems?
|
||||
- Is new shared code genuinely domain-free or genuinely shared?
|
||||
- Does every abstraction remove current complexity?
|
||||
- Can important behavior be tested without rendering the whole app?
|
||||
- Did the change preserve accessibility, error handling, and cleanup?
|
||||
- Is this the least code that clearly solves the current problem?
|
||||
+6
-11
@@ -1,17 +1,18 @@
|
||||
# Wand Web Panel
|
||||
|
||||
Local mobile-friendly web panel scaffold for Wand.
|
||||
Local mobile-friendly web panel for Wand.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
pnpm dev
|
||||
pnpm bridge:demo
|
||||
```
|
||||
|
||||
Hosted access on the local machine:
|
||||
|
||||
- `http://localhost:4173/?mock=1`
|
||||
- `http://localhost:4173/`
|
||||
|
||||
Hosted access on the LAN:
|
||||
|
||||
@@ -21,11 +22,5 @@ pnpm run dev:host
|
||||
|
||||
Then open the machine IP on port `4173`.
|
||||
|
||||
## Modes
|
||||
|
||||
- `?mock=1`
|
||||
- dev server only; loads the demo trainer and values through a debug-only import
|
||||
- `?ws=ws://host:port/remote/ws`
|
||||
- connects to a real bridge once the desktop layer exists
|
||||
|
||||
Production builds exclude the debug route and demo JSON from the shipped bundle.
|
||||
Use `?ws=ws://host:port/remote/ws` to override the bridge URL. The fixture bridge is dev-only;
|
||||
production is bundled to `dist/bridge.cjs`.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"
|
||||
const bridgeRoot = dirname(fileURLToPath(import.meta.url))
|
||||
const webPanelRoot = resolve(bridgeRoot, "..")
|
||||
const distRoot = resolve(webPanelRoot, "dist")
|
||||
const bridgeEntryPoint = resolve(bridgeRoot, "source.cjs")
|
||||
const bridgeEntryPoint = resolve(bridgeRoot, "src", "index.ts")
|
||||
const bridgeOutfile = resolve(distRoot, "bridge.cjs")
|
||||
const rendererScriptsRoot = resolve(bridgeRoot, "scripts", "default")
|
||||
const rendererScriptsOutdir = resolve(distRoot, "renderer-scripts")
|
||||
|
||||
@@ -4,17 +4,18 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import demoSession from '../fixtures/demo-session.json' with { type: 'json' };
|
||||
import webContract from '../protocol/web-contract.json' with { type: 'json' };
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const distDir = path.join(rootDir, 'dist');
|
||||
const DEFAULT_REMOTE_PORT = 3223;
|
||||
const DEFAULT_REMOTE_HOST = '0.0.0.0';
|
||||
const REMOTE_BASE_PATH = '/remote/';
|
||||
const REMOTE_WS_PATH = '/remote/ws';
|
||||
const REMOTE_HEALTH_PATH = '/remote/api/health';
|
||||
const REMOTE_ASSETS_PREFIX = '/remote/assets/';
|
||||
const DEFAULT_REMOTE_PORT = webContract.defaultRemotePort;
|
||||
const DEFAULT_REMOTE_HOST = webContract.defaultRemoteHost;
|
||||
const REMOTE_BASE_PATH = webContract.basePath;
|
||||
const REMOTE_WS_PATH = webContract.webSocketPath;
|
||||
const REMOTE_HEALTH_PATH = webContract.healthPath;
|
||||
const REMOTE_ASSETS_PREFIX = webContract.assetsPath;
|
||||
const host = process.env.HOST || DEFAULT_REMOTE_HOST;
|
||||
const port = Number(process.env.PORT || DEFAULT_REMOTE_PORT);
|
||||
|
||||
@@ -26,7 +27,7 @@ const wss = new WebSocketServer({ noServer: true });
|
||||
function jsonMessage(type, payload, requestId = null) {
|
||||
return JSON.stringify({
|
||||
type,
|
||||
version: 1,
|
||||
version: webContract.protocolVersion,
|
||||
requestId,
|
||||
payload,
|
||||
});
|
||||
@@ -145,13 +146,20 @@ wss.on('connection', (ws) => {
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const message = JSON.parse(String(raw));
|
||||
if (message?.version !== webContract.protocolVersion || typeof message?.type !== 'string' || !message?.payload) {
|
||||
ws.send(jsonMessage('error', {
|
||||
code: 'invalid_message',
|
||||
message: 'Expected a compatible protocol envelope.',
|
||||
}, message?.requestId ?? null));
|
||||
return;
|
||||
}
|
||||
if (message?.type === 'hello') {
|
||||
ws.send(
|
||||
jsonMessage('hello_ack', {
|
||||
sessionId: `sess_${Date.now()}`,
|
||||
accepted: true,
|
||||
serverVersion: '0.1.0-demo',
|
||||
protocolVersion: 1,
|
||||
protocolVersion: webContract.protocolVersion,
|
||||
}, message.requestId ?? null)
|
||||
);
|
||||
sendSnapshot(ws);
|
||||
@@ -160,7 +168,7 @@ wss.on('connection', (ws) => {
|
||||
|
||||
if (message?.type === 'set_value') {
|
||||
const target = message.payload?.target;
|
||||
if (typeof target !== 'string' || !(target in trainerValues.values)) {
|
||||
if (message.payload?.trainerId !== trainerMeta.trainer.trainerId || typeof target !== 'string' || !(target in trainerValues.values)) {
|
||||
ws.send(
|
||||
jsonMessage('set_value_result', {
|
||||
ok: false,
|
||||
@@ -209,4 +217,4 @@ wss.on('connection', (ws) => {
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`Wand web panel bridge listening on http://${host === DEFAULT_REMOTE_HOST ? 'localhost' : host}:${port}${REMOTE_BASE_PATH}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
const {
|
||||
buildInstalledAppsDebugPayload,
|
||||
gameStatusSignature,
|
||||
installedAppsSignature,
|
||||
normalizeGameStatusSnapshot,
|
||||
normalizeInstalledAppsSnapshot,
|
||||
normalizeSnapshot,
|
||||
normalizeTrainerValue,
|
||||
summarizeInstalledAppsSource,
|
||||
} = require('./normalizers');
|
||||
const { cloneValue, isRecord, safeString } = require('./utils');
|
||||
const { sendJson } = require('./websocket-codec');
|
||||
|
||||
function createBridgeState({ clients, log, getServerInfo }) {
|
||||
let currentSnapshot: any = null;
|
||||
let currentInstalledApps: any = null;
|
||||
let currentInstalledAppsSignature: string | null = null;
|
||||
let currentGameStatus: any = null;
|
||||
let currentGameStatusSignature: string | null = null;
|
||||
|
||||
function broadcast(type, payload, requestId = null) {
|
||||
for (const client of clients) {
|
||||
sendJson(client, type, payload, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function sendSnapshot(client) {
|
||||
if (!currentSnapshot) {
|
||||
sendJson(client, 'trainer_changed', { previousTrainerId: null, trainerId: '' });
|
||||
} else {
|
||||
sendJson(client, 'trainer_meta', currentSnapshot.trainerMeta);
|
||||
sendJson(client, 'trainer_values', currentSnapshot.trainerValues);
|
||||
}
|
||||
if (currentGameStatus) sendJson(client, 'game_status', currentGameStatus);
|
||||
if (currentInstalledApps) sendJson(client, 'installed_apps', currentInstalledApps);
|
||||
}
|
||||
|
||||
function sync(rawSnapshot) {
|
||||
const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null;
|
||||
const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
|
||||
const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
|
||||
currentSnapshot = nextSnapshot;
|
||||
|
||||
if (previousTrainerId !== nextTrainerId) {
|
||||
broadcast('trainer_changed', { previousTrainerId, trainerId: nextTrainerId || '' });
|
||||
}
|
||||
if (currentSnapshot) {
|
||||
broadcast('trainer_meta', currentSnapshot.trainerMeta);
|
||||
broadcast('trainer_values', currentSnapshot.trainerValues);
|
||||
}
|
||||
}
|
||||
|
||||
function valueChanged(change) {
|
||||
if (!currentSnapshot || !isRecord(change)) return;
|
||||
const target = safeString(change.target);
|
||||
if (!target) return;
|
||||
|
||||
const value = normalizeTrainerValue(currentSnapshot, target, change.value);
|
||||
currentSnapshot.trainerValues.values[target] = value;
|
||||
broadcast('value_changed', {
|
||||
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
|
||||
target,
|
||||
value,
|
||||
oldValue: cloneValue(change.oldValue),
|
||||
source: safeString(change.source, 'desktop'),
|
||||
cheatId: typeof change.cheatId === 'string' ? change.cheatId : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function syncInstalledApps(rawInstalledApps) {
|
||||
const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps);
|
||||
const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps);
|
||||
if (!nextInstalledApps) {
|
||||
log('warn', `Ignored invalid installed apps snapshot.${sourceSummary ? ` ${sourceSummary}` : ''}`);
|
||||
return;
|
||||
}
|
||||
const nextSignature = installedAppsSignature(nextInstalledApps);
|
||||
if (nextSignature === currentInstalledAppsSignature) return;
|
||||
currentInstalledApps = nextInstalledApps;
|
||||
currentInstalledAppsSignature = nextSignature;
|
||||
log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
|
||||
broadcast('installed_apps', currentInstalledApps);
|
||||
}
|
||||
|
||||
function syncGameStatus(rawGameStatus) {
|
||||
const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus);
|
||||
if (!nextGameStatus) {
|
||||
log('warn', 'Ignored invalid game status snapshot.');
|
||||
return;
|
||||
}
|
||||
const nextSignature = gameStatusSignature(nextGameStatus);
|
||||
if (nextSignature === currentGameStatusSignature) return;
|
||||
currentGameStatus = nextGameStatus;
|
||||
currentGameStatusSignature = nextSignature;
|
||||
log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`);
|
||||
broadcast('game_status', currentGameStatus);
|
||||
}
|
||||
|
||||
function buildHealthPayload() {
|
||||
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
|
||||
const serverInfo = getServerInfo();
|
||||
return {
|
||||
ok: serverInfo.listening,
|
||||
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || null,
|
||||
gameSessionState: currentGameStatus?.session?.state || 'idle',
|
||||
gameSessionEvent: currentGameStatus?.session?.event || 'snapshot',
|
||||
runningTrainerId: currentGameStatus?.trainer?.trainerId || null,
|
||||
installedAppsCount: installedAppsDebug.counts.myGamesEntries,
|
||||
installedRawAppsCount: installedAppsDebug.counts.rawInstallEntries,
|
||||
installedTitlesCount: installedAppsDebug.counts.groupedTitles,
|
||||
installedUniqueTitleIdsCount: installedAppsDebug.counts.uniqueTitleIds,
|
||||
installedUniqueGameIdsCount: installedAppsDebug.counts.uniqueGameIds,
|
||||
installedAppsApiPath: serverInfo.installedAppsApiPath,
|
||||
remoteUrl: serverInfo.remoteUrl,
|
||||
advertisedUrls: serverInfo.advertisedUrls,
|
||||
};
|
||||
}
|
||||
|
||||
function clear() {
|
||||
currentSnapshot = null;
|
||||
currentInstalledApps = null;
|
||||
currentInstalledAppsSignature = null;
|
||||
currentGameStatus = null;
|
||||
currentGameStatusSignature = null;
|
||||
}
|
||||
|
||||
return {
|
||||
get snapshot() { return currentSnapshot; },
|
||||
buildHealthPayload,
|
||||
buildInstalledAppsDebugPayload: () => buildInstalledAppsDebugPayload(currentInstalledApps),
|
||||
clear,
|
||||
sendSnapshot,
|
||||
sync,
|
||||
syncGameStatus,
|
||||
syncInstalledApps,
|
||||
valueChanged,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeState,
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
const KNOWN_CHEAT_TYPES = new Set(['slider', 'number', 'toggle', 'button', 'selection', 'scalar', 'incremental']);
|
||||
const WEB_CONTRACT = require('../../protocol/web-contract.json');
|
||||
|
||||
const WS_OPCODE = Object.freeze({
|
||||
TEXT: 1,
|
||||
@@ -22,23 +23,23 @@ const IPC_CHANNEL = Object.freeze({
|
||||
|
||||
module.exports = {
|
||||
BRIDGE_LOG_FILE_NAME: 'wand-remote-bridge.log',
|
||||
BRIDGE_PROTOCOL_VERSION: 1,
|
||||
BRIDGE_SERVER_VERSION: '0.2.0-wand',
|
||||
DEFAULT_REMOTE_HOST: '0.0.0.0',
|
||||
DEFAULT_REMOTE_PORT: 3223,
|
||||
BRIDGE_PROTOCOL_VERSION: WEB_CONTRACT.protocolVersion,
|
||||
BRIDGE_SERVER_VERSION: WEB_CONTRACT.serverVersion,
|
||||
DEFAULT_REMOTE_HOST: WEB_CONTRACT.defaultRemoteHost,
|
||||
DEFAULT_REMOTE_PORT: WEB_CONTRACT.defaultRemotePort,
|
||||
IPC_CHANNEL,
|
||||
KNOWN_CHEAT_TYPES,
|
||||
PORT_SCAN_RANGE: 30,
|
||||
REMOTE_ASSETS_PREFIX: '/remote/assets/',
|
||||
REMOTE_BASE_PATH: '/remote/',
|
||||
PORT_SCAN_RANGE: WEB_CONTRACT.portScanRange,
|
||||
REMOTE_ASSETS_PREFIX: WEB_CONTRACT.assetsPath,
|
||||
REMOTE_BASE_PATH: WEB_CONTRACT.basePath,
|
||||
REMOTE_COMMAND_REQUEST_CHANNEL: IPC_CHANNEL.COMMAND_REQUEST,
|
||||
REMOTE_COMMAND_RESPONSE_CHANNEL: IPC_CHANNEL.COMMAND_RESPONSE,
|
||||
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS: 15000,
|
||||
REMOTE_GAME_STATUS_CHANNEL: IPC_CHANNEL.GAME_STATUS,
|
||||
REMOTE_HEALTH_PATH: '/remote/api/health',
|
||||
REMOTE_INSTALLED_APPS_API_PATH: '/remote/api/installed-apps',
|
||||
REMOTE_HEALTH_PATH: WEB_CONTRACT.healthPath,
|
||||
REMOTE_INSTALLED_APPS_API_PATH: WEB_CONTRACT.installedAppsPath,
|
||||
REMOTE_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS,
|
||||
REMOTE_WS_PATH: '/remote/ws',
|
||||
REMOTE_WS_PATH: WEB_CONTRACT.webSocketPath,
|
||||
RENDERER_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
|
||||
RENDERER_SCRIPT_API_VERSION: 1,
|
||||
RENDERER_SCRIPTS_DIR: 'renderer-scripts',
|
||||
@@ -1,7 +1,8 @@
|
||||
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./bridge-modules/runtime.cjs');
|
||||
const { installWandRuntime: installRuntime } = require('./bridge-modules/wand-runtime.cjs');
|
||||
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./runtime');
|
||||
const { installWandRuntime: installRuntime } = require('./wand/runtime');
|
||||
import type { BridgeOptions, ElectronPort } from './types';
|
||||
|
||||
function withDefaultPanelRoot(options = {}) {
|
||||
function withDefaultPanelRoot(options: BridgeOptions = {}): BridgeOptions {
|
||||
if (options.panelRoot) {
|
||||
return options;
|
||||
}
|
||||
@@ -12,15 +13,15 @@ function withDefaultPanelRoot(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createBridgeRuntime(options = {}) {
|
||||
function createBridgeRuntime(options: BridgeOptions = {}) {
|
||||
return createRuntime(withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
function ensureBridge(options = {}) {
|
||||
function ensureBridge(options: BridgeOptions = {}) {
|
||||
return ensureRuntime(withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
function installWandRuntime(electron, options = {}) {
|
||||
function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) {
|
||||
return installRuntime(electron, withDefaultPanelRoot(options));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { BRIDGE_LOG_FILE_NAME } = require('./constants.cjs');
|
||||
const { BRIDGE_LOG_FILE_NAME } = require('./constants');
|
||||
import type { BridgeOptions } from './types';
|
||||
|
||||
function writeLogLine(logFile, level, message, error) {
|
||||
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
|
||||
@@ -18,7 +19,7 @@ function writeLogLine(logFile, level, message, error) {
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function createBridgeLogger(options = {}) {
|
||||
function createBridgeLogger(options: BridgeOptions = {}) {
|
||||
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
|
||||
const log = (level, message, error) => writeLogLine(logFile, level, message, error);
|
||||
log.file = logFile;
|
||||
@@ -0,0 +1,32 @@
|
||||
const { isRecord, safeString, toStringId } = require('../utils');
|
||||
|
||||
function normalizeRemoteCommandAction(value) {
|
||||
return value === 'launch' || value === 'stop' ? value : null;
|
||||
}
|
||||
|
||||
function normalizeRemoteCommandResult(rawResult, fallback) {
|
||||
const action = normalizeRemoteCommandAction(isRecord(rawResult) ? rawResult.action : null) || fallback.action;
|
||||
const gameId = isRecord(rawResult) ? toStringId(rawResult.gameId) || fallback.gameId || null : fallback.gameId || null;
|
||||
const titleId = isRecord(rawResult) ? toStringId(rawResult.titleId) || fallback.titleId || null : fallback.titleId || null;
|
||||
const ok = rawResult === true || Boolean(isRecord(rawResult) && rawResult.ok === true);
|
||||
const payload = { ok, action, gameId, titleId };
|
||||
if (ok) return payload;
|
||||
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
|
||||
return {
|
||||
...payload,
|
||||
error: { code: 'command_rejected', message: 'The renderer rejected the remote command.' },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
error: {
|
||||
code: safeString(rawResult.error.code, 'command_rejected'),
|
||||
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeRemoteCommandAction,
|
||||
normalizeRemoteCommandResult,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
const { isRecord, safeString, toStringId } = require('../utils');
|
||||
|
||||
function normalizeGameStatusSnapshot(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot)) return null;
|
||||
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
|
||||
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
|
||||
return {
|
||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
|
||||
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
|
||||
session: {
|
||||
state: rawSession.state === 'running' ? 'running' : 'idle',
|
||||
event: safeString(rawSession.event, 'snapshot'),
|
||||
processId: typeof rawSession.processId === 'number' ? rawSession.processId : null,
|
||||
gameId: toStringId(rawSession.gameId),
|
||||
titleId: toStringId(rawSession.titleId),
|
||||
titleName: typeof rawSession.titleName === 'string' ? rawSession.titleName : null,
|
||||
sessionDurationSeconds: typeof rawSession.sessionDurationSeconds === 'number' ? rawSession.sessionDurationSeconds : null,
|
||||
startedAt: typeof rawSession.startedAt === 'string' ? rawSession.startedAt : null,
|
||||
endedAt: typeof rawSession.endedAt === 'string' ? rawSession.endedAt : null,
|
||||
},
|
||||
trainer: {
|
||||
state: rawTrainer.state === 'running' ? 'running' : 'idle',
|
||||
event: safeString(rawTrainer.event, 'snapshot'),
|
||||
trainerId: toStringId(rawTrainer.trainerId),
|
||||
displayName: typeof rawTrainer.displayName === 'string' ? rawTrainer.displayName : null,
|
||||
gameId: toStringId(rawTrainer.gameId),
|
||||
titleId: toStringId(rawTrainer.titleId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gameStatusSignature(snapshot) {
|
||||
return [
|
||||
snapshot.session.state,
|
||||
snapshot.session.event,
|
||||
snapshot.session.processId || '',
|
||||
snapshot.session.gameId || '',
|
||||
snapshot.session.titleId || '',
|
||||
snapshot.session.titleName || '',
|
||||
snapshot.session.sessionDurationSeconds || '',
|
||||
snapshot.session.startedAt || '',
|
||||
snapshot.session.endedAt || '',
|
||||
snapshot.trainer.state,
|
||||
snapshot.trainer.event,
|
||||
snapshot.trainer.trainerId || '',
|
||||
snapshot.trainer.displayName || '',
|
||||
snapshot.trainer.gameId || '',
|
||||
snapshot.trainer.titleId || '',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
gameStatusSignature,
|
||||
normalizeGameStatusSnapshot,
|
||||
};
|
||||
+15
-100
@@ -1,5 +1,8 @@
|
||||
const { KNOWN_CHEAT_TYPES } = require('./constants.cjs');
|
||||
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('./utils.cjs');
|
||||
const { KNOWN_CHEAT_TYPES } = require('../constants');
|
||||
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('../utils');
|
||||
const { normalizeRemoteCommandAction, normalizeRemoteCommandResult } = require('./command-results');
|
||||
const { gameStatusSignature, normalizeGameStatusSnapshot } = require('./game-status');
|
||||
const { normalizeTrainerValue } = require('./trainer');
|
||||
|
||||
function normalizeOption(option) {
|
||||
if (typeof option === 'string' || typeof option === 'number') {
|
||||
@@ -29,7 +32,7 @@ function normalizeArgs(args) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const next = {};
|
||||
const next: Record<string, unknown> = {};
|
||||
if (typeof args.min === 'number') next.min = args.min;
|
||||
if (typeof args.max === 'number') next.max = args.max;
|
||||
if (typeof args.step === 'number') next.step = args.step;
|
||||
@@ -60,7 +63,7 @@ function normalizeCheat(cheat, index) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
const normalized: Record<string, unknown> = {
|
||||
uuid: safeString(cheat.uuid, `${target}-${index}`),
|
||||
target,
|
||||
type,
|
||||
@@ -161,88 +164,13 @@ function normalizeInstalledAppsSnapshot(rawSnapshot) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGameStatusSnapshot(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
|
||||
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
|
||||
|
||||
return {
|
||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
|
||||
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
|
||||
session: {
|
||||
state: rawSession.state === 'running' ? 'running' : 'idle',
|
||||
event: safeString(rawSession.event, 'snapshot'),
|
||||
processId: typeof rawSession.processId === 'number' ? rawSession.processId : null,
|
||||
gameId: toStringId(rawSession.gameId),
|
||||
titleId: toStringId(rawSession.titleId),
|
||||
titleName: typeof rawSession.titleName === 'string' ? rawSession.titleName : null,
|
||||
sessionDurationSeconds: typeof rawSession.sessionDurationSeconds === 'number' ? rawSession.sessionDurationSeconds : null,
|
||||
startedAt: typeof rawSession.startedAt === 'string' ? rawSession.startedAt : null,
|
||||
endedAt: typeof rawSession.endedAt === 'string' ? rawSession.endedAt : null,
|
||||
},
|
||||
trainer: {
|
||||
state: rawTrainer.state === 'running' ? 'running' : 'idle',
|
||||
event: safeString(rawTrainer.event, 'snapshot'),
|
||||
trainerId: toStringId(rawTrainer.trainerId),
|
||||
displayName: typeof rawTrainer.displayName === 'string' ? rawTrainer.displayName : null,
|
||||
gameId: toStringId(rawTrainer.gameId),
|
||||
titleId: toStringId(rawTrainer.titleId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRemoteCommandAction(value) {
|
||||
if (value === 'launch' || value === 'stop') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeRemoteCommandResult(rawResult, fallback) {
|
||||
const action = normalizeRemoteCommandAction(isRecord(rawResult) ? rawResult.action : null) || fallback.action;
|
||||
const gameId = isRecord(rawResult) ? toStringId(rawResult.gameId) || fallback.gameId || null : fallback.gameId || null;
|
||||
const titleId = isRecord(rawResult) ? toStringId(rawResult.titleId) || fallback.titleId || null : fallback.titleId || null;
|
||||
const ok = rawResult === true || Boolean(isRecord(rawResult) && rawResult.ok === true);
|
||||
const payload = {
|
||||
ok,
|
||||
action,
|
||||
gameId,
|
||||
titleId,
|
||||
};
|
||||
|
||||
if (ok) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
|
||||
return {
|
||||
...payload,
|
||||
error: {
|
||||
code: 'command_rejected',
|
||||
message: 'The renderer rejected the remote command.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
error: {
|
||||
code: safeString(rawResult.error.code, 'command_rejected'),
|
||||
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeInstalledAppsSource(rawSnapshot) {
|
||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
const parts: string[] = [];
|
||||
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
|
||||
const value = rawSnapshot.diagnostics[key];
|
||||
if (typeof value === 'number') {
|
||||
@@ -269,26 +197,6 @@ function installedAppsSignature(snapshot) {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function gameStatusSignature(snapshot) {
|
||||
return [
|
||||
snapshot.session.state,
|
||||
snapshot.session.event,
|
||||
snapshot.session.processId || '',
|
||||
snapshot.session.gameId || '',
|
||||
snapshot.session.titleId || '',
|
||||
snapshot.session.titleName || '',
|
||||
snapshot.session.sessionDurationSeconds || '',
|
||||
snapshot.session.startedAt || '',
|
||||
snapshot.session.endedAt || '',
|
||||
snapshot.trainer.state,
|
||||
snapshot.trainer.event,
|
||||
snapshot.trainer.trainerId || '',
|
||||
snapshot.trainer.displayName || '',
|
||||
snapshot.trainer.gameId || '',
|
||||
snapshot.trainer.titleId || '',
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function buildInstalledAppsDebugPayload(snapshot) {
|
||||
if (!snapshot) {
|
||||
return {
|
||||
@@ -412,6 +320,7 @@ function normalizeSnapshot(rawSnapshot) {
|
||||
const trainerMeta = {
|
||||
session: {
|
||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
|
||||
accessToken: safeString(rawSnapshot.accessToken),
|
||||
},
|
||||
trainer: {
|
||||
trainerId,
|
||||
@@ -437,6 +346,11 @@ function normalizeSnapshot(rawSnapshot) {
|
||||
trainerId,
|
||||
values: isRecord(rawSnapshot.values) ? cloneValue(rawSnapshot.values) : {},
|
||||
};
|
||||
for (const cheat of cheats) {
|
||||
if (cheat.target in trainerValues.values) {
|
||||
trainerValues.values[cheat.target] = normalizeTrainerValue({ trainerMeta }, cheat.target, trainerValues.values[cheat.target]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
trainerMeta,
|
||||
@@ -479,5 +393,6 @@ module.exports = {
|
||||
normalizeRemoteCommandAction,
|
||||
normalizeRemoteCommandResult,
|
||||
normalizeSnapshot,
|
||||
normalizeTrainerValue,
|
||||
summarizeInstalledAppsSource,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeTrainerValue } from './trainer';
|
||||
|
||||
describe('trainer normalization', () => {
|
||||
it('normalizes toggle values before they reach clients or Wand', () => {
|
||||
const snapshot = {
|
||||
trainerMeta: {
|
||||
schema: { cheats: [{ target: 'god', type: 'toggle' }] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(normalizeTrainerValue(snapshot, 'god', 1)).toBe(true);
|
||||
expect(normalizeTrainerValue(snapshot, 'god', 0)).toBe(false);
|
||||
expect(normalizeTrainerValue(snapshot, 'speed', 2)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export function normalizeTrainerValue(snapshot, target, value) {
|
||||
const cheat = snapshot?.trainerMeta?.schema?.cheats?.find((entry) => entry.target === target);
|
||||
return cheat?.type === 'toggle' ? Boolean(value) : cloneValue(value);
|
||||
}
|
||||
|
||||
function cloneValue(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneValue);
|
||||
if (typeof value !== 'object' || value === null) return value;
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)]));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { validateClientMessage, validateSetValueTarget } from './protocol-router';
|
||||
|
||||
const snapshot = {
|
||||
trainerMeta: {
|
||||
trainer: { trainerId: 'active' },
|
||||
schema: { cheats: [{ target: 'god', type: 'toggle' }] },
|
||||
},
|
||||
trainerValues: { values: { god: false } },
|
||||
};
|
||||
|
||||
describe('bridge protocol router', () => {
|
||||
it('requires a compatible hello before commands', () => {
|
||||
const command = {
|
||||
type: 'set_value',
|
||||
version: 1,
|
||||
requestId: 'set',
|
||||
payload: { trainerId: 'active', target: 'god', value: true },
|
||||
};
|
||||
expect(validateClientMessage(command, false)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'handshake_required' },
|
||||
});
|
||||
expect(validateClientMessage({ ...command, version: 2 }, true)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'protocol_mismatch' },
|
||||
});
|
||||
});
|
||||
|
||||
it('validates trainer and target while normalizing toggle values', () => {
|
||||
expect(validateSetValueTarget({
|
||||
payload: { trainerId: 'other', target: 'god', value: 1 },
|
||||
}, snapshot)).toMatchObject({ ok: false, error: { code: 'trainer_mismatch' } });
|
||||
|
||||
expect(validateSetValueTarget({
|
||||
payload: { trainerId: 'active', target: 'god', value: 1 },
|
||||
}, snapshot)).toMatchObject({ ok: true, value: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import webContract from '../../protocol/web-contract.json';
|
||||
|
||||
const BRIDGE_PROTOCOL_VERSION = webContract.protocolVersion;
|
||||
|
||||
export function validateClientMessage(message, handshaken) {
|
||||
if (!isRecord(message) || typeof message.type !== 'string' || !isRecord(message.payload)) {
|
||||
return invalid('invalid_message', 'Expected a protocol envelope with an object payload.');
|
||||
}
|
||||
|
||||
if (message.version !== BRIDGE_PROTOCOL_VERSION) {
|
||||
return invalid('protocol_mismatch', `Unsupported protocol version ${String(message.version)}.`);
|
||||
}
|
||||
|
||||
if (message.requestId !== null && typeof message.requestId !== 'string') {
|
||||
return invalid('invalid_request_id', 'requestId must be a string or null.');
|
||||
}
|
||||
|
||||
if (message.type === 'hello') {
|
||||
if (message.payload.client !== 'mobile-web' || typeof message.payload.clientVersion !== 'string' || !isRecord(message.payload.capabilities)) {
|
||||
return invalid('invalid_hello', 'The hello payload is incomplete.');
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (!handshaken) {
|
||||
return invalid('handshake_required', 'Send a compatible hello message before commands.');
|
||||
}
|
||||
|
||||
if (message.type === 'set_value') {
|
||||
if (!safeString(message.payload.trainerId) || !safeString(message.payload.target) || !('value' in message.payload)) {
|
||||
return invalid('invalid_set_value', 'trainerId, target and value are required.');
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (message.type === 'remote_command') {
|
||||
if (message.payload.action !== 'launch' && message.payload.action !== 'stop') {
|
||||
return invalid('invalid_command', 'Unknown remote command.');
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return invalid('unknown_message', 'Unknown protocol message type.');
|
||||
}
|
||||
|
||||
export function validateSetValueTarget(message, snapshot) {
|
||||
const target = safeString(message.payload?.target);
|
||||
const requestedTrainerId = safeString(message.payload?.trainerId);
|
||||
const activeTrainerId = snapshot?.trainerMeta?.trainer?.trainerId || '';
|
||||
if (!snapshot || requestedTrainerId !== activeTrainerId) {
|
||||
return invalid('trainer_mismatch', 'The requested trainer is not active.');
|
||||
}
|
||||
|
||||
const cheat = snapshot.trainerMeta.schema.cheats.find((entry) => entry.target === target);
|
||||
if (!target || !cheat || !(target in snapshot.trainerValues.values)) {
|
||||
return invalid('invalid_target', 'Unknown cheat target.');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
trainerId: activeTrainerId,
|
||||
target,
|
||||
cheat,
|
||||
value: cheat.type === 'toggle' ? Boolean(message.payload.value) : message.payload.value,
|
||||
};
|
||||
}
|
||||
|
||||
function invalid(code, message) {
|
||||
return { ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function safeString(value) {
|
||||
return typeof value === 'string' && value.length > 0 ? value : '';
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findSteamAppId,
|
||||
getSteamClientIconUrl,
|
||||
normalizeImageUrl,
|
||||
} from '../scripts/default/installed-apps-sync/artwork.js';
|
||||
|
||||
describe('installed-apps renderer script models', () => {
|
||||
it('normalizes captured artwork shapes without a Wand runtime', () => {
|
||||
expect(normalizeImageUrl({ cover: { imageUrl: '//cdn.example/game.webp' } }))
|
||||
.toBe('https://cdn.example/game.webp');
|
||||
expect(normalizeImageUrl('file:///local/image.png')).toBeNull();
|
||||
});
|
||||
|
||||
it('finds nested Steam metadata and builds the Wand client icon URL', () => {
|
||||
const fixture = {
|
||||
game: {
|
||||
metadata: {
|
||||
steam: {
|
||||
appId: 1245620,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(findSteamAppId(fixture)).toBe('1245620');
|
||||
expect(getSteamClientIconUrl(findSteamAppId(fixture)))
|
||||
.toBe('https://api-cdn.wemod.com/steam_community/1245620/client_icon/96.webp');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WebSocket as NodeWebSocket } from 'ws';
|
||||
|
||||
describe('production bridge runtime', () => {
|
||||
it('preserves the public API and sends cached snapshots after hello', async () => {
|
||||
const bridge = require('../../dist/bridge.cjs');
|
||||
expect(Object.keys(bridge).sort()).toEqual(['createBridgeRuntime', 'ensureBridge', 'installWandRuntime']);
|
||||
|
||||
const port = await getFreePort();
|
||||
const runtime = bridge.createBridgeRuntime({ host: '127.0.0.1', port, maxPort: port });
|
||||
runtime.sync(rawTrainerSnapshot());
|
||||
|
||||
try {
|
||||
await waitUntil(() => runtime.listening);
|
||||
const messages = await connectAndCollect(port, 3);
|
||||
expect(messages.map((message) => message.type)).toEqual(['hello_ack', 'trainer_meta', 'trainer_values']);
|
||||
expect(messages[2].payload.values.god).toBe(true);
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close((error) => error ? reject(error) : resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function connectAndCollect(port: number, count: number): Promise<any[]> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const messages: any[] = [];
|
||||
const socket = new NodeWebSocket(`ws://127.0.0.1:${port}/remote/ws`);
|
||||
socket.once('error', reject);
|
||||
socket.once('open', () => socket.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
version: 1,
|
||||
requestId: 'hello',
|
||||
payload: {
|
||||
client: 'mobile-web',
|
||||
clientVersion: 'test',
|
||||
capabilities: { supportsDeltaValues: true, supportsTrainerSwitch: true },
|
||||
},
|
||||
})));
|
||||
socket.on('message', (raw) => {
|
||||
messages.push(JSON.parse(String(raw)));
|
||||
if (messages.length === count) {
|
||||
socket.close();
|
||||
resolve(messages);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean): Promise<void> {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('Bridge did not start listening.');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
function rawTrainerSnapshot() {
|
||||
return {
|
||||
instanceId: 'instance',
|
||||
trainerId: 'trainer',
|
||||
trainerInfo: { gameId: 'game', displayName: 'Game' },
|
||||
metadata: {
|
||||
info: {
|
||||
blueprint: {
|
||||
cheats: [{
|
||||
uuid: 'god',
|
||||
target: 'god',
|
||||
type: 'toggle',
|
||||
name: 'God mode',
|
||||
category: 'player',
|
||||
args: {},
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
values: { god: 1 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const { createBridgeServer } = require('./server');
|
||||
import type { BridgeOptions } from './types';
|
||||
|
||||
function createBridgeRuntime(options: BridgeOptions = {}) {
|
||||
return createBridgeServer(options);
|
||||
}
|
||||
|
||||
function ensureBridge(options: BridgeOptions = {}) {
|
||||
if (!globalThis.__wandRemoteBridgeRuntime) {
|
||||
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
|
||||
}
|
||||
|
||||
return globalThis.__wandRemoteBridgeRuntime;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeRuntime,
|
||||
ensureBridge,
|
||||
};
|
||||
+5
-5
@@ -2,7 +2,7 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { REMOTE_BASE_PATH } = require('./constants.cjs');
|
||||
const { REMOTE_BASE_PATH } = require('./constants');
|
||||
|
||||
const IPV4_OCTET_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
const PHYSICAL_INTERFACE_NAME_PATTERN = /(?:ethernet|wi-?fi|wireless|wlan|lan|local area)/i;
|
||||
@@ -38,11 +38,11 @@ function contentTypeFor(filePath) {
|
||||
}
|
||||
|
||||
function getAdvertisedUrls(port) {
|
||||
const candidates = [];
|
||||
const candidates: any[] = [];
|
||||
const interfaces = os.networkInterfaces();
|
||||
let index = 0;
|
||||
|
||||
for (const [name, entries] of Object.entries(interfaces)) {
|
||||
for (const [name, entries] of Object.entries(interfaces) as [string, any[] | undefined][]) {
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ function isIpv4Family(family) {
|
||||
}
|
||||
|
||||
function scoreIpv4Entry(name, entry) {
|
||||
const octets = parseIpv4(entry.address);
|
||||
const octets = parseIpv4(entry.address) as number[];
|
||||
let score = 0;
|
||||
|
||||
if (isPrivateIpv4(octets)) {
|
||||
@@ -116,7 +116,7 @@ function scoreIpv4Entry(name, entry) {
|
||||
return score;
|
||||
}
|
||||
|
||||
function parseIpv4(address) {
|
||||
function parseIpv4(address): number[] | null {
|
||||
if (typeof address !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -13,40 +13,41 @@ const {
|
||||
REMOTE_INSTALLED_APPS_API_PATH,
|
||||
REMOTE_WS_PATH,
|
||||
WS_OPCODE,
|
||||
} = require('./constants.cjs');
|
||||
const { createBridgeLogger } = require('./logger.cjs');
|
||||
} = require('./constants');
|
||||
const { createBridgeLogger } = require('./logger');
|
||||
const {
|
||||
buildInstalledAppsDebugPayload,
|
||||
gameStatusSignature,
|
||||
installedAppsSignature,
|
||||
normalizeGameStatusSnapshot,
|
||||
normalizeInstalledAppsSnapshot,
|
||||
normalizeRemoteCommandAction,
|
||||
normalizeRemoteCommandResult,
|
||||
normalizeSnapshot,
|
||||
summarizeInstalledAppsSource,
|
||||
} = require('./normalizers.cjs');
|
||||
const { getAdvertisedUrls, serveFile } = require('./static-server.cjs');
|
||||
const { cloneValue, isRecord, isValidPort, safeString } = require('./utils.cjs');
|
||||
const { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket.cjs');
|
||||
} = require('./normalizers');
|
||||
const { createBridgeState } = require('./bridge-state');
|
||||
const { validateClientMessage, validateSetValueTarget } = require('./protocol-router');
|
||||
const { getAdvertisedUrls, serveFile } = require('./server-files');
|
||||
const { cloneValue, isValidPort, safeString } = require('./utils');
|
||||
const { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket-codec');
|
||||
import type { BridgeOptions } from './types';
|
||||
|
||||
function createBridgeRuntime(options = {}) {
|
||||
function createBridgeServer(options: BridgeOptions = {}) {
|
||||
const preferredPort = Number(options.port || process.env.WAND_REMOTE_PORT || DEFAULT_REMOTE_PORT);
|
||||
let port = isValidPort(preferredPort) ? preferredPort : DEFAULT_REMOTE_PORT;
|
||||
const maxPort = Number(options.maxPort || process.env.WAND_REMOTE_MAX_PORT || port + PORT_SCAN_RANGE);
|
||||
const host = options.host || process.env.WAND_REMOTE_HOST || DEFAULT_REMOTE_HOST;
|
||||
const panelRoot = options.panelRoot || path.dirname(__dirname);
|
||||
const clients = new Set();
|
||||
const clients = new Set<any>();
|
||||
const log = createBridgeLogger(options);
|
||||
let advertisedUrls = [];
|
||||
let currentSnapshot = null;
|
||||
let currentInstalledApps = null;
|
||||
let currentInstalledAppsSignature = null;
|
||||
let currentGameStatus = null;
|
||||
let currentGameStatusSignature = null;
|
||||
let setValueHandler = null;
|
||||
let commandHandler = null;
|
||||
let advertisedUrls: string[] = [];
|
||||
let setValueHandler: any = null;
|
||||
let commandHandler: any = null;
|
||||
let listening = false;
|
||||
const bridgeState = createBridgeState({
|
||||
clients,
|
||||
log,
|
||||
getServerInfo: () => ({
|
||||
advertisedUrls,
|
||||
installedAppsApiPath: REMOTE_INSTALLED_APPS_API_PATH,
|
||||
listening,
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
function setAdvertisedPort(nextPort) {
|
||||
port = nextPort;
|
||||
@@ -54,112 +55,6 @@ function createBridgeRuntime(options = {}) {
|
||||
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
|
||||
}
|
||||
|
||||
function broadcast(type, payload, requestId = null) {
|
||||
for (const client of clients) {
|
||||
sendJson(client, type, payload, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function sendSnapshot(client) {
|
||||
if (!currentSnapshot) {
|
||||
sendJson(client, 'trainer_changed', {
|
||||
previousTrainerId: null,
|
||||
trainerId: '',
|
||||
});
|
||||
} else {
|
||||
sendJson(client, 'trainer_meta', currentSnapshot.trainerMeta);
|
||||
sendJson(client, 'trainer_values', currentSnapshot.trainerValues);
|
||||
}
|
||||
|
||||
if (currentGameStatus) {
|
||||
sendJson(client, 'game_status', currentGameStatus);
|
||||
}
|
||||
|
||||
if (currentInstalledApps) {
|
||||
sendJson(client, 'installed_apps', currentInstalledApps);
|
||||
}
|
||||
}
|
||||
|
||||
function sync(rawSnapshot) {
|
||||
const nextSnapshot = rawSnapshot ? normalizeSnapshot(rawSnapshot) : null;
|
||||
const previousTrainerId = currentSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
|
||||
const nextTrainerId = nextSnapshot?.trainerMeta?.trainer?.trainerId ?? null;
|
||||
currentSnapshot = nextSnapshot;
|
||||
|
||||
if (previousTrainerId !== nextTrainerId) {
|
||||
broadcast('trainer_changed', {
|
||||
previousTrainerId,
|
||||
trainerId: nextTrainerId || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (!currentSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcast('trainer_meta', currentSnapshot.trainerMeta);
|
||||
broadcast('trainer_values', currentSnapshot.trainerValues);
|
||||
}
|
||||
|
||||
function valueChanged(change) {
|
||||
if (!currentSnapshot || !isRecord(change)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = safeString(change.target);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentSnapshot.trainerValues.values[target] = cloneValue(change.value);
|
||||
broadcast('value_changed', {
|
||||
trainerId: safeString(change.trainerId, currentSnapshot.trainerMeta.trainer.trainerId),
|
||||
target,
|
||||
value: cloneValue(change.value),
|
||||
oldValue: cloneValue(change.oldValue),
|
||||
source: safeString(change.source, 'desktop'),
|
||||
cheatId: typeof change.cheatId === 'string' ? change.cheatId : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function syncInstalledApps(rawInstalledApps) {
|
||||
const sourceSummary = summarizeInstalledAppsSource(rawInstalledApps);
|
||||
const nextInstalledApps = normalizeInstalledAppsSnapshot(rawInstalledApps);
|
||||
if (!nextInstalledApps) {
|
||||
log('warn', `Ignored invalid installed apps snapshot.${sourceSummary ? ` ${sourceSummary}` : ''}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSignature = installedAppsSignature(nextInstalledApps);
|
||||
if (nextSignature === currentInstalledAppsSignature) {
|
||||
log('info', `Installed apps snapshot unchanged (${nextInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
|
||||
return;
|
||||
}
|
||||
|
||||
currentInstalledApps = nextInstalledApps;
|
||||
currentInstalledAppsSignature = nextSignature;
|
||||
log('info', `Installed apps snapshot accepted (${currentInstalledApps.apps.length} app(s)).${sourceSummary ? ` ${sourceSummary}` : ''}`);
|
||||
broadcast('installed_apps', currentInstalledApps);
|
||||
}
|
||||
|
||||
function syncGameStatus(rawGameStatus) {
|
||||
const nextGameStatus = normalizeGameStatusSnapshot(rawGameStatus);
|
||||
if (!nextGameStatus) {
|
||||
log('warn', 'Ignored invalid game status snapshot.');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSignature = gameStatusSignature(nextGameStatus);
|
||||
if (nextSignature === currentGameStatusSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentGameStatus = nextGameStatus;
|
||||
currentGameStatusSignature = nextSignature;
|
||||
log('info', `Game status snapshot accepted (${currentGameStatus.session.state}/${currentGameStatus.session.event}).`);
|
||||
broadcast('game_status', currentGameStatus);
|
||||
}
|
||||
|
||||
function setHandler(handler) {
|
||||
setValueHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
@@ -168,25 +63,6 @@ function createBridgeRuntime(options = {}) {
|
||||
commandHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
|
||||
function buildHealthPayload() {
|
||||
const installedAppsDebug = buildInstalledAppsDebugPayload(currentInstalledApps);
|
||||
return {
|
||||
ok: listening,
|
||||
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || null,
|
||||
gameSessionState: currentGameStatus?.session?.state || 'idle',
|
||||
gameSessionEvent: currentGameStatus?.session?.event || 'snapshot',
|
||||
runningTrainerId: currentGameStatus?.trainer?.trainerId || null,
|
||||
installedAppsCount: installedAppsDebug.counts.myGamesEntries,
|
||||
installedRawAppsCount: installedAppsDebug.counts.rawInstallEntries,
|
||||
installedTitlesCount: installedAppsDebug.counts.groupedTitles,
|
||||
installedUniqueTitleIdsCount: installedAppsDebug.counts.uniqueTitleIds,
|
||||
installedUniqueGameIdsCount: installedAppsDebug.counts.uniqueGameIds,
|
||||
installedAppsApiPath: REMOTE_INSTALLED_APPS_API_PATH,
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
advertisedUrls,
|
||||
};
|
||||
}
|
||||
|
||||
function handleRequest(request, response) {
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||
|
||||
@@ -209,13 +85,13 @@ function createBridgeRuntime(options = {}) {
|
||||
|
||||
if (url.pathname === REMOTE_HEALTH_PATH) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
response.end(JSON.stringify(buildHealthPayload()));
|
||||
response.end(JSON.stringify(bridgeState.buildHealthPayload()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === REMOTE_INSTALLED_APPS_API_PATH) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
response.end(JSON.stringify(buildInstalledAppsDebugPayload(currentInstalledApps), null, 2));
|
||||
response.end(JSON.stringify(bridgeState.buildInstalledAppsDebugPayload(), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -283,19 +159,18 @@ function createBridgeRuntime(options = {}) {
|
||||
}
|
||||
|
||||
async function handleSetValueMessage(client, message) {
|
||||
const target = safeString(message.payload?.target);
|
||||
if (!currentSnapshot || !target || !(target in currentSnapshot.trainerValues.values)) {
|
||||
const currentSnapshot = bridgeState.snapshot;
|
||||
const validation = validateSetValueTarget(message, currentSnapshot);
|
||||
if (!validation.ok) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
ok: false,
|
||||
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '',
|
||||
target,
|
||||
error: {
|
||||
code: 'invalid_target',
|
||||
message: 'Unknown cheat target.',
|
||||
},
|
||||
target: safeString(message.payload?.target),
|
||||
error: validation.error,
|
||||
}, message.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
const { target } = validation;
|
||||
|
||||
if (!setValueHandler) {
|
||||
sendJson(client, 'set_value_result', {
|
||||
@@ -315,7 +190,7 @@ function createBridgeRuntime(options = {}) {
|
||||
result = await Promise.resolve(setValueHandler({
|
||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||
target,
|
||||
value: cloneValue(message.payload?.value),
|
||||
value: cloneValue(validation.value),
|
||||
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -352,7 +227,14 @@ function createBridgeRuntime(options = {}) {
|
||||
}
|
||||
|
||||
async function handleClientMessage(client, message) {
|
||||
const validation = validateClientMessage(message, client.handshaken);
|
||||
if (!validation.ok) {
|
||||
sendJson(client, 'error', validation.error, message?.requestId ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === 'hello') {
|
||||
client.handshaken = true;
|
||||
sendJson(client, 'hello_ack', {
|
||||
sessionId: `sess_${Date.now()}`,
|
||||
accepted: true,
|
||||
@@ -361,7 +243,7 @@ function createBridgeRuntime(options = {}) {
|
||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||
advertisedUrls,
|
||||
}, message.requestId ?? null);
|
||||
sendSnapshot(client);
|
||||
bridgeState.sendSnapshot(client);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -380,6 +262,7 @@ function createBridgeRuntime(options = {}) {
|
||||
socket,
|
||||
buffer: Buffer.alloc(0),
|
||||
closed: false,
|
||||
handshaken: false,
|
||||
};
|
||||
|
||||
clients.add(client);
|
||||
@@ -510,32 +393,19 @@ function createBridgeRuntime(options = {}) {
|
||||
closeClient(client);
|
||||
}
|
||||
clients.clear();
|
||||
currentSnapshot = null;
|
||||
currentInstalledApps = null;
|
||||
currentInstalledAppsSignature = null;
|
||||
currentGameStatus = null;
|
||||
currentGameStatusSignature = null;
|
||||
bridgeState.clear();
|
||||
listening = false;
|
||||
server.close();
|
||||
},
|
||||
setCommandHandler,
|
||||
setHandler,
|
||||
sync,
|
||||
syncGameStatus,
|
||||
syncInstalledApps,
|
||||
valueChanged,
|
||||
sync: bridgeState.sync,
|
||||
syncGameStatus: bridgeState.syncGameStatus,
|
||||
syncInstalledApps: bridgeState.syncInstalledApps,
|
||||
valueChanged: bridgeState.valueChanged,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureBridge(options = {}) {
|
||||
if (!globalThis.__wandRemoteBridgeRuntime) {
|
||||
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
|
||||
}
|
||||
|
||||
return globalThis.__wandRemoteBridgeRuntime;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBridgeRuntime,
|
||||
ensureBridge,
|
||||
createBridgeServer,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
export type BridgeOptions = {
|
||||
host?: string;
|
||||
logFile?: string;
|
||||
maxPort?: number | string;
|
||||
panelRoot?: string;
|
||||
port?: number | string;
|
||||
scriptsRoot?: string;
|
||||
};
|
||||
|
||||
export type WebContentsPort = {
|
||||
executeJavaScript(source: string, userGesture?: boolean): Promise<unknown>;
|
||||
isDestroyed(): boolean;
|
||||
on(event: string, listener: () => void): void;
|
||||
send(channel: string, payload: unknown): void;
|
||||
};
|
||||
|
||||
export type ElectronPort = {
|
||||
app: {
|
||||
on(event: 'web-contents-created', listener: (event: unknown, contents: WebContentsPort) => void): void;
|
||||
};
|
||||
ipcMain: {
|
||||
handle(channel: string, handler: (event: { sender?: WebContentsPort }, payload?: unknown) => unknown): void;
|
||||
};
|
||||
};
|
||||
+4
-3
@@ -1,8 +1,9 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('./constants.cjs');
|
||||
const { writeInstallLog } = require('./logger.cjs');
|
||||
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('../constants');
|
||||
const { writeInstallLog } = require('../logger');
|
||||
import type { BridgeOptions, ElectronPort } from '../types';
|
||||
|
||||
function loadRendererScripts(panelRoot, scriptsRoot) {
|
||||
const root = scriptsRoot || path.join(panelRoot, RENDERER_SCRIPTS_DIR);
|
||||
@@ -51,7 +52,7 @@ function buildRendererBootstrap(remoteUrl, scripts) {
|
||||
return `(() => {\n${header}\n${body}\n})();`;
|
||||
}
|
||||
|
||||
function installRendererScripts(electron, runtime, options = {}) {
|
||||
function installRendererScripts(electron: ElectronPort, runtime, options: BridgeOptions = {}) {
|
||||
if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) {
|
||||
return;
|
||||
}
|
||||
+38
-9
@@ -8,19 +8,25 @@ const {
|
||||
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS,
|
||||
REMOTE_GAME_STATUS_CHANNEL,
|
||||
REMOTE_INSTALLED_APPS_CHANNEL,
|
||||
} = require('./constants.cjs');
|
||||
const { writeInstallLog } = require('./logger.cjs');
|
||||
const { ensureBridge } = require('./runtime.cjs');
|
||||
const { installRendererScripts } = require('./renderer-scripts.cjs');
|
||||
const { safeString } = require('./utils.cjs');
|
||||
} = require('../constants');
|
||||
const { writeInstallLog } = require('../logger');
|
||||
const { ensureBridge } = require('../runtime');
|
||||
const { installRendererScripts } = require('./renderer-scripts');
|
||||
const { safeString } = require('../utils');
|
||||
import type { BridgeOptions, ElectronPort, WebContentsPort } from '../types';
|
||||
|
||||
function installWandRuntime(electron, options = {}) {
|
||||
// Reads the signed-in WeMod access token from the renderer's localStorage so the
|
||||
// panel can request localized cheat metadata from the WeMod API.
|
||||
const WEMOD_ACCESS_TOKEN_SCRIPT =
|
||||
'JSON.parse(localStorage.getItem("infinity:globalStore") || "{}")?.token?.accessToken ?? null';
|
||||
|
||||
function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) {
|
||||
const runtime = ensureBridge(options);
|
||||
if (!electron || !electron.ipcMain || !electron.app) {
|
||||
throw new Error('Electron main-process API is required to install Wand runtime hooks.');
|
||||
}
|
||||
|
||||
const boundRenderers = globalThis.__wandRemoteBridgeBoundRenderers || new Set();
|
||||
const boundRenderers: Set<WebContentsPort> = globalThis.__wandRemoteBridgeBoundRenderers || new Set();
|
||||
const pendingCommandResponses = globalThis.__wandRemoteBridgePendingCommandResponses || new Map();
|
||||
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
|
||||
globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses;
|
||||
@@ -77,8 +83,8 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
|
||||
}
|
||||
|
||||
globalThis.__wandRemoteBridgeIpcInstalled = true;
|
||||
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (_event, snapshot) => {
|
||||
runtime.sync(snapshot);
|
||||
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event, snapshot) => {
|
||||
void syncSnapshotWithAccessToken(runtime, event?.sender, snapshot);
|
||||
return true;
|
||||
});
|
||||
electron.ipcMain.handle(REMOTE_INSTALLED_APPS_CHANNEL, (_event, snapshot) => {
|
||||
@@ -113,6 +119,29 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
|
||||
electron.ipcMain.handle(IPC_CHANNEL.REMOTE_URL, () => runtime.remoteUrl);
|
||||
}
|
||||
|
||||
async function syncSnapshotWithAccessToken(runtime, sender, snapshot) {
|
||||
const accessToken = await readWemodAccessToken(sender);
|
||||
if (accessToken && snapshot && typeof snapshot === 'object') {
|
||||
snapshot.accessToken = accessToken;
|
||||
}
|
||||
|
||||
runtime.sync(snapshot);
|
||||
}
|
||||
|
||||
async function readWemodAccessToken(sender) {
|
||||
if (!sender || typeof sender.executeJavaScript !== 'function' || sender.isDestroyed?.()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await sender.executeJavaScript(WEMOD_ACCESS_TOKEN_SCRIPT);
|
||||
return typeof token === 'string' && token ? token : null;
|
||||
} catch (error) {
|
||||
writeInstallLog('warn', 'Failed to read WeMod access token from renderer.', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = `remote_command_${typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : Date.now().toString(36)}`;
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants.cjs');
|
||||
const { BRIDGE_PROTOCOL_VERSION, WS_OPCODE } = require('./constants');
|
||||
|
||||
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
@@ -15,7 +15,7 @@ function jsonMessage(type, payload, requestId = null) {
|
||||
|
||||
function makeFrame(opcode, payload) {
|
||||
const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
||||
const header = [];
|
||||
const header: number[] = [];
|
||||
header.push(0x80 | (opcode & 0x0f));
|
||||
|
||||
if (source.length < 126) {
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noImplicitAny": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(['dist', 'src/locales']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
@@ -19,5 +19,16 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['bridge/src/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-empty': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
<script type="module" src="./src/app/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from '@lingui/cli';
|
||||
import { formatter } from '@lingui/format-po';
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: 'en',
|
||||
locales: ['en'],
|
||||
catalogs: [
|
||||
{
|
||||
path: '<rootDir>/src/locales/{locale}/messages',
|
||||
include: ['src'],
|
||||
},
|
||||
],
|
||||
format: formatter({ lineNumbers: false }),
|
||||
});
|
||||
+20
-3
@@ -6,19 +6,33 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:host": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && vite build && pnpm run build:bridge",
|
||||
"build": "pnpm run i18n:compile && tsc --noEmit && vite build && pnpm run build:bridge",
|
||||
"build:bridge": "node ./bridge/build.mjs",
|
||||
"lint": "eslint src protocol bridge/src --max-warnings=0",
|
||||
"typecheck:web": "tsc --noEmit",
|
||||
"typecheck:bridge": "tsc -p bridge/tsconfig.json --noEmit",
|
||||
"typecheck": "pnpm typecheck:web && pnpm typecheck:bridge",
|
||||
"test": "pnpm build:bridge && vitest run",
|
||||
"i18n:extract": "lingui extract",
|
||||
"i18n:compile": "lingui compile",
|
||||
"preview": "vite preview",
|
||||
"preview:host": "vite preview --host 0.0.0.0",
|
||||
"bridge": "node ./bridge/server.mjs"
|
||||
"bridge:demo": "node ./bridge/dev-server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lingui/core": "^6.3.0",
|
||||
"@lingui/react": "^6.3.0",
|
||||
"preact": "^10.27.2",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@lingui/babel-plugin-lingui-macro": "^6.3.0",
|
||||
"@lingui/cli": "^6.3.0",
|
||||
"@lingui/format-po": "^6.3.0",
|
||||
"@lingui/vite-plugin": "^6.3.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@testing-library/preact": "^3.2.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -28,10 +42,13 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.2"
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite": "^7.3.2",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2261
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
import contract from './web-contract.json';
|
||||
|
||||
export const WEB_CONTRACT = contract;
|
||||
export const PROTOCOL_VERSION = contract.protocolVersion;
|
||||
@@ -1,5 +1,4 @@
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
const NUMBER_GROUP_SEPARATOR_PATTERN = /[,\s]/g;
|
||||
export { PROTOCOL_VERSION } from './contract';
|
||||
|
||||
// String values mirror the wire protocol; do not rename the right-hand side.
|
||||
export enum ECheatType {
|
||||
@@ -109,6 +108,7 @@ export interface TrainerSummary {
|
||||
export interface TrainerMetaPayload {
|
||||
session: {
|
||||
instanceId: string;
|
||||
accessToken?: string;
|
||||
};
|
||||
trainer: TrainerSummary;
|
||||
schema: {
|
||||
@@ -237,50 +237,3 @@ export type IncomingMessage =
|
||||
|
||||
export type OutgoingMessage = HelloMessage | SetValueMessage | RemoteCommandMessage;
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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;
|
||||
}
|
||||
|
||||
export function resolveOption(option: CheatOptionLike): CheatOption {
|
||||
if (typeof option === 'string' || typeof option === 'number') {
|
||||
return { label: String(option), value: option };
|
||||
}
|
||||
|
||||
return {
|
||||
label: option.label ?? String(option.value),
|
||||
value: option.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeIncomingValue(cheat: CheatSchema, value: unknown): unknown {
|
||||
if (cheat.type === ECheatType.Toggle) {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeOutgoingValue(cheat: CheatSchema, value: unknown): unknown {
|
||||
if (cheat.type === ECheatType.Toggle) {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
if (cheat.type !== ECheatType.Slider && cheat.type !== ECheatType.Number) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Number(trimmedValue.replace(NUMBER_GROUP_SEPARATOR_PATTERN, ''));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PROTOCOL_VERSION } from './contract';
|
||||
import { isIncomingMessage, isOutgoingMessage } from './validation';
|
||||
|
||||
describe('web protocol validation', () => {
|
||||
it('rejects messages with another envelope version', () => {
|
||||
expect(isIncomingMessage({
|
||||
type: 'error',
|
||||
version: PROTOCOL_VERSION + 1,
|
||||
requestId: null,
|
||||
payload: { code: 'bad', message: 'bad' },
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects incomplete required payloads', () => {
|
||||
expect(isIncomingMessage({
|
||||
type: 'hello_ack',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: null,
|
||||
payload: { accepted: true },
|
||||
})).toBe(false);
|
||||
expect(isOutgoingMessage({
|
||||
type: 'set_value',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: 'set',
|
||||
payload: { target: 'speed', value: 1 },
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { PROTOCOL_VERSION } from './contract';
|
||||
import type { IncomingMessage, OutgoingMessage } from './messages';
|
||||
|
||||
const INCOMING_TYPES = new Set<IncomingMessage['type']>([
|
||||
'hello_ack',
|
||||
'trainer_meta',
|
||||
'trainer_values',
|
||||
'game_status',
|
||||
'installed_apps',
|
||||
'value_changed',
|
||||
'trainer_changed',
|
||||
'set_value_result',
|
||||
'remote_command_result',
|
||||
'error',
|
||||
]);
|
||||
|
||||
const OUTGOING_TYPES = new Set<OutgoingMessage['type']>(['hello', 'set_value', 'remote_command']);
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export function isIncomingMessage(value: unknown): value is IncomingMessage {
|
||||
if (!isEnvelope(value) || !INCOMING_TYPES.has(value.type as IncomingMessage['type'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = value.payload;
|
||||
switch (value.type) {
|
||||
case 'hello_ack':
|
||||
return hasString(payload, 'sessionId') && hasBoolean(payload, 'accepted') && hasString(payload, 'serverVersion')
|
||||
&& hasNumber(payload, 'protocolVersion');
|
||||
case 'trainer_meta':
|
||||
return isRecord(payload.session) && hasString(payload.session, 'instanceId')
|
||||
&& isRecord(payload.trainer) && hasString(payload.trainer, 'trainerId')
|
||||
&& isRecord(payload.schema) && Array.isArray(payload.schema.categories) && Array.isArray(payload.schema.cheats);
|
||||
case 'trainer_values':
|
||||
return hasString(payload, 'trainerId') && isRecord(payload.values);
|
||||
case 'installed_apps':
|
||||
return hasString(payload, 'instanceId') && hasString(payload, 'updatedAt') && Array.isArray(payload.apps);
|
||||
case 'game_status':
|
||||
return hasString(payload, 'instanceId') && hasString(payload, 'updatedAt')
|
||||
&& isRecord(payload.session) && isRecord(payload.trainer);
|
||||
case 'value_changed':
|
||||
return hasString(payload, 'trainerId') && hasString(payload, 'target') && 'value' in payload;
|
||||
case 'trainer_changed':
|
||||
return hasString(payload, 'trainerId');
|
||||
case 'set_value_result':
|
||||
return hasBoolean(payload, 'ok') && hasString(payload, 'trainerId') && hasString(payload, 'target');
|
||||
case 'remote_command_result':
|
||||
return hasBoolean(payload, 'ok') && (payload.action === 'launch' || payload.action === 'stop');
|
||||
case 'error':
|
||||
return hasString(payload, 'code') && hasString(payload, 'message');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOutgoingMessage(value: unknown): value is OutgoingMessage {
|
||||
if (!isEnvelope(value) || !OUTGOING_TYPES.has(value.type as OutgoingMessage['type'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = value.payload;
|
||||
if (value.type === 'hello') {
|
||||
return payload.client === 'mobile-web' && hasString(payload, 'clientVersion') && isRecord(payload.capabilities);
|
||||
}
|
||||
if (value.type === 'set_value') {
|
||||
return hasString(payload, 'trainerId') && hasString(payload, 'target') && 'value' in payload;
|
||||
}
|
||||
return (payload.action === 'launch' || payload.action === 'stop');
|
||||
}
|
||||
|
||||
function isEnvelope(value: unknown): value is Record<string, unknown> & {
|
||||
type: string;
|
||||
version: number;
|
||||
requestId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
} {
|
||||
return isRecord(value)
|
||||
&& typeof value.type === 'string'
|
||||
&& value.version === PROTOCOL_VERSION
|
||||
&& (value.requestId === null || typeof value.requestId === 'string')
|
||||
&& isRecord(value.payload);
|
||||
}
|
||||
|
||||
function hasString(value: Record<string, unknown>, key: string): boolean {
|
||||
return typeof value[key] === 'string';
|
||||
}
|
||||
|
||||
function hasNumber(value: Record<string, unknown>, key: string): boolean {
|
||||
return typeof value[key] === 'number';
|
||||
}
|
||||
|
||||
function hasBoolean(value: Record<string, unknown>, key: string): boolean {
|
||||
return typeof value[key] === 'boolean';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"clientVersion": "0.2.0",
|
||||
"serverVersion": "0.2.0-wand",
|
||||
"defaultRemoteHost": "0.0.0.0",
|
||||
"defaultRemotePort": 3223,
|
||||
"portScanRange": 30,
|
||||
"basePath": "/remote/",
|
||||
"assetsPath": "/remote/assets/",
|
||||
"webSocketPath": "/remote/ws",
|
||||
"healthPath": "/remote/api/health",
|
||||
"installedAppsPath": "/remote/api/installed-apps"
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type UIEvent } from 'react';
|
||||
|
||||
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 } 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 [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 stateRef = useRef(state);
|
||||
const handleConnectRef = useRef<() => void>(() => {});
|
||||
const pinnedStorageKeyRef = useRef<string | null>('');
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedGameIds(loadPinnedGameIds());
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
handleConnectRef.current = handleConnect;
|
||||
pinnedStorageKeyRef.current = pinnedStorageKey;
|
||||
});
|
||||
|
||||
const activeTrainer = state.trainerMeta?.trainer ?? null;
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
setPresets(loadPresets(presetStorageKey));
|
||||
}, [presetStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.wsUrl.trim()) {
|
||||
handleConnect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && !clientRef.current?.isOpen()) {
|
||||
handleConnectRef.current();
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}, []);
|
||||
|
||||
function handleConnect(): void {
|
||||
clientRef.current?.disconnect();
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const wsUrl = state.wsUrl.trim();
|
||||
if (!wsUrl) {
|
||||
dispatch({ type: 'error', message: 'Enter a WebSocket URL first.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextClient = new PanelSocketClient(wsUrl, {
|
||||
onConnecting: () => dispatch({ type: 'connecting' }),
|
||||
onOpen: () => dispatch({ type: 'connected' }),
|
||||
onMessage: (message) => handleProtocolMessage(dispatch, message, stateRef.current.trainerMeta),
|
||||
onClose: () => {
|
||||
dispatch({ type: 'error', message: 'The WebSocket connection closed.' });
|
||||
if (document.visibilityState === 'visible') {
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
if (document.visibilityState === 'visible' && stateRef.current.wsUrl.trim()) {
|
||||
handleConnectRef.current();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
onError: (message) => dispatch({ type: 'error', message }),
|
||||
});
|
||||
|
||||
clientRef.current = nextClient;
|
||||
nextClient.connect();
|
||||
}
|
||||
|
||||
function handleDisconnect(): void {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
dispatch({ type: 'disconnected' });
|
||||
}
|
||||
|
||||
const handleCheatChange = useCallback((cheat: CheatSchema, nextValue: unknown): void => {
|
||||
const { connectionStatus, trainerMeta } = stateRef.current;
|
||||
const normalizedValue = normalizeOutgoingValue(cheat, nextValue);
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: true });
|
||||
dispatch({ type: 'valueChanged', target: cheat.target, value: normalizedValue });
|
||||
|
||||
if (connectionStatus !== EConnectionStatus.Connected || !trainerMeta || !clientRef.current) {
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const sent = clientRef.current.setValue(trainerMeta.trainer.trainerId, cheat.target, normalizedValue, cheat.uuid);
|
||||
if (!sent) {
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: false });
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleCheatPin = useCallback((cheat: CheatSchema): void => {
|
||||
const { pinnedTargets } = stateRef.current;
|
||||
const next = { ...pinnedTargets };
|
||||
if (next[cheat.target]) {
|
||||
delete next[cheat.target];
|
||||
} else {
|
||||
next[cheat.target] = true;
|
||||
}
|
||||
|
||||
dispatch({ type: 'togglePinnedTarget', target: cheat.target });
|
||||
savePinnedTargets(pinnedStorageKeyRef.current, next);
|
||||
}, []);
|
||||
|
||||
function handleToggleGamePin(game: LibraryGame): void {
|
||||
const next = togglePinnedGame(game, pinnedGameIds);
|
||||
setPinnedGameIds(next);
|
||||
savePinnedGameIds(next);
|
||||
}
|
||||
|
||||
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 (!client?.isOpen()) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
|
||||
return;
|
||||
}
|
||||
|
||||
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 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%)]" />
|
||||
<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-27.5" 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
|
||||
forceOpen={Boolean(cheatQuery)}
|
||||
group={filteredPinnedGroup}
|
||||
values={state.values}
|
||||
pendingTargets={state.pendingTargets}
|
||||
pinnedTargets={state.pinnedTargets}
|
||||
disabled={controlsDisabled}
|
||||
onCheatChange={handleCheatChange}
|
||||
onTogglePin={handleToggleCheatPin}
|
||||
/>
|
||||
) : null}
|
||||
{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={handleToggleCheatPin}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { FloatingDock } from '@/app/ui/FloatingDock';
|
||||
import { SessionPlaceholder } from '@/app/ui/SessionPlaceholder';
|
||||
import { SettingsDrawer } from '@/app/ui/SettingsDrawer';
|
||||
import { TopBar } from '@/app/ui/TopBar';
|
||||
import { LibraryDrawer } from '@/library/ui/LibraryDrawer';
|
||||
import { Drawer } from '@/shared/ui/Drawer';
|
||||
import { SearchInput } from '@/shared/ui/SearchInput';
|
||||
import { CategorySection } from '@/trainer/ui/CategorySection';
|
||||
import { QuickActions } from '@/trainer/ui/QuickActions';
|
||||
import { TrainerHeader } from '@/trainer/ui/TrainerHeader';
|
||||
|
||||
import { useRemotePanel } from './use-remote-panel';
|
||||
|
||||
export const App = () => {
|
||||
const { _ } = useLingui();
|
||||
const panel = useRemotePanel();
|
||||
const { session, trainer, library, shell } = panel;
|
||||
|
||||
return (
|
||||
<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%)]" />
|
||||
<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={session.status} currentGame={library.currentGame} runningTrainer={trainer.activeTrainer} onOpenSettings={shell.openSettings} />
|
||||
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain px-3.5 pb-27.5" onScroll={shell.onScroll}>
|
||||
{!session.connected || !trainer.activeTrainer ? (
|
||||
<SessionPlaceholder
|
||||
connected={session.connected}
|
||||
activeTrainer={trainer.activeTrainer}
|
||||
onOpenLibrary={shell.openLibrary}
|
||||
onOpenSettings={shell.openSettings}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TrainerHeader trainer={trainer.activeTrainer} game={library.currentGame} isPinned={Boolean(library.currentGame && library.pinnedGameIds[library.currentGame.id])} onPin={() => library.currentGame && library.togglePin(library.currentGame)} />
|
||||
<QuickActions presets={trainer.presets} onAddPreset={trainer.addPreset} onApplyPreset={trainer.applyPreset} onDeletePreset={trainer.deletePreset} onPanic={trainer.panic} />
|
||||
<div className="sticky top-0 z-10 -mx-3.5 mb-2.5 px-3.5 py-0.5">
|
||||
<SearchInput value={trainer.query} placeholder={_(msg`Search mods`)} onChange={trainer.setQuery} />
|
||||
</div>
|
||||
{trainer.filteredPinnedGroup ? (
|
||||
<CategorySection
|
||||
forceOpen={Boolean(trainer.query)}
|
||||
group={trainer.filteredPinnedGroup}
|
||||
values={session.values}
|
||||
pendingTargets={session.pendingTargets}
|
||||
pinnedTargets={trainer.pinnedTargets}
|
||||
disabled={trainer.controlsDisabled}
|
||||
onCheatChange={trainer.changeCheat}
|
||||
onTogglePin={trainer.togglePin}
|
||||
/>
|
||||
) : null}
|
||||
{trainer.filteredGroups.map((group, index) => (
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
forceOpen={Boolean(trainer.query)}
|
||||
group={group}
|
||||
openByDefault={index < 2}
|
||||
values={session.values}
|
||||
pendingTargets={session.pendingTargets}
|
||||
pinnedTargets={trainer.pinnedTargets}
|
||||
disabled={trainer.controlsDisabled}
|
||||
onCheatChange={trainer.changeCheat}
|
||||
onTogglePin={trainer.togglePin}
|
||||
/>
|
||||
))}
|
||||
{trainer.query && trainer.totalVisibleCheats === 0 ? <p className="px-8 py-8 text-center text-[13px] text-(--deck-fg-4)"><Trans>No mods match "{trainer.query}"</Trans></p> : null}
|
||||
<div className="mt-4 text-center font-mono text-[10px] uppercase tracking-[0.08em] text-(--deck-fg-4)">
|
||||
{trainer.query
|
||||
? _(msg`${trainer.totalVisibleCheats} matches`)
|
||||
: _(msg`END · ${trainer.totalCheats} MODS`)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingDock
|
||||
status={session.status}
|
||||
runningGameTitle={library.currentGame?.title ?? null}
|
||||
hidden={shell.dockHidden}
|
||||
leftHasBadge={!session.connected}
|
||||
rightHasBadge={session.connected && !library.currentGame}
|
||||
onOpenSettings={shell.openSettings}
|
||||
onOpenLibrary={shell.openLibrary}
|
||||
/>
|
||||
|
||||
<Drawer open={shell.leftOpen} side="left" onClose={shell.closeSettings}>
|
||||
<SettingsDrawer
|
||||
status={session.status}
|
||||
wsUrl={session.wsUrl}
|
||||
currentGame={library.currentGame}
|
||||
currentTrainer={trainer.activeTrainer}
|
||||
lastError={session.lastError}
|
||||
onClose={shell.closeSettings}
|
||||
onConnect={session.connect}
|
||||
onDisconnect={session.disconnect}
|
||||
onWsUrlChange={session.setWsUrl}
|
||||
/>
|
||||
</Drawer>
|
||||
<Drawer open={shell.rightOpen} side="right" onClose={shell.closeLibrary}>
|
||||
<LibraryDrawer
|
||||
games={library.games}
|
||||
query={library.query}
|
||||
canLaunch={session.socketReady}
|
||||
onClose={shell.closeLibrary}
|
||||
onPin={library.togglePin}
|
||||
onPlay={library.playGame}
|
||||
onStop={library.stopPlaying}
|
||||
onQueryChange={library.setQuery}
|
||||
/>
|
||||
</Drawer>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
type CatalogModule = {
|
||||
default?: { messages: Record<string, string> };
|
||||
messages?: Record<string, string>;
|
||||
};
|
||||
|
||||
const catalogs = import.meta.glob<CatalogModule>('../locales/*/messages.js');
|
||||
|
||||
export async function activateLocale(locale: string): Promise<void> {
|
||||
const loadCatalog = catalogs[`../locales/${locale}/messages.js`];
|
||||
if (!loadCatalog) {
|
||||
throw new Error(`Locale catalog not found: ${locale}`);
|
||||
}
|
||||
|
||||
const catalog = await loadCatalog();
|
||||
const messages = catalog.messages ?? catalog.default?.messages;
|
||||
if (!messages) {
|
||||
throw new Error(`Locale catalog is invalid: ${locale}`);
|
||||
}
|
||||
|
||||
i18n.load(locale, messages);
|
||||
i18n.activate(locale);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
|
||||
import { applySavedAccentColor } from '@/appearance/appearance-storage';
|
||||
|
||||
import { App } from './app';
|
||||
import { activateLocale, DEFAULT_LOCALE } from './i18n';
|
||||
import '../index.css';
|
||||
|
||||
const root = document.getElementById('root') ?? document.getElementById('app');
|
||||
|
||||
if (!root) {
|
||||
throw new Error('App root not found.');
|
||||
}
|
||||
|
||||
applySavedAccentColor();
|
||||
|
||||
activateLocale(DEFAULT_LOCALE).then(() => {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
+10
-6
@@ -1,7 +1,10 @@
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EConnectionStatus } from '../state';
|
||||
import { Icon, type IconName } from '@/shared/ui/Icon';
|
||||
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
|
||||
|
||||
type FloatingDockProps = {
|
||||
status: EConnectionStatus;
|
||||
@@ -22,18 +25,19 @@ export const FloatingDock = ({
|
||||
onOpenSettings,
|
||||
onOpenLibrary,
|
||||
}: FloatingDockProps) => {
|
||||
const { _ } = useLingui();
|
||||
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} />
|
||||
<DockButton badge={leftHasBadge} icon="settings" label={_(msg`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'}
|
||||
{runningGameTitle || _(msg`No session`)}
|
||||
</span>
|
||||
</div>
|
||||
<DockButton badge={rightHasBadge} icon="list" label="Library" onClick={onOpenLibrary} />
|
||||
<DockButton badge={rightHasBadge} icon="list" label={_(msg`Library`)} onClick={onOpenLibrary} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
import { Icon, type IconName } from '@/shared/ui/Icon';
|
||||
|
||||
type PlaceholderStateProps = {
|
||||
icon: IconName;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { TrainerSummary } from '../../../protocol/messages';
|
||||
import { PlaceholderState } from './PlaceholderState';
|
||||
|
||||
type SessionPlaceholderProps = {
|
||||
connected: boolean;
|
||||
activeTrainer: TrainerSummary | null;
|
||||
onOpenLibrary: () => void;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
export const SessionPlaceholder = ({
|
||||
connected,
|
||||
activeTrainer,
|
||||
onOpenLibrary,
|
||||
onOpenSettings,
|
||||
}: SessionPlaceholderProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<PlaceholderState
|
||||
icon="plug"
|
||||
title={_(msg`Bridge offline`)}
|
||||
sub={_(msg`Open Settings to point Wand at your trainer bridge over WebSocket.`)}
|
||||
action={_(msg`Open Settings`)}
|
||||
onAction={onOpenSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeTrainer) {
|
||||
return (
|
||||
<PlaceholderState
|
||||
icon="gamepad-variant-outline"
|
||||
title={_(msg`Select a game`)}
|
||||
sub={_(msg`No game is running yet. Open the library and launch one to start tweaking.`)}
|
||||
action={_(msg`Browse library`)}
|
||||
onAction={onOpenLibrary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+51
-40
@@ -1,23 +1,27 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
import { DEFAULT_ACCENT_COLOR, loadAccentColor, setAccentColor } from '@/appearance/appearance-storage';
|
||||
import type { LibraryGame } from '@/library/model/games';
|
||||
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
|
||||
import { WEB_CONTRACT } from '../../../protocol/contract';
|
||||
import type { TrainerSummary } from '../../../protocol/messages';
|
||||
|
||||
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]' },
|
||||
const ACCENT_OPTIONS: { value: string; label: MessageDescriptor; swatchClass: string }[] = [
|
||||
{ value: '#3B82F6', label: msg`Cobalt`, swatchClass: 'bg-[#3B82F6]' },
|
||||
{ value: DEFAULT_ACCENT_COLOR, label: msg`Cyan`, swatchClass: 'bg-[#00FFD5]' },
|
||||
{ value: '#FF2E63', label: msg`Crimson`, swatchClass: 'bg-[#FF2E63]' },
|
||||
{ value: '#A78BFA', label: msg`Violet`, swatchClass: 'bg-[#A78BFA]' },
|
||||
{ value: '#7CFF5B', label: msg`Lime`, swatchClass: 'bg-[#7CFF5B]' },
|
||||
{ value: '#FFB12E', label: msg`Amber`, swatchClass: 'bg-[#FFB12E]' },
|
||||
{ value: '#ee00ff', label: msg`Magenta`, swatchClass: 'bg-[#ee00ff]' },
|
||||
];
|
||||
|
||||
type SettingsDrawerProps = {
|
||||
@@ -43,14 +47,20 @@ export const SettingsDrawer = ({
|
||||
onDisconnect,
|
||||
onWsUrlChange,
|
||||
}: SettingsDrawerProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
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>
|
||||
<h2 className="text-lg font-bold text-(--deck-fg)">
|
||||
<Trans>Settings</Trans>
|
||||
</h2>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">
|
||||
<Trans>wand remote · port {WEB_CONTRACT.defaultRemotePort}</Trans>
|
||||
</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}>
|
||||
<button type="button" aria-label={_(msg`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>
|
||||
@@ -58,10 +68,10 @@ export const SettingsDrawer = ({
|
||||
<BridgeControl status={status} wsUrl={wsUrl} onConnect={onConnect} onDisconnect={onDisconnect} onWsUrlChange={onWsUrlChange} />
|
||||
{lastError ? <ErrorPanel message={lastError} /> : null}
|
||||
|
||||
<SectionHeader title="Session" />
|
||||
<SectionHeader title={_(msg`Session`)} />
|
||||
<SessionPanel currentGame={currentGame} currentTrainer={currentTrainer} />
|
||||
|
||||
<SectionHeader title="Accent Color" />
|
||||
<SectionHeader title={_(msg`Accent Color`)} />
|
||||
<AccentPicker />
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,20 +87,24 @@ type BridgeControlProps = {
|
||||
};
|
||||
|
||||
const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }: BridgeControlProps) => {
|
||||
const { _ } = useLingui();
|
||||
const live = status === EConnectionStatus.Connected;
|
||||
const connecting = status === EConnectionStatus.Connecting;
|
||||
const connecting = status === EConnectionStatus.Connecting || status === EConnectionStatus.Reconnecting;
|
||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onWsUrlChange(event.currentTarget.value);
|
||||
const buttonLabel = connecting ? '...' : _(live ? msg`STOP` : msg`GO`);
|
||||
|
||||
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>
|
||||
<h3 className="font-mono text-[10px] font-bold uppercase tracking-[0.18em] text-(--deck-fg-4)">
|
||||
<Trans>Bridge</Trans>
|
||||
</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`}
|
||||
placeholder={`ws://127.0.0.1:${WEB_CONTRACT.defaultRemotePort}${WEB_CONTRACT.webSocketPath}`}
|
||||
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}
|
||||
@@ -101,7 +115,7 @@ const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }
|
||||
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)}
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -119,7 +133,11 @@ const ErrorPanel = ({ message }: { message: string }) => {
|
||||
|
||||
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>;
|
||||
return (
|
||||
<div className="remote-glass-control rounded-[10px] border p-3 text-[12px] text-(--deck-fg-3)">
|
||||
<Trans>No active game session.</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const subtitleBase = currentTrainer?.displayName ?? currentGame.platform;
|
||||
@@ -130,7 +148,9 @@ const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGam
|
||||
<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>
|
||||
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.12em] text-(--deck-accent)">
|
||||
<Trans>Active Session</Trans>
|
||||
</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)">
|
||||
@@ -141,6 +161,7 @@ const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGam
|
||||
};
|
||||
|
||||
const AccentPicker = () => {
|
||||
const { _ } = useLingui();
|
||||
const [current, setCurrent] = useState(loadAccentColor);
|
||||
|
||||
const applyAccent = (value: string) => {
|
||||
@@ -155,13 +176,15 @@ const AccentPicker = () => {
|
||||
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}
|
||||
{_(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="flex-1 font-mono text-[11px] font-semibold uppercase tracking-[0.08em] text-(--deck-fg-3)">
|
||||
<Trans>Custom</Trans>
|
||||
</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>
|
||||
@@ -177,15 +200,3 @@ const SectionHeader = ({ title }: { title: string }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getBridgeButtonLabel(status: EConnectionStatus): string {
|
||||
if (status === EConnectionStatus.Connected) {
|
||||
return 'STOP';
|
||||
}
|
||||
|
||||
if (status === EConnectionStatus.Connecting) {
|
||||
return '...';
|
||||
}
|
||||
|
||||
return 'GO';
|
||||
}
|
||||
+19
-10
@@ -1,28 +1,37 @@
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EConnectionStatus } from '../state';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
const STATUS_LABELS: Record<EConnectionStatus, string> = {
|
||||
[EConnectionStatus.Connected]: 'LIVE',
|
||||
[EConnectionStatus.Connecting]: 'LINKING',
|
||||
[EConnectionStatus.Error]: 'OFFLINE',
|
||||
[EConnectionStatus.Idle]: 'OFFLINE',
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
|
||||
|
||||
const STATUS_LABELS: Record<EConnectionStatus, MessageDescriptor> = {
|
||||
[EConnectionStatus.Connected]: msg`LIVE`,
|
||||
[EConnectionStatus.Connecting]: msg`LINKING`,
|
||||
[EConnectionStatus.Reconnecting]: msg`LINKING`,
|
||||
[EConnectionStatus.Error]: msg`OFFLINE`,
|
||||
[EConnectionStatus.Idle]: msg`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.Reconnecting]: '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;
|
||||
const { _ } = useLingui();
|
||||
const live = status === EConnectionStatus.Connected
|
||||
|| status === EConnectionStatus.Connecting
|
||||
|| status === EConnectionStatus.Reconnecting;
|
||||
|
||||
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_LABELS[status])}
|
||||
{status === EConnectionStatus.Error ? <Icon className="size-3" name="alert" /> : null}
|
||||
</div>
|
||||
);
|
||||
+12
-6
@@ -1,8 +1,12 @@
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import type { LibraryGame } from '../game-library';
|
||||
import type { TrainerSummary } from '../protocol';
|
||||
import type { EConnectionStatus } from '../state';
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
|
||||
import type { LibraryGame } from '@/library/model/games';
|
||||
import type { TrainerSummary } from '../../../protocol/messages';
|
||||
import type { EConnectionStatus } from '@/remote-session/remote-session.reducer';
|
||||
import { StatusPill } from './StatusPill';
|
||||
|
||||
type TopBarProps = {
|
||||
@@ -13,17 +17,19 @@ type TopBarProps = {
|
||||
};
|
||||
|
||||
export const TopBar = ({ status, currentGame, runningTrainer, onOpenSettings }: TopBarProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
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}>
|
||||
<button type="button" aria-label={_(msg`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'}
|
||||
{currentGame ? currentGame.title : <Trans>Idle · no game</Trans>}
|
||||
</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)">
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
|
||||
import type { TrainerSummary } from '../../../protocol/messages';
|
||||
import { TrainerHeader } from '../../trainer/ui/TrainerHeader';
|
||||
import { SessionPlaceholder } from './SessionPlaceholder';
|
||||
|
||||
i18n.load('en', {});
|
||||
i18n.activate('en');
|
||||
|
||||
const renderWithI18n = (ui: ReactNode) => render(<I18nProvider i18n={i18n}>{ui}</I18nProvider>);
|
||||
|
||||
const trainer: TrainerSummary = {
|
||||
trainerId: 'trainer',
|
||||
gameId: 'game',
|
||||
displayName: 'Test Trainer',
|
||||
trainerLoading: false,
|
||||
gameInstalled: true,
|
||||
needsCompatibilityWarning: false,
|
||||
isTimeLimitExpired: false,
|
||||
};
|
||||
|
||||
describe('session state components', () => {
|
||||
it('renders the offline intent', () => {
|
||||
const openSettings = vi.fn();
|
||||
renderWithI18n(<SessionPlaceholder connected={false} activeTrainer={null} onOpenLibrary={() => undefined} onOpenSettings={openSettings} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Settings' }));
|
||||
expect(screen.getByText('Bridge offline')).toBeTruthy();
|
||||
expect(openSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders the no-trainer intent', () => {
|
||||
renderWithI18n(<SessionPlaceholder connected activeTrainer={null} onOpenLibrary={() => undefined} onOpenSettings={() => undefined} />);
|
||||
expect(screen.getByText('Select a game')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders an active trainer header', () => {
|
||||
renderWithI18n(<TrainerHeader trainer={trainer} game={null} isPinned={false} onPin={() => undefined} />);
|
||||
expect(screen.getByText('Test Trainer')).toBeTruthy();
|
||||
expect(screen.getByText('Trainer Active')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useRef, useState, type UIEvent } from 'react';
|
||||
|
||||
const SCROLL_HIDE_THRESHOLD_PX = 60;
|
||||
const SCROLL_REVEAL_DEAD_ZONE_PX = 4;
|
||||
|
||||
export function useDockAutoHide() {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const lastScrollRef = useRef(0);
|
||||
|
||||
const onScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||
const y = event.currentTarget.scrollTop;
|
||||
if (y > lastScrollRef.current && y > SCROLL_HIDE_THRESHOLD_PX) {
|
||||
setHidden(true);
|
||||
} else if (y < lastScrollRef.current - SCROLL_REVEAL_DEAD_ZONE_PX) {
|
||||
setHidden(false);
|
||||
}
|
||||
|
||||
lastScrollRef.current = y;
|
||||
}, []);
|
||||
|
||||
return { hidden, onScroll };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { ECheatType } from '../../protocol/messages';
|
||||
import { buildLibraryGames, getCurrentGame, type LibraryGame } from '../library/model/games';
|
||||
import { useGamePins } from '../library/pinned-games/use-game-pins';
|
||||
import { useRemoteSession } from '../remote-session/use-remote-session';
|
||||
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from '../trainer/model/categories';
|
||||
import { getPinnedStorageKey } from '../trainer/pinned-cheats/pinned-cheat-storage';
|
||||
import { usePinnedCheats } from '../trainer/pinned-cheats/use-pinned-cheats';
|
||||
import { getPresetStorageKey, type RemotePreset } from '../trainer/presets/preset-storage';
|
||||
import { usePresets } from '../trainer/presets/use-presets';
|
||||
import { useDockAutoHide } from './use-dock-auto-hide';
|
||||
|
||||
export function useRemotePanel() {
|
||||
const session = useRemoteSession();
|
||||
const [cheatQuery, setCheatQuery] = useState('');
|
||||
const [gameQuery, setGameQuery] = useState('');
|
||||
const [leftOpen, setLeftOpen] = useState(false);
|
||||
const [rightOpen, setRightOpen] = useState(false);
|
||||
const dock = useDockAutoHide();
|
||||
|
||||
const activeTrainer = session.state.trainerMeta?.trainer ?? null;
|
||||
const { pinnedGameIds, togglePin: toggleGamePin } = useGamePins();
|
||||
const libraryGames = useMemo(
|
||||
() => buildLibraryGames(session.state.installedApps, session.state.gameStatus, activeTrainer, pinnedGameIds),
|
||||
[activeTrainer, pinnedGameIds, session.state.gameStatus, session.state.installedApps],
|
||||
);
|
||||
const currentGame = useMemo(() => getCurrentGame(libraryGames), [libraryGames]);
|
||||
|
||||
const pinnedStorageKey = useMemo(() => getPinnedStorageKey(activeTrainer), [activeTrainer]);
|
||||
const { pinnedTargets, toggle: togglePinnedCheat } = usePinnedCheats({ pinnedStorageKey });
|
||||
const groups = useMemo(() => groupCheatsByCategory(session.state.trainerMeta), [session.state.trainerMeta]);
|
||||
const pinnedGroup = useMemo(
|
||||
() => buildPinnedGroup(session.state.trainerMeta, pinnedTargets),
|
||||
[pinnedTargets, session.state.trainerMeta],
|
||||
);
|
||||
const filteredGroups = useMemo(() => filterGroups(groups, cheatQuery), [cheatQuery, groups]);
|
||||
const filteredPinnedGroup = useMemo(
|
||||
() => (pinnedGroup ? filterGroups([pinnedGroup], cheatQuery)[0] ?? null : null),
|
||||
[cheatQuery, pinnedGroup],
|
||||
);
|
||||
|
||||
const presetStorageKey = useMemo(() => getPresetStorageKey(activeTrainer), [activeTrainer]);
|
||||
const presets = usePresets({
|
||||
presetStorageKey,
|
||||
trainerMeta: session.state.trainerMeta,
|
||||
values: session.state.values,
|
||||
onError: session.reportError,
|
||||
});
|
||||
|
||||
const panic = useCallback(() => {
|
||||
const trainerMeta = session.state.trainerMeta;
|
||||
if (!trainerMeta) return;
|
||||
for (const cheat of trainerMeta.schema.cheats) {
|
||||
if (cheat.type === ECheatType.Toggle && Boolean(session.state.values[cheat.target])) {
|
||||
session.changeCheat(cheat, false);
|
||||
}
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const applyPreset = useCallback((preset: RemotePreset) => {
|
||||
const trainerMeta = session.state.trainerMeta;
|
||||
if (!trainerMeta) return;
|
||||
for (const cheat of trainerMeta.schema.cheats) {
|
||||
if (cheat.target in preset.values) {
|
||||
session.changeCheat(cheat, preset.values[cheat.target]);
|
||||
}
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const playGame = useCallback((game: LibraryGame) => {
|
||||
if (session.launchGame(game.app)) {
|
||||
setRightOpen(false);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const totalVisibleCheats = filteredGroups.reduce(
|
||||
(count, group) => count + group.cheats.length,
|
||||
filteredPinnedGroup?.cheats.length ?? 0,
|
||||
);
|
||||
|
||||
return {
|
||||
session: {
|
||||
status: session.state.connectionStatus,
|
||||
wsUrl: session.state.wsUrl,
|
||||
lastError: session.state.lastError,
|
||||
values: session.state.values,
|
||||
pendingTargets: session.pendingTargets,
|
||||
connected: session.connected,
|
||||
socketReady: session.socketReady,
|
||||
connect: session.connect,
|
||||
disconnect: session.disconnect,
|
||||
setWsUrl: session.setWsUrl,
|
||||
},
|
||||
trainer: {
|
||||
activeTrainer,
|
||||
query: cheatQuery,
|
||||
setQuery: setCheatQuery,
|
||||
filteredGroups,
|
||||
filteredPinnedGroup,
|
||||
pinnedTargets,
|
||||
controlsDisabled: Boolean(activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired),
|
||||
totalVisibleCheats,
|
||||
totalCheats: session.state.trainerMeta?.schema.cheats.length ?? 0,
|
||||
changeCheat: session.changeCheat,
|
||||
togglePin: togglePinnedCheat,
|
||||
panic,
|
||||
presets: presets.presets,
|
||||
addPreset: presets.addPreset,
|
||||
applyPreset,
|
||||
deletePreset: presets.deletePreset,
|
||||
},
|
||||
library: {
|
||||
games: libraryGames,
|
||||
currentGame,
|
||||
pinnedGameIds,
|
||||
query: gameQuery,
|
||||
setQuery: setGameQuery,
|
||||
togglePin: toggleGamePin,
|
||||
playGame,
|
||||
stopPlaying: session.stopPlaying,
|
||||
},
|
||||
shell: {
|
||||
leftOpen,
|
||||
rightOpen,
|
||||
openSettings: () => setLeftOpen(true),
|
||||
closeSettings: () => setLeftOpen(false),
|
||||
openLibrary: () => setRightOpen(true),
|
||||
closeLibrary: () => setRightOpen(false),
|
||||
dockHidden: dock.hidden,
|
||||
onScroll: dock.onScroll,
|
||||
},
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { loadJson, saveJson } from './storage';
|
||||
import { loadJson, saveJson } from '../shared/storage';
|
||||
|
||||
export const DEFAULT_ACCENT_COLOR = '#00ffd5';
|
||||
|
||||
@@ -39,4 +39,4 @@ function normalizeAccentColor(value: unknown): string | null {
|
||||
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export const DEFAULT_REMOTE_PORT = 3223;
|
||||
export const REMOTE_BASE_PATH = '/remote/';
|
||||
export const REMOTE_WS_PATH = '/remote/ws';
|
||||
export const CLIENT_VERSION = '0.2.0';
|
||||
export const WS_QUERY_PARAM = 'ws';
|
||||
|
||||
const DEV_SERVER_PORTS = new Set(['4173', '5173']);
|
||||
|
||||
function protocolForWebSocket(): 'ws' | 'wss' {
|
||||
return window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
}
|
||||
|
||||
function isServedByRemoteBridge(): boolean {
|
||||
return window.location.pathname.startsWith(REMOTE_BASE_PATH) && !DEV_SERVER_PORTS.has(window.location.port);
|
||||
}
|
||||
|
||||
export function readInitialRemoteUrl(): string {
|
||||
if (isServedByRemoteBridge()) {
|
||||
return `${window.location.protocol}//${window.location.host}${REMOTE_BASE_PATH}`;
|
||||
}
|
||||
|
||||
return `http://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_BASE_PATH}`;
|
||||
}
|
||||
|
||||
export function readInitialWebSocketUrl(): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const explicitUrl = params.get(WS_QUERY_PARAM)?.trim();
|
||||
if (explicitUrl) {
|
||||
return explicitUrl;
|
||||
}
|
||||
|
||||
if (isServedByRemoteBridge()) {
|
||||
return `${protocolForWebSocket()}://${window.location.host}${REMOTE_WS_PATH}`;
|
||||
}
|
||||
|
||||
return `ws://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_WS_PATH}`;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { IncomingMessage, TrainerMetaPayload } from './protocol';
|
||||
import { normalizeIncomingValue } from './protocol';
|
||||
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 '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 (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 });
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { CLIENT_VERSION } from './constants';
|
||||
import {
|
||||
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;
|
||||
};
|
||||
|
||||
export class PanelSocketClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly handlers: SocketHandlers,
|
||||
) { }
|
||||
|
||||
connect(pairingToken?: string): void {
|
||||
this.disconnect();
|
||||
this.intentionalDisconnect = false;
|
||||
this.handlers.onConnecting();
|
||||
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.handlers.onOpen();
|
||||
this.send(this.createHelloMessage(pairingToken));
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,166 +0,0 @@
|
||||
import { readInitialRemoteUrl, readInitialWebSocketUrl } from './constants';
|
||||
import type { GameStatusPayload, InstalledAppSummary, InstalledAppsPayload, TrainerMetaPayload } from './protocol';
|
||||
|
||||
export enum EConnectionStatus {
|
||||
Idle = 'idle',
|
||||
Connecting = 'connecting',
|
||||
Connected = 'connected',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type PanelState = {
|
||||
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: '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: EConnectionStatus.Idle,
|
||||
wsUrl: readInitialWebSocketUrl(),
|
||||
remoteUrl: readInitialRemoteUrl(),
|
||||
trainerMeta: null,
|
||||
gameStatus: null,
|
||||
installedApps: [],
|
||||
installedAppsUpdatedAt: 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: 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,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { InstalledAppSummary } from '../../../protocol/messages';
|
||||
import { buildLibraryGames, filterLibraryGames, getCurrentGame } from './games';
|
||||
import { togglePinnedGame } from '../pinned-games/game-pin-storage';
|
||||
|
||||
const apps: InstalledAppSummary[] = [
|
||||
{
|
||||
platform: 'steam',
|
||||
sku: 'one',
|
||||
correlationId: 'steam:one',
|
||||
displayName: 'Alpha Game',
|
||||
gameId: 'game-one',
|
||||
location: 'C:\\Games\\Alpha',
|
||||
alternateLocations: [],
|
||||
},
|
||||
{
|
||||
platform: 'epic',
|
||||
sku: 'two',
|
||||
correlationId: 'epic:two',
|
||||
displayName: 'Beta Game',
|
||||
gameId: 'game-two',
|
||||
location: 'C:\\Games\\Beta',
|
||||
alternateLocations: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('library models', () => {
|
||||
it('projects running and pinned games and filters them', () => {
|
||||
const games = buildLibraryGames(apps, {
|
||||
instanceId: 'status',
|
||||
updatedAt: 'now',
|
||||
session: { state: 'running', event: 'snapshot', gameId: 'game-two' },
|
||||
trainer: { state: 'idle', event: 'snapshot' },
|
||||
}, null, { 'game-one': true });
|
||||
|
||||
expect(getCurrentGame(games)?.id).toBe('game-two');
|
||||
expect(games.find((game) => game.id === 'game-one')?.pinned).toBe(true);
|
||||
expect(filterLibraryGames(games, 'alpha').map((game) => game.id)).toEqual(['game-one']);
|
||||
});
|
||||
|
||||
it('toggles pins without mutating the current set', () => {
|
||||
const game = buildLibraryGames([apps[0]], null, null, {})[0];
|
||||
const current = {};
|
||||
const next = togglePinnedGame(game, current);
|
||||
expect(next).toEqual({ 'game-one': true });
|
||||
expect(current).toEqual({});
|
||||
expect(togglePinnedGame(game, next)).toEqual({});
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { formatHumanLabel } from '@/lib/utils';
|
||||
import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from './protocol';
|
||||
import { formatHumanLabel } from '@/shared/lib/ui';
|
||||
import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from '../../../protocol/messages';
|
||||
|
||||
export type LibraryGame = {
|
||||
id: string;
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { loadStringSet, saveStringSet } from './storage';
|
||||
import type { LibraryGame } from './game-library';
|
||||
import { loadStringSet, saveStringSet } from '../../shared/storage';
|
||||
import type { LibraryGame } from '../model/games';
|
||||
|
||||
const STORAGE_KEY = 'wand-remote.pinned-games.v1';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { LibraryGame } from '../model/games';
|
||||
import { loadPinnedGameIds, savePinnedGameIds, togglePinnedGame } from './game-pin-storage';
|
||||
|
||||
export function useGamePins() {
|
||||
const [pinnedGameIds, setPinnedGameIds] = useState<Record<string, true>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedGameIds(loadPinnedGameIds());
|
||||
}, []);
|
||||
|
||||
const togglePin = useCallback((game: LibraryGame) => {
|
||||
setPinnedGameIds((current) => {
|
||||
const next = togglePinnedGame(game, current);
|
||||
savePinnedGameIds(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { pinnedGameIds, togglePin };
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getGameCoverLabel, type LibraryGame } from '../game-library';
|
||||
import { getGameCoverLabel, type LibraryGame } from '../model/games';
|
||||
|
||||
type GameCoverProps = {
|
||||
game: LibraryGame;
|
||||
+27
-15
@@ -1,11 +1,15 @@
|
||||
import { memo, useMemo, type ReactNode } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
import { Icon, type IconName } from '@/shared/ui/Icon';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../game-library';
|
||||
import { SearchInput } from '@/shared/ui/SearchInput';
|
||||
|
||||
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../model/games';
|
||||
import { GameCover } from './GameCover';
|
||||
import { SearchInput } from './SearchInput';
|
||||
|
||||
type LibraryDrawerProps = {
|
||||
games: LibraryGame[];
|
||||
@@ -19,6 +23,7 @@ type LibraryDrawerProps = {
|
||||
};
|
||||
|
||||
const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const filteredGames = useMemo(() => filterLibraryGames(games, query), [games, query]);
|
||||
const sections = useMemo(() => getLibrarySections(filteredGames), [filteredGames]);
|
||||
|
||||
@@ -26,34 +31,40 @@ const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, on
|
||||
<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>
|
||||
<h2 className="text-lg font-bold text-(--deck-fg)">
|
||||
<Trans>Library</Trans>
|
||||
</h2>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">
|
||||
<Plural value={games.length} one="# game detected" other="# 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}>
|
||||
<button type="button" aria-label={_(msg`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} />
|
||||
<SearchInput value={query} placeholder={_(msg`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">
|
||||
<GameSection accent count={1} icon="dot" title={_(msg`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">
|
||||
<GameSection count={sections.pinned.length} icon="star-filled" title={_(msg`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">
|
||||
<GameSection count={sections.rest.length} title={_(msg`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>
|
||||
<p className="px-8 py-10 text-center text-[13px] text-(--deck-fg-4)">
|
||||
<Trans>No games match "{query}"</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,6 +105,7 @@ type GameRowProps = {
|
||||
};
|
||||
|
||||
const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps) => {
|
||||
const { _ } = useLingui();
|
||||
const hours = formatHours(game.hours);
|
||||
const handlePin = () => onPin(game);
|
||||
const handlePlay = () => onPlay(game);
|
||||
@@ -110,11 +122,11 @@ const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps
|
||||
</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} />
|
||||
<IconButton active={game.pinned} label={game.pinned ? _(msg`Remove favorite`) : _(msg`Favorite game`)} icon={game.pinned ? 'star-filled' : 'star'} onClick={handlePin} />
|
||||
{game.running ? (
|
||||
<IconButton danger label="Stop playing" icon="stop" onClick={onStop} />
|
||||
<IconButton danger label={_(msg`Stop playing`)} icon="stop" onClick={onStop} />
|
||||
) : (
|
||||
<IconButton disabled={!canLaunch || !game.gameId} play label="Play" icon="play" onClick={handlePlay} />
|
||||
<IconButton disabled={!canLaunch || !game.gameId} play label={_(msg`Play`)} icon="play" onClick={handlePlay} />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1 @@
|
||||
/*eslint-disable*/module.exports={messages:JSON.parse("{\"0eyak2\":[\"Close preset modal\"],\"15SPG8\":[\"Stop playing\"],\"1uJlG9\":[\"Accent Color\"],\"29Hx9U\":[\"Stats\"],\"2MiXF-\":[\"No mods match \\\"\",[\"0\"],\"\\\"\"],\"4R8qoo\":[\"No games match \\\"\",[\"query\"],\"\\\"\"],\"5B_EbJ\":[\"OFFLINE\"],\"5Tsr0S\":[\"GO\"],\"5_DJAT\":[\"Delete preset \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"7kV7X8\":[\"Idle · no game\"],\"8Tg_JR\":[\"Custom\"],\"8v0yqM\":[\"Weapons\"],\"9EqSGz\":[\"Lime\"],\"BzfzPK\":[\"Items\"],\"C_i4ng\":[\"Select a game\"],\"Cie1-b\":[\"Teleport\"],\"CqO9qC\":[\"Session\"],\"D1jeqs\":[\"No session\"],\"DB8zMK\":[\"Apply\"],\"DD0Fnh\":[\"LINKING\"],\"DlBKuz\":[\"Browse library\"],\"F37c1s\":[\"Open Settings\"],\"Hz-sxK\":[\"No options\"],\"J24FyN\":[\"Bridge\"],\"Jv26w1\":[\"Close drawer\"],\"LCXVTv\":[\"Cobalt\"],\"LZRTnY\":[\"Game\"],\"NWm6Qw\":[\"All Games\"],\"OkA8xh\":[\"Crafting\"],\"OzDccM\":[\"Bridge offline\"],\"QBOwbf\":[\"Close settings\"],\"T91vKp\":[\"Play\"],\"TvwYwN\":[\"Enemies\"],\"Tz0i8g\":[\"Settings\"],\"U3lTk6\":[\"Add Preset\"],\"Ul-m9L\":[\"Search games\"],\"V3MCts\":[\"Preset name\"],\"V8yTm6\":[\"Clear search\"],\"X9kySA\":[\"Favorites\"],\"Y9Da-w\":[\"Trainer Active\"],\"ZrsGjm\":[\"Inventory\"],\"aPk9_7\":[\"Panic Off\"],\"dEgA5A\":[\"Cancel\"],\"dMVx8s\":[\"New preset\"],\"e73uuf\":[\"Crimson\"],\"eedtPL\":[\"Violet\"],\"exYcTF\":[\"Library\"],\"fFwMZv\":[\"Cyan\"],\"fg3Xvh\":[\"Character\"],\"fpMs2Z\":[\"LIVE\"],\"garODz\":[\"Remove favorite\"],\"i5IWIg\":[\"Physics\"],\"iZWlw6\":[\"Challenge\"],\"isSRI0\":[\"Active Session\"],\"kJs53F\":[[\"0\"],\" matches\"],\"kNiQp6\":[\"Pinned\"],\"llTC8Z\":[\"END · \",[\"0\"],\" MODS\"],\"m16xKo\":[\"Add\"],\"nPRduv\":[\"Close library\"],\"nsI6F9\":[[\"cheatCount\"],\" mods · \",[\"enabledCount\"],\"/\",[\"toggleCount\"],\" on\"],\"pBpfre\":[\"Cheats\"],\"pipqSe\":[\"Now Playing\"],\"q1-iDP\":[\"Open Settings to point Wand at your trainer bridge over WebSocket.\"],\"q1I2y_\":[[\"0\",\"plural\",{\"one\":[\"#\",\" game detected\"],\"other\":[\"#\",\" games detected\"]}]],\"qSNzRg\":[\"wand remote · port \",[\"0\"]],\"qVkch9\":[\"No active game session.\"],\"qanr4_\":[\"Search mods\"],\"s-MGs7\":[\"Resources\"],\"s8OcrP\":[\"Vehicles\"],\"tfDRzk\":[\"Save\"],\"u4hZgR\":[\"Favorite game\"],\"uprOiC\":[\"Amber\"],\"v-jdqd\":[\"World\"],\"vRayGs\":[\"Player\"],\"vmwjgT\":[\"No game is running yet. Open the library and launch one to start tweaking.\"],\"vz5zAR\":[\"Magenta\"],\"wOM6pH\":[\"STOP\"],\"xVi7fK\":[[\"cheatCount\"],\" mods\"]}")};
|
||||
@@ -0,0 +1,341 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2026-06-14 23:42+0300\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: en\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. placeholder {0}: games.length
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "{0, plural, one {# game detected} other {# games detected}}"
|
||||
msgstr "{0, plural, one {# game detected} other {# games detected}}"
|
||||
|
||||
#. placeholder {0}: trainer.totalVisibleCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "{0} matches"
|
||||
msgstr "{0} matches"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods"
|
||||
msgstr "{cheatCount} mods"
|
||||
|
||||
#: src/trainer/ui/CategorySection.tsx
|
||||
msgid "{cheatCount} mods · {enabledCount}/{toggleCount} on"
|
||||
msgstr "{cheatCount} mods · {enabledCount}/{toggleCount} on"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Accent Color"
|
||||
msgstr "Accent Color"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Active Session"
|
||||
msgstr "Active Session"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add"
|
||||
msgstr "Add"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Add Preset"
|
||||
msgstr "Add Preset"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "All Games"
|
||||
msgstr "All Games"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Amber"
|
||||
msgstr "Amber"
|
||||
|
||||
#: src/trainer/controls/ActionButton.tsx
|
||||
msgid "Apply"
|
||||
msgstr "Apply"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Bridge"
|
||||
msgstr "Bridge"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Bridge offline"
|
||||
msgstr "Bridge offline"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Browse library"
|
||||
msgstr "Browse library"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Challenge"
|
||||
msgstr "Challenge"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Character"
|
||||
msgstr "Character"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Cheats"
|
||||
msgstr "Cheats"
|
||||
|
||||
#: src/shared/ui/SearchInput.tsx
|
||||
msgid "Clear search"
|
||||
msgstr "Clear search"
|
||||
|
||||
#: src/shared/ui/Drawer.tsx
|
||||
msgid "Close drawer"
|
||||
msgstr "Close drawer"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Close library"
|
||||
msgstr "Close library"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Close preset modal"
|
||||
msgstr "Close preset modal"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Close settings"
|
||||
msgstr "Close settings"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cobalt"
|
||||
msgstr "Cobalt"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Crafting"
|
||||
msgstr "Crafting"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Crimson"
|
||||
msgstr "Crimson"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Custom"
|
||||
msgstr "Custom"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Cyan"
|
||||
msgstr "Cyan"
|
||||
|
||||
#. placeholder {0}: preset.name
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Delete preset {0}"
|
||||
msgstr "Delete preset {0}"
|
||||
|
||||
#. placeholder {0}: trainer.totalCheats
|
||||
#: src/app/app.tsx
|
||||
msgid "END · {0} MODS"
|
||||
msgstr "END · {0} MODS"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Enemies"
|
||||
msgstr "Enemies"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Favorite game"
|
||||
msgstr "Favorite game"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Favorites"
|
||||
msgstr "Favorites"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Game"
|
||||
msgstr "Game"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "GO"
|
||||
msgstr "GO"
|
||||
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Idle · no game"
|
||||
msgstr "Idle · no game"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Inventory"
|
||||
msgstr "Inventory"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Items"
|
||||
msgstr "Items"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Library"
|
||||
msgstr "Library"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Lime"
|
||||
msgstr "Lime"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LINKING"
|
||||
msgstr "LINKING"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "LIVE"
|
||||
msgstr "LIVE"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Magenta"
|
||||
msgstr "Magenta"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "New preset"
|
||||
msgstr "New preset"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "No active game session."
|
||||
msgstr "No active game session."
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "No game is running yet. Open the library and launch one to start tweaking."
|
||||
msgstr "No game is running yet. Open the library and launch one to start tweaking."
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "No games match \"{query}\""
|
||||
msgstr "No games match \"{query}\""
|
||||
|
||||
#. placeholder {0}: trainer.query
|
||||
#: src/app/app.tsx
|
||||
msgid "No mods match \"{0}\""
|
||||
msgstr "No mods match \"{0}\""
|
||||
|
||||
#: src/trainer/controls/SelectionControl.tsx
|
||||
msgid "No options"
|
||||
msgstr "No options"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
msgid "No session"
|
||||
msgstr "No session"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Now Playing"
|
||||
msgstr "Now Playing"
|
||||
|
||||
#: src/app/ui/StatusPill.tsx
|
||||
msgid "OFFLINE"
|
||||
msgstr "OFFLINE"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings"
|
||||
msgstr "Open Settings"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Open Settings to point Wand at your trainer bridge over WebSocket."
|
||||
msgstr "Open Settings to point Wand at your trainer bridge over WebSocket."
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Panic Off"
|
||||
msgstr "Panic Off"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Physics"
|
||||
msgstr "Physics"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Pinned"
|
||||
msgstr "Pinned"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Play"
|
||||
msgstr "Play"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Player"
|
||||
msgstr "Player"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Preset name"
|
||||
msgstr "Preset name"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Remove favorite"
|
||||
msgstr "Remove favorite"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: src/trainer/ui/QuickActions.tsx
|
||||
msgid "Save"
|
||||
msgstr "Save"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Search games"
|
||||
msgstr "Search games"
|
||||
|
||||
#: src/app/app.tsx
|
||||
msgid "Search mods"
|
||||
msgstr "Search mods"
|
||||
|
||||
#: src/app/ui/SessionPlaceholder.tsx
|
||||
msgid "Select a game"
|
||||
msgstr "Select a game"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Session"
|
||||
msgstr "Session"
|
||||
|
||||
#: src/app/ui/FloatingDock.tsx
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
#: src/app/ui/TopBar.tsx
|
||||
msgid "Settings"
|
||||
msgstr "Settings"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Stats"
|
||||
msgstr "Stats"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "STOP"
|
||||
msgstr "STOP"
|
||||
|
||||
#: src/library/ui/LibraryDrawer.tsx
|
||||
msgid "Stop playing"
|
||||
msgstr "Stop playing"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Teleport"
|
||||
msgstr "Teleport"
|
||||
|
||||
#: src/trainer/ui/TrainerHeader.tsx
|
||||
msgid "Trainer Active"
|
||||
msgstr "Trainer Active"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Vehicles"
|
||||
msgstr "Vehicles"
|
||||
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "Violet"
|
||||
msgstr "Violet"
|
||||
|
||||
#. placeholder {0}: WEB_CONTRACT.defaultRemotePort
|
||||
#: src/app/ui/SettingsDrawer.tsx
|
||||
msgid "wand remote · port {0}"
|
||||
msgstr "wand remote · port {0}"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "Weapons"
|
||||
msgstr "Weapons"
|
||||
|
||||
#: src/trainer/ui/category-labels.ts
|
||||
msgid "World"
|
||||
msgstr "World"
|
||||
@@ -1,21 +0,0 @@
|
||||
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';
|
||||
|
||||
const root = document.getElementById('root') ?? document.getElementById('app');
|
||||
|
||||
if (!root) {
|
||||
throw new Error('App root not found.');
|
||||
}
|
||||
|
||||
applySavedAccentColor();
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.po' {
|
||||
import type { Messages } from '@lingui/core';
|
||||
export const messages: Messages;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { WEB_CONTRACT } from '../../protocol/contract';
|
||||
import {
|
||||
type HelloMessage,
|
||||
type IncomingMessage,
|
||||
type OutgoingMessage,
|
||||
PROTOCOL_VERSION,
|
||||
type RemoteCommandMessage,
|
||||
type SetValueMessage,
|
||||
} from '../../protocol/messages';
|
||||
import { isIncomingMessage } from '../../protocol/validation';
|
||||
|
||||
type SocketHandlers = {
|
||||
onConnecting: () => void;
|
||||
onTransportOpen: () => void;
|
||||
onMessage: (message: IncomingMessage) => void;
|
||||
onClose: () => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
let requestSequence = 0;
|
||||
|
||||
export class RemoteSessionClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly handlers: SocketHandlers,
|
||||
) {}
|
||||
|
||||
connect(pairingToken?: string): void {
|
||||
this.disconnect();
|
||||
this.intentionalDisconnect = false;
|
||||
this.handlers.onConnecting();
|
||||
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.handlers.onTransportOpen();
|
||||
this.send(this.createHelloMessage(pairingToken));
|
||||
});
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
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;
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return Boolean(this.socket && this.socket.readyState === WebSocket.OPEN);
|
||||
}
|
||||
|
||||
setValue(trainerId: string, target: string, value: unknown, cheatId?: string): string | null {
|
||||
const requestId = createRequestId(`set_${target}`);
|
||||
const message: SetValueMessage = {
|
||||
type: 'set_value',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId,
|
||||
payload: { trainerId, target, value, cheatId },
|
||||
};
|
||||
return this.send(message) ? requestId : null;
|
||||
}
|
||||
|
||||
launchGame(gameId: string, titleId?: string): boolean {
|
||||
return this.sendCommand('launch', gameId, titleId);
|
||||
}
|
||||
|
||||
stopPlaying(gameId?: string, titleId?: string): boolean {
|
||||
return this.sendCommand('stop', gameId, titleId);
|
||||
}
|
||||
|
||||
private send(message: OutgoingMessage): boolean {
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
socket.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
private sendCommand(action: 'launch' | 'stop', gameId?: string, titleId?: string): boolean {
|
||||
const message: RemoteCommandMessage = {
|
||||
type: 'remote_command',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: createRequestId(`command_${action}`),
|
||||
payload: { action, gameId, titleId },
|
||||
};
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
private createHelloMessage(pairingToken?: string): HelloMessage {
|
||||
return {
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: createRequestId('hello'),
|
||||
payload: {
|
||||
client: 'mobile-web',
|
||||
clientVersion: WEB_CONTRACT.clientVersion,
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestId(prefix: string): string {
|
||||
requestSequence += 1;
|
||||
return `${prefix}_${Date.now()}_${requestSequence}`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { CheatSchema, TrainerMetaPayload } from '../../protocol/messages';
|
||||
|
||||
const WEMOD_TRAINER_ENDPOINT = 'https://api.wemod.com/v3/games';
|
||||
|
||||
type WemodTrainerResponse = {
|
||||
i18n?: { strings?: Record<string, string> };
|
||||
};
|
||||
|
||||
export async function localizeTrainerMeta(payload: TrainerMetaPayload): Promise<TrainerMetaPayload> {
|
||||
const strings = await fetchTrainerStrings(payload);
|
||||
if (!strings) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const cheats = payload.schema.cheats.map((cheat) => localizeCheat(cheat, strings));
|
||||
return { ...payload, schema: { ...payload.schema, cheats } };
|
||||
}
|
||||
|
||||
async function fetchTrainerStrings(payload: TrainerMetaPayload): Promise<Record<string, string> | null> {
|
||||
const { accessToken } = payload.session;
|
||||
const { gameId, gameVersion, language } = payload.trainer;
|
||||
if (!accessToken || !gameId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (gameVersion) params.set('gameVersions', gameVersion);
|
||||
if (language) params.set('locale', language);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WEMOD_TRAINER_ENDPOINT}/${gameId}/trainer?${params}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trainer = (await response.json()) as WemodTrainerResponse;
|
||||
return trainer.i18n?.strings ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function localizeCheat(cheat: CheatSchema, strings: Record<string, string>): CheatSchema {
|
||||
return {
|
||||
...cheat,
|
||||
name: strings[cheat.name] ?? cheat.name,
|
||||
description: translate(cheat.description, strings),
|
||||
instructions: translate(cheat.instructions, strings),
|
||||
};
|
||||
}
|
||||
|
||||
function translate(value: string | null | undefined, strings: Record<string, string>): string | null {
|
||||
if (!value) {
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
return strings[value] ?? value;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { PROTOCOL_VERSION, type IncomingMessage } from '../../protocol/messages';
|
||||
import type { RemoteSessionAction } from './remote-session.reducer';
|
||||
|
||||
export function protocolAction(message: IncomingMessage): RemoteSessionAction | null {
|
||||
switch (message.type) {
|
||||
case 'hello_ack':
|
||||
if (!message.payload.accepted) {
|
||||
return { type: 'error', message: 'The desktop bridge rejected the connection.' };
|
||||
}
|
||||
if (message.payload.protocolVersion !== PROTOCOL_VERSION) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: `Protocol mismatch: bridge=${message.payload.protocolVersion}, panel=${PROTOCOL_VERSION}.`,
|
||||
};
|
||||
}
|
||||
return { type: 'connected' };
|
||||
case 'trainer_meta':
|
||||
return { type: 'trainerMeta', payload: message.payload };
|
||||
case 'game_status':
|
||||
return { type: 'gameStatus', payload: message.payload };
|
||||
case 'installed_apps':
|
||||
return { type: 'installedApps', payload: message.payload };
|
||||
case 'trainer_values':
|
||||
return { type: 'trainerValues', payload: message.payload.values };
|
||||
case 'value_changed':
|
||||
return { type: 'valueChanged', target: message.payload.target, value: message.payload.value };
|
||||
case 'trainer_changed':
|
||||
return { type: 'trainerChanged' };
|
||||
case 'set_value_result':
|
||||
return {
|
||||
type: 'writeResult',
|
||||
target: message.payload.target,
|
||||
requestId: message.requestId,
|
||||
ok: message.payload.ok,
|
||||
message: message.payload.error?.message,
|
||||
};
|
||||
case 'remote_command_result':
|
||||
return message.payload.ok
|
||||
? null
|
||||
: { type: 'error', message: message.payload.error?.message ?? 'The remote game command was rejected.' };
|
||||
case 'error':
|
||||
return { type: 'error', message: message.payload.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PROTOCOL_VERSION, type HelloAckMessage } from '../../protocol/messages';
|
||||
import { protocolAction } from './remote-session.protocol';
|
||||
import {
|
||||
createInitialRemoteSessionState,
|
||||
EConnectionStatus,
|
||||
remoteSessionReducer,
|
||||
type RemoteSessionState,
|
||||
} from './remote-session.reducer';
|
||||
|
||||
describe('remote session protocol', () => {
|
||||
it('connects only after an accepted compatible hello acknowledgement', () => {
|
||||
const action = protocolAction(helloAck(PROTOCOL_VERSION));
|
||||
expect(action).toEqual({ type: 'connected' });
|
||||
});
|
||||
|
||||
it('rejects a protocol version mismatch', () => {
|
||||
const action = protocolAction(helloAck(PROTOCOL_VERSION + 1));
|
||||
expect(action).toEqual({
|
||||
type: 'error',
|
||||
message: `Protocol mismatch: bridge=${PROTOCOL_VERSION + 1}, panel=${PROTOCOL_VERSION}.`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote session reducer', () => {
|
||||
it('enters reconnecting state after an unexpected close', () => {
|
||||
const state = { ...initialState(), connectionStatus: EConnectionStatus.Connected };
|
||||
const next = remoteSessionReducer(state, { type: 'connectionClosed', message: 'closed' });
|
||||
|
||||
expect(next.connectionStatus).toBe(EConnectionStatus.Reconnecting);
|
||||
expect(next.lastError).toBe('closed');
|
||||
});
|
||||
|
||||
it('applies snapshots and clears trainer data on trainer switch', () => {
|
||||
let state = remoteSessionReducer(initialState(), { type: 'trainerValues', payload: { speed: 2 } });
|
||||
state = remoteSessionReducer(state, { type: 'valueChanged', target: 'speed', value: 3 });
|
||||
expect(state.values.speed).toBe(3);
|
||||
|
||||
state = remoteSessionReducer(state, { type: 'trainerChanged' });
|
||||
expect(state.values).toEqual({});
|
||||
expect(state.pendingWrites).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps a write pending until result and commits a successful result', () => {
|
||||
let state = withConfirmedValue(1);
|
||||
state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: 2, requestId: 'new' });
|
||||
expect(state.values.speed).toBe(2);
|
||||
expect(state.pendingWrites.speed?.requestId).toBe('new');
|
||||
|
||||
state = remoteSessionReducer(state, { type: 'writeResult', target: 'speed', requestId: 'new', ok: true });
|
||||
expect(state.confirmedValues.speed).toBe(2);
|
||||
expect(state.pendingWrites.speed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a write after a matching value delta', () => {
|
||||
let state = withConfirmedValue(false);
|
||||
state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: true, requestId: 'new' });
|
||||
state = remoteSessionReducer(state, { type: 'valueChanged', target: 'speed', value: true });
|
||||
|
||||
expect(state.pendingWrites.speed).toBeUndefined();
|
||||
expect(state.confirmedValues.speed).toBe(true);
|
||||
});
|
||||
|
||||
it('rolls back only the current rejected request', () => {
|
||||
let state = withConfirmedValue(1);
|
||||
state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: 2, requestId: 'old' });
|
||||
state = remoteSessionReducer(state, { type: 'writeStarted', target: 'speed', value: 3, requestId: 'new' });
|
||||
state = remoteSessionReducer(state, { type: 'writeResult', target: 'speed', requestId: 'old', ok: false });
|
||||
expect(state.values.speed).toBe(3);
|
||||
expect(state.pendingWrites.speed?.requestId).toBe('new');
|
||||
|
||||
state = remoteSessionReducer(state, { type: 'writeResult', target: 'speed', requestId: 'new', ok: false });
|
||||
expect(state.values.speed).toBe(1);
|
||||
expect(state.pendingWrites.speed).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function helloAck(protocolVersion: number): HelloAckMessage {
|
||||
return {
|
||||
type: 'hello_ack',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: 'hello',
|
||||
payload: {
|
||||
sessionId: 'session',
|
||||
accepted: true,
|
||||
serverVersion: 'test',
|
||||
protocolVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function initialState(): RemoteSessionState {
|
||||
return { ...createInitialRemoteSessionState(), wsUrl: 'ws://test' };
|
||||
}
|
||||
|
||||
function withConfirmedValue(value: unknown): RemoteSessionState {
|
||||
return {
|
||||
...initialState(),
|
||||
values: { speed: value },
|
||||
confirmedValues: { speed: value },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
GameStatusPayload,
|
||||
InstalledAppSummary,
|
||||
InstalledAppsPayload,
|
||||
TrainerMetaPayload,
|
||||
} from '../../protocol/messages';
|
||||
import { readInitialWebSocketUrl } from './remote-session.urls';
|
||||
|
||||
export enum EConnectionStatus {
|
||||
Idle = 'idle',
|
||||
Connecting = 'connecting',
|
||||
Reconnecting = 'reconnecting',
|
||||
Connected = 'connected',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type PendingWrite = {
|
||||
requestId: string;
|
||||
value: unknown;
|
||||
previousConfirmedValue: unknown;
|
||||
};
|
||||
|
||||
export type RemoteSessionState = {
|
||||
connectionStatus: EConnectionStatus;
|
||||
wsUrl: string;
|
||||
trainerMeta: TrainerMetaPayload | null;
|
||||
gameStatus: GameStatusPayload | null;
|
||||
installedApps: InstalledAppSummary[];
|
||||
values: Record<string, unknown>;
|
||||
confirmedValues: Record<string, unknown>;
|
||||
pendingWrites: Record<string, PendingWrite>;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type RemoteSessionAction =
|
||||
| { type: 'setWsUrl'; wsUrl: string }
|
||||
| { type: 'connecting'; reconnecting?: boolean }
|
||||
| { type: 'connected' }
|
||||
| { type: 'connectionClosed'; message: string }
|
||||
| { 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: 'writeStarted'; target: string; value: unknown; requestId: string }
|
||||
| { type: 'writeResult'; target: string; requestId: string | null; ok: boolean; message?: string }
|
||||
| { type: 'trainerChanged' }
|
||||
| { type: 'error'; message: string | null };
|
||||
|
||||
export function createInitialRemoteSessionState(): RemoteSessionState {
|
||||
return {
|
||||
connectionStatus: EConnectionStatus.Idle,
|
||||
wsUrl: readInitialWebSocketUrl(),
|
||||
trainerMeta: null,
|
||||
gameStatus: null,
|
||||
installedApps: [],
|
||||
values: {},
|
||||
confirmedValues: {},
|
||||
pendingWrites: {},
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function remoteSessionReducer(
|
||||
state: RemoteSessionState,
|
||||
action: RemoteSessionAction,
|
||||
): RemoteSessionState {
|
||||
switch (action.type) {
|
||||
case 'setWsUrl':
|
||||
return { ...state, wsUrl: action.wsUrl };
|
||||
case 'connecting':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: action.reconnecting ? EConnectionStatus.Reconnecting : EConnectionStatus.Connecting,
|
||||
lastError: null,
|
||||
};
|
||||
case 'connected':
|
||||
return { ...state, connectionStatus: EConnectionStatus.Connected, lastError: null };
|
||||
case 'connectionClosed':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: EConnectionStatus.Reconnecting,
|
||||
pendingWrites: {},
|
||||
lastError: action.message,
|
||||
};
|
||||
case 'disconnected':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: EConnectionStatus.Idle,
|
||||
trainerMeta: null,
|
||||
gameStatus: null,
|
||||
values: {},
|
||||
confirmedValues: {},
|
||||
pendingWrites: {},
|
||||
lastError: null,
|
||||
};
|
||||
case 'trainerMeta':
|
||||
return { ...state, trainerMeta: action.payload, pendingWrites: {} };
|
||||
case 'gameStatus':
|
||||
return { ...state, gameStatus: action.payload };
|
||||
case 'installedApps':
|
||||
return { ...state, installedApps: action.payload.apps };
|
||||
case 'trainerValues':
|
||||
return {
|
||||
...state,
|
||||
values: action.payload,
|
||||
confirmedValues: action.payload,
|
||||
pendingWrites: {},
|
||||
};
|
||||
case 'valueChanged':
|
||||
return applyConfirmedValue(state, action.target, action.value);
|
||||
case 'writeStarted':
|
||||
return {
|
||||
...state,
|
||||
values: { ...state.values, [action.target]: action.value },
|
||||
pendingWrites: {
|
||||
...state.pendingWrites,
|
||||
[action.target]: {
|
||||
requestId: action.requestId,
|
||||
value: action.value,
|
||||
previousConfirmedValue: state.confirmedValues[action.target],
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'writeResult':
|
||||
return applyWriteResult(state, action);
|
||||
case 'trainerChanged':
|
||||
return {
|
||||
...state,
|
||||
trainerMeta: null,
|
||||
values: {},
|
||||
confirmedValues: {},
|
||||
pendingWrites: {},
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: action.message && state.connectionStatus !== EConnectionStatus.Connected
|
||||
? EConnectionStatus.Error
|
||||
: state.connectionStatus,
|
||||
lastError: action.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfirmedValue(
|
||||
state: RemoteSessionState,
|
||||
target: string,
|
||||
value: unknown,
|
||||
): RemoteSessionState {
|
||||
const pending = state.pendingWrites[target];
|
||||
const pendingWrites = { ...state.pendingWrites };
|
||||
if (pending && Object.is(pending.value, value)) {
|
||||
delete pendingWrites[target];
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
values: { ...state.values, [target]: value },
|
||||
confirmedValues: { ...state.confirmedValues, [target]: value },
|
||||
pendingWrites,
|
||||
};
|
||||
}
|
||||
|
||||
function applyWriteResult(
|
||||
state: RemoteSessionState,
|
||||
action: Extract<RemoteSessionAction, { type: 'writeResult' }>,
|
||||
): RemoteSessionState {
|
||||
const pending = state.pendingWrites[action.target];
|
||||
if (!pending || !action.requestId || pending.requestId !== action.requestId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const pendingWrites = { ...state.pendingWrites };
|
||||
delete pendingWrites[action.target];
|
||||
|
||||
if (action.ok) {
|
||||
return {
|
||||
...state,
|
||||
confirmedValues: { ...state.confirmedValues, [action.target]: pending.value },
|
||||
pendingWrites,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
values: { ...state.values, [action.target]: pending.previousConfirmedValue },
|
||||
pendingWrites,
|
||||
lastError: action.message ?? 'The trainer rejected the requested value.',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { WEB_CONTRACT } from '../../protocol/contract';
|
||||
|
||||
export const WS_QUERY_PARAM = 'ws';
|
||||
|
||||
const DEV_SERVER_PORTS = new Set(['4173', '5173']);
|
||||
|
||||
function protocolForWebSocket(): 'ws' | 'wss' {
|
||||
return window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
}
|
||||
|
||||
function isServedByRemoteBridge(): boolean {
|
||||
return window.location.pathname.startsWith(WEB_CONTRACT.basePath) && !DEV_SERVER_PORTS.has(window.location.port);
|
||||
}
|
||||
|
||||
export function readInitialWebSocketUrl(): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const explicitUrl = params.get(WS_QUERY_PARAM)?.trim();
|
||||
if (explicitUrl) {
|
||||
return explicitUrl;
|
||||
}
|
||||
|
||||
if (isServedByRemoteBridge()) {
|
||||
return `${protocolForWebSocket()}://${window.location.host}${WEB_CONTRACT.webSocketPath}`;
|
||||
}
|
||||
|
||||
return `ws://127.0.0.1:${WEB_CONTRACT.defaultRemotePort}${WEB_CONTRACT.webSocketPath}`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { EConnectionStatus, type RemoteSessionState } from './remote-session.reducer';
|
||||
|
||||
export function selectIsConnected(state: RemoteSessionState): boolean {
|
||||
return state.connectionStatus === EConnectionStatus.Connected;
|
||||
}
|
||||
|
||||
export function selectPendingTargets(state: RemoteSessionState): Record<string, boolean> {
|
||||
return Object.fromEntries(Object.keys(state.pendingWrites).map((target) => [target, true]));
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
|
||||
import { type CheatSchema, type InstalledAppSummary } from '../../protocol/messages';
|
||||
import { normalizeCheatValue } from '../trainer/model/values';
|
||||
import { RemoteSessionClient } from './remote-session.client';
|
||||
import { localizeTrainerMeta } from './remote-session.i18n';
|
||||
import {
|
||||
createInitialRemoteSessionState,
|
||||
EConnectionStatus,
|
||||
remoteSessionReducer,
|
||||
type RemoteSessionState,
|
||||
} from './remote-session.reducer';
|
||||
import { protocolAction } from './remote-session.protocol';
|
||||
import { selectIsConnected, selectPendingTargets } from './selectors';
|
||||
|
||||
const RECONNECT_DELAY_MS = 2000;
|
||||
|
||||
export function useRemoteSession() {
|
||||
const [state, dispatch] = useReducer(remoteSessionReducer, undefined, createInitialRemoteSessionState);
|
||||
const stateRef = useRef(state);
|
||||
const clientRef = useRef<RemoteSessionClient | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const connectRef = useRef<() => void>(() => {});
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
const clearReconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
clearReconnect();
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
if (document.visibilityState === 'visible' && stateRef.current.wsUrl.trim()) {
|
||||
connectRef.current();
|
||||
}
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}, [clearReconnect]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
clientRef.current?.disconnect();
|
||||
clearReconnect();
|
||||
|
||||
const wsUrl = stateRef.current.wsUrl.trim();
|
||||
if (!wsUrl) {
|
||||
dispatch({ type: 'error', message: 'Enter a WebSocket URL first.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new RemoteSessionClient(wsUrl, {
|
||||
onConnecting: () => dispatch({
|
||||
type: 'connecting',
|
||||
reconnecting: stateRef.current.connectionStatus === EConnectionStatus.Reconnecting,
|
||||
}),
|
||||
onTransportOpen: () => undefined,
|
||||
onMessage: (message) => {
|
||||
const action = protocolAction(message);
|
||||
if (!action) return;
|
||||
if (action.type === 'trainerMeta') {
|
||||
void localizeTrainerMeta(action.payload).then((payload) => dispatch({ ...action, payload }));
|
||||
return;
|
||||
}
|
||||
dispatch(action);
|
||||
},
|
||||
onClose: () => {
|
||||
dispatch({ type: 'connectionClosed', message: 'The WebSocket connection closed. Reconnecting...' });
|
||||
scheduleReconnect();
|
||||
},
|
||||
onError: (message) => dispatch({ type: 'error', message }),
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
client.connect();
|
||||
}, [clearReconnect, scheduleReconnect]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
clearReconnect();
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
dispatch({ type: 'disconnected' });
|
||||
}, [clearReconnect]);
|
||||
|
||||
const setWsUrl = useCallback((wsUrl: string) => dispatch({ type: 'setWsUrl', wsUrl }), []);
|
||||
const reportError = useCallback((message: string | null) => dispatch({ type: 'error', message }), []);
|
||||
|
||||
const changeCheat = useCallback((cheat: CheatSchema, nextValue: unknown) => {
|
||||
const current = stateRef.current;
|
||||
if (current.connectionStatus !== EConnectionStatus.Connected || !current.trainerMeta) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not connected.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = normalizeCheatValue(cheat, nextValue);
|
||||
const requestId = clientRef.current?.setValue(
|
||||
current.trainerMeta.trainer.trainerId,
|
||||
cheat.target,
|
||||
value,
|
||||
cheat.uuid,
|
||||
) ?? null;
|
||||
if (!requestId) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
dispatch({ type: 'writeStarted', target: cheat.target, value, requestId });
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const launchGame = useCallback((app: InstalledAppSummary): boolean => {
|
||||
if (!app.gameId) {
|
||||
dispatch({ type: 'error', message: 'This My Games entry does not expose a Wand game id.' });
|
||||
return false;
|
||||
}
|
||||
if (!isReadyToSend(stateRef.current, clientRef.current)) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not connected.' });
|
||||
return false;
|
||||
}
|
||||
if (!clientRef.current?.launchGame(app.gameId, app.titleId ?? undefined)) {
|
||||
dispatch({ type: 'error', message: 'Failed to send the launch command to the bridge.' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const stopPlaying = useCallback(() => {
|
||||
const current = stateRef.current;
|
||||
if (!isReadyToSend(current, clientRef.current)) {
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not connected.' });
|
||||
return;
|
||||
}
|
||||
const gameId = current.gameStatus?.session.gameId ?? current.gameStatus?.trainer.gameId ?? undefined;
|
||||
const titleId = current.gameStatus?.session.titleId ?? current.gameStatus?.trainer.titleId ?? undefined;
|
||||
if (!clientRef.current?.stopPlaying(gameId ?? undefined, titleId ?? undefined)) {
|
||||
dispatch({ type: 'error', message: 'Failed to send the stop command to the bridge.' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connectRef.current = connect;
|
||||
}, [connect]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && !clientRef.current?.isOpen()) {
|
||||
connectRef.current();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (stateRef.current.wsUrl.trim()) {
|
||||
connectRef.current();
|
||||
}
|
||||
return () => {
|
||||
clearReconnect();
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, [clearReconnect]);
|
||||
|
||||
const connected = selectIsConnected(state);
|
||||
const pendingTargets = useMemo(() => selectPendingTargets(state), [state]);
|
||||
|
||||
return {
|
||||
state,
|
||||
connected,
|
||||
pendingTargets,
|
||||
socketReady: connected,
|
||||
connect,
|
||||
disconnect,
|
||||
setWsUrl,
|
||||
reportError,
|
||||
changeCheat,
|
||||
launchGame,
|
||||
stopPlaying,
|
||||
};
|
||||
}
|
||||
|
||||
function isReadyToSend(state: RemoteSessionState, client: RemoteSessionClient | null): boolean {
|
||||
return state.connectionStatus === EConnectionStatus.Connected && Boolean(client?.isOpen());
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { loadStringSet, saveStringSet } from './storage';
|
||||
|
||||
describe('storage revival', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('revives only valid string ids', () => {
|
||||
localStorage.setItem('pins', JSON.stringify(['one', '', 2, 'two']));
|
||||
expect(loadStringSet('pins')).toEqual({ one: true, two: true });
|
||||
});
|
||||
|
||||
it('removes empty sets', () => {
|
||||
saveStringSet('pins', { one: true });
|
||||
expect(localStorage.getItem('pins')).toBe(JSON.stringify(['one']));
|
||||
saveStringSet('pins', {});
|
||||
expect(localStorage.getItem('pins')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TrainerSummary } from './protocol';
|
||||
import type { TrainerSummary } from '../../protocol/messages';
|
||||
|
||||
type Reviver<T> = (raw: unknown) => T | null;
|
||||
|
||||
+5
-2
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
const DRAWER_SIDE_CLASSES = {
|
||||
left: 'left-0 border-r',
|
||||
@@ -23,6 +25,7 @@ type DrawerProps = {
|
||||
};
|
||||
|
||||
export const Drawer = ({ open, side, children, onClose }: DrawerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const sideClassName = DRAWER_SIDE_CLASSES[side];
|
||||
const closedClassName = DRAWER_CLOSED_CLASSES[side];
|
||||
|
||||
@@ -30,7 +33,7 @@ export const Drawer = ({ open, side, children, onClose }: DrawerProps) => {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close drawer"
|
||||
aria-label={_(msg`Close drawer`)}
|
||||
className={cn(DRAWER_OVERLAY_CLASS, open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
+6
-3
@@ -1,8 +1,10 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
type SearchInputProps = {
|
||||
value: string;
|
||||
@@ -12,6 +14,7 @@ type SearchInputProps = {
|
||||
};
|
||||
|
||||
export const SearchInput = ({ value, placeholder, className, onChange }: SearchInputProps) => {
|
||||
const { _ } = useLingui();
|
||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onChange(event.currentTarget.value);
|
||||
const handleClear = () => onChange('');
|
||||
|
||||
@@ -26,7 +29,7 @@ export const SearchInput = ({ value, placeholder, className, onChange }: SearchI
|
||||
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}>
|
||||
<button type="button" aria-label={_(msg`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}
|
||||
+6
-2
@@ -1,10 +1,14 @@
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
|
||||
import type { ControlInternalProps } from './shared';
|
||||
|
||||
export const ActionButton = ({ cheat, disabled, onChange }: ControlInternalProps) => {
|
||||
const { _ } = useLingui();
|
||||
const handleClick = () => onChange(1);
|
||||
const label = typeof cheat.args.button === 'string' ? cheat.args.button : 'Apply';
|
||||
const label = typeof cheat.args.button === 'string' ? cheat.args.button : _(msg`Apply`);
|
||||
|
||||
return (
|
||||
<button
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { ECheatType } from '../protocol';
|
||||
import type { CheatSchema } from '../../../protocol/messages';
|
||||
import { ECheatType } from '../../../protocol/messages';
|
||||
import { ActionButton } from './ActionButton';
|
||||
import { IncrementalControl } from './IncrementalControl';
|
||||
import { NumberControl } from './NumberControl';
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import { resolveOption } from '../protocol';
|
||||
import { resolveOption } from '../model/values';
|
||||
import { ActionButton } from './ActionButton';
|
||||
import { StepButton, type ControlInternalProps } from './shared';
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { type FormEvent } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import { formatInputNumber, numericValue, stripNumberGrouping } from './format-number';
|
||||
import { StepButton, type ControlInternalProps } from './shared';
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { type FormEvent } from 'react';
|
||||
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { resolveOption } from '../protocol';
|
||||
import type { CheatSchema } from '../../../protocol/messages';
|
||||
import { resolveOption } from '../model/values';
|
||||
import { formatNumber, numericValue } from './format-number';
|
||||
import { SliderTrack, type ControlInternalProps } from './shared';
|
||||
|
||||
+6
-5
@@ -1,17 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import type { CheatOption } from '../protocol';
|
||||
import { resolveOption } from '../protocol';
|
||||
import type { CheatOption } from '../../../protocol/messages';
|
||||
import { resolveOption } from '../model/values';
|
||||
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>;
|
||||
return <span className="text-[12px] text-(--deck-fg-4)"><Trans>No options</Trans></span>;
|
||||
}
|
||||
|
||||
const selectedOption = findOption(options, String(value ?? options[0].value)) ?? options[0];
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import type { ControlInternalProps } from './shared';
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import { type FormEvent } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import type { CheatSchema } from '../../../protocol/messages';
|
||||
|
||||
export type ControlInternalProps = {
|
||||
cheat: CheatSchema;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ECheatType, type TrainerMetaPayload } from '../../../protocol/messages';
|
||||
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from './categories';
|
||||
|
||||
const trainerMeta: TrainerMetaPayload = {
|
||||
session: { instanceId: 'session' },
|
||||
trainer: {
|
||||
trainerId: 'trainer',
|
||||
gameId: 'game',
|
||||
trainerLoading: false,
|
||||
gameInstalled: true,
|
||||
needsCompatibilityWarning: false,
|
||||
isTimeLimitExpired: false,
|
||||
},
|
||||
schema: {
|
||||
categories: ['player', 'world'],
|
||||
cheats: [
|
||||
{ uuid: '1', target: 'health', type: ECheatType.Toggle, name: 'Infinite Health', category: 'player', args: {} },
|
||||
{ uuid: '2', target: 'time', type: ECheatType.Slider, name: 'World Time', category: 'world', args: {} },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('trainer categories', () => {
|
||||
it('groups, filters and projects pinned cheats', () => {
|
||||
const groups = groupCheatsByCategory(trainerMeta);
|
||||
expect(groups.map((group) => group.id)).toEqual(['player', 'world']);
|
||||
expect(filterGroups(groups, 'health')[0]?.cheats.map((cheat) => cheat.target)).toEqual(['health']);
|
||||
expect(buildPinnedGroup(trainerMeta, { time: true })?.cheats.map((cheat) => cheat.target)).toEqual(['time']);
|
||||
});
|
||||
});
|
||||
+4
-52
@@ -1,46 +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> = {
|
||||
challenge: 'Challenge',
|
||||
character: 'Character',
|
||||
cheats: 'Cheats',
|
||||
crafting: 'Crafting',
|
||||
enemies: 'Enemies',
|
||||
game: 'Game',
|
||||
inventory: 'Inventory',
|
||||
items: 'Items',
|
||||
physics: 'Physics',
|
||||
pinned: 'Pinned',
|
||||
player: 'Player',
|
||||
resources: 'Resources',
|
||||
stats: 'Stats',
|
||||
teleport: 'Teleport',
|
||||
vehicles: 'Vehicles',
|
||||
weapons: 'Weapons',
|
||||
world: 'World',
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, IconName> = {
|
||||
challenge: 'flame',
|
||||
character: 'user',
|
||||
cheats: 'sparkles',
|
||||
crafting: 'hammer',
|
||||
enemies: 'heart-broken',
|
||||
game: 'gamepad',
|
||||
inventory: 'backpack',
|
||||
items: 'package',
|
||||
physics: 'atom',
|
||||
pinned: 'bolt',
|
||||
player: 'user',
|
||||
resources: 'box',
|
||||
stats: 'chart',
|
||||
teleport: 'map-pin',
|
||||
vehicles: 'car',
|
||||
weapons: 'swords',
|
||||
world: 'world',
|
||||
};
|
||||
import { formatHumanLabel } from '@/shared/lib/ui';
|
||||
import type { CheatSchema, TrainerMetaPayload, TrainerSummary } from '../../../protocol/messages';
|
||||
|
||||
export type CategoryGroup = {
|
||||
id: string;
|
||||
@@ -48,10 +7,6 @@ export type CategoryGroup = {
|
||||
cheats: CheatSchema[];
|
||||
};
|
||||
|
||||
export function formatCategoryName(category: string): string {
|
||||
return CATEGORY_LABELS[category.toLowerCase()] ?? formatHumanLabel(category);
|
||||
}
|
||||
|
||||
export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): CategoryGroup[] {
|
||||
if (!trainerMeta) {
|
||||
return [];
|
||||
@@ -65,7 +20,7 @@ export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): C
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.map(([id, cheats]) => ({ id, label: formatCategoryName(id), cheats }))
|
||||
.map(([id, cheats]) => ({ id, label: formatHumanLabel(id), cheats }))
|
||||
.sort((left, right) => left.label.localeCompare(right.label));
|
||||
}
|
||||
|
||||
@@ -86,7 +41,7 @@ export function buildPinnedGroup(
|
||||
|
||||
return {
|
||||
id: PINNED_CATEGORY_ID,
|
||||
label: formatCategoryName(PINNED_CATEGORY_ID),
|
||||
label: formatHumanLabel(PINNED_CATEGORY_ID),
|
||||
cheats: pinnedCheats,
|
||||
};
|
||||
}
|
||||
@@ -125,6 +80,3 @@ export function getTrainerDisplayName(trainer: TrainerSummary): string {
|
||||
return trainer.displayName?.trim() || trainer.gameId || trainer.titleId || trainer.trainerId;
|
||||
}
|
||||
|
||||
export function CategoryIcon({ category, className }: { category: string; className?: string }) {
|
||||
return <Icon className={className} name={CATEGORY_ICONS[category.toLowerCase()] ?? 'gamepad'} stroke={1.8} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ECheatType, type CheatOption, type CheatOptionLike, type CheatSchema } from '../../../protocol/messages';
|
||||
|
||||
const NUMBER_GROUP_SEPARATOR_PATTERN = /[,\s]/g;
|
||||
|
||||
export function resolveOption(option: CheatOptionLike): CheatOption {
|
||||
if (typeof option === 'string' || typeof option === 'number') {
|
||||
return { label: String(option), value: option };
|
||||
}
|
||||
|
||||
return {
|
||||
label: option.label ?? String(option.value),
|
||||
value: option.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCheatValue(cheat: CheatSchema, value: unknown): unknown {
|
||||
if (cheat.type === ECheatType.Toggle) {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
if (cheat.type !== ECheatType.Slider && cheat.type !== ECheatType.Number) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Number(value.trim().replace(NUMBER_GROUP_SEPARATOR_PATTERN, ''));
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { getTrainerStorageId, loadStringSet, saveStringSet } from './storage';
|
||||
import type { TrainerSummary } from './protocol';
|
||||
import type { TrainerSummary } from '../../../protocol/messages';
|
||||
import { getTrainerStorageId, loadStringSet, saveStringSet } from '../../shared/storage';
|
||||
|
||||
const STORAGE_PREFIX = 'wand-remote.pinned-cheats.v1:';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { CheatSchema } from '../../../protocol/messages';
|
||||
import { loadPinnedTargets, savePinnedTargets } from './pinned-cheat-storage';
|
||||
|
||||
type PinnedTargetsParams = {
|
||||
pinnedStorageKey: string | null;
|
||||
};
|
||||
|
||||
export function usePinnedCheats({ pinnedStorageKey }: PinnedTargetsParams) {
|
||||
const [pinnedTargets, setPinnedTargets] = useState<Record<string, true>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedTargets(loadPinnedTargets(pinnedStorageKey));
|
||||
}, [pinnedStorageKey]);
|
||||
|
||||
const toggle = useCallback(
|
||||
(cheat: CheatSchema) => {
|
||||
setPinnedTargets((current) => {
|
||||
const next = { ...current };
|
||||
if (next[cheat.target]) delete next[cheat.target];
|
||||
else next[cheat.target] = true;
|
||||
savePinnedTargets(pinnedStorageKey, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pinnedStorageKey],
|
||||
);
|
||||
|
||||
return { pinnedTargets, toggle };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ECheatType, type CheatSchema } from '../../../protocol/messages';
|
||||
import { capturePresetValues, loadPresets, savePresets } from './preset-storage';
|
||||
|
||||
const cheats: CheatSchema[] = [
|
||||
{ uuid: 'toggle', target: 'god', type: ECheatType.Toggle, name: 'God', category: 'player', args: {} },
|
||||
{ uuid: 'button', target: 'apply', type: ECheatType.Button, name: 'Apply', category: 'player', args: {} },
|
||||
];
|
||||
|
||||
describe('preset storage', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('captures persistent values and excludes one-shot actions', () => {
|
||||
expect(capturePresetValues(cheats, { god: true, apply: 1 })).toEqual({ god: true });
|
||||
});
|
||||
|
||||
it('revives valid presets and ignores malformed entries', () => {
|
||||
localStorage.setItem('presets', JSON.stringify([
|
||||
{ id: 'valid', name: 'Valid', createdAt: 'now', values: { god: true } },
|
||||
{ id: 'invalid', values: {} },
|
||||
]));
|
||||
|
||||
expect(loadPresets('presets')).toEqual([
|
||||
{ id: 'valid', name: 'Valid', createdAt: 'now', values: { god: true } },
|
||||
]);
|
||||
|
||||
savePresets('presets', []);
|
||||
expect(localStorage.getItem('presets')).toBeNull();
|
||||
});
|
||||
});
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
import { getTrainerStorageId, loadJson, saveJson } from './storage';
|
||||
import type { CheatSchema, TrainerSummary } from './protocol';
|
||||
import { ECheatType } from './protocol';
|
||||
import { ECheatType, type CheatSchema, type TrainerSummary } from '../../../protocol/messages';
|
||||
import { getTrainerStorageId, loadJson, saveJson } from '../../shared/storage';
|
||||
|
||||
export type RemotePreset = {
|
||||
id: string;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { TrainerMetaPayload } from '../../../protocol/messages';
|
||||
import { capturePresetValues, createPreset, loadPresets, savePresets, type RemotePreset } from './preset-storage';
|
||||
|
||||
type PresetsParams = {
|
||||
presetStorageKey: string;
|
||||
trainerMeta: TrainerMetaPayload | null;
|
||||
values: Record<string, unknown>;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
export function usePresets({ presetStorageKey, trainerMeta, values, onError }: PresetsParams) {
|
||||
const [presets, setPresets] = useState<RemotePreset[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setPresets(loadPresets(presetStorageKey));
|
||||
}, [presetStorageKey]);
|
||||
|
||||
const addPreset = useCallback(
|
||||
(name: string): boolean => {
|
||||
if (!trainerMeta) {
|
||||
onError('No active trainer to save as a preset.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const captured = capturePresetValues(trainerMeta.schema.cheats, values);
|
||||
if (Object.keys(captured).length === 0) {
|
||||
onError('There are no mod values to save yet.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = [...presets, createPreset(name, captured)];
|
||||
setPresets(next);
|
||||
savePresets(presetStorageKey, next);
|
||||
return true;
|
||||
},
|
||||
[onError, presets, presetStorageKey, trainerMeta, values],
|
||||
);
|
||||
|
||||
const deletePreset = useCallback(
|
||||
(presetId: string) => {
|
||||
const next = presets.filter((preset) => preset.id !== presetId);
|
||||
setPresets(next);
|
||||
savePresets(presetStorageKey, next);
|
||||
},
|
||||
[presets, presetStorageKey],
|
||||
);
|
||||
|
||||
return { presets, addPreset, deletePreset };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Icon, type IconName } from '@/shared/ui/Icon';
|
||||
|
||||
const CATEGORY_ICONS: Record<string, IconName> = {
|
||||
challenge: 'flame',
|
||||
character: 'user',
|
||||
cheats: 'sparkles',
|
||||
crafting: 'hammer',
|
||||
enemies: 'heart-broken',
|
||||
game: 'gamepad',
|
||||
inventory: 'backpack',
|
||||
items: 'package',
|
||||
physics: 'atom',
|
||||
pinned: 'bolt',
|
||||
player: 'user',
|
||||
resources: 'box',
|
||||
stats: 'chart',
|
||||
teleport: 'map-pin',
|
||||
vehicles: 'car',
|
||||
weapons: 'swords',
|
||||
world: 'world',
|
||||
};
|
||||
|
||||
export function CategoryIcon({ category, className }: { category: string; className?: string }) {
|
||||
return <Icon className={className} name={CATEGORY_ICONS[category.toLowerCase()] ?? 'gamepad'} stroke={1.8} />;
|
||||
}
|
||||
+18
-15
@@ -1,11 +1,15 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CategoryIcon, type CategoryGroup } from '../category';
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { ECheatType } from '../protocol';
|
||||
import type { CategoryGroup } from '../model/categories';
|
||||
import { CategoryIcon } from './CategoryIcon';
|
||||
import { CATEGORY_LABELS } from './category-labels';
|
||||
import type { CheatSchema } from '../../../protocol/messages';
|
||||
import { ECheatType } from '../../../protocol/messages';
|
||||
import { CheatTile } from './CheatTile';
|
||||
|
||||
type CategorySectionProps = {
|
||||
@@ -31,11 +35,18 @@ const CategorySectionBase = ({
|
||||
onCheatChange,
|
||||
onTogglePin,
|
||||
}: CategorySectionProps) => {
|
||||
const { _ } = useLingui();
|
||||
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);
|
||||
|
||||
const cheatCount = group.cheats.length;
|
||||
const descriptor = CATEGORY_LABELS[group.id.toLowerCase()];
|
||||
const label = descriptor ? _(descriptor) : group.label;
|
||||
const summary =
|
||||
toggleCount > 0 ? _(msg`${cheatCount} mods · ${enabledCount}/${toggleCount} on`) : _(msg`${cheatCount} mods`);
|
||||
|
||||
const cheatHandlers = useMemo(
|
||||
() =>
|
||||
group.cheats.map((cheat) => ({
|
||||
@@ -58,8 +69,8 @@ const CategorySectionBase = ({
|
||||
<CategoryIcon category={group.id} className="size-3.75" />
|
||||
</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 className="block truncate text-sm font-semibold">{label}</span>
|
||||
<span className="mt-0.5 block font-mono text-[10.5px] text-(--deck-fg-4)">{summary}</span>
|
||||
</span>
|
||||
{enabledCount > 0 ? <span className="inline-flex h-4.5 min-w-4.5 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" />
|
||||
@@ -102,11 +113,3 @@ function getEnabledToggleCount(cheats: CheatSchema[], values: Record<string, unk
|
||||
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`;
|
||||
}
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
import { memo, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Icon } from '@/shared/ui/Icon';
|
||||
import { cn } from '@/shared/lib/ui';
|
||||
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { ECheatType } from '../protocol';
|
||||
import type { CheatSchema } from '../../../protocol/messages';
|
||||
import { ECheatType } from '../../../protocol/messages';
|
||||
import { CheatControl } from '../controls/CheatControl';
|
||||
|
||||
type CheatTileProps = {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user