mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-28 17:01:04 +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
|
# Wand Web Panel
|
||||||
|
|
||||||
Local mobile-friendly web panel scaffold for Wand.
|
Local mobile-friendly web panel for Wand.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run dev
|
pnpm dev
|
||||||
|
pnpm bridge:demo
|
||||||
```
|
```
|
||||||
|
|
||||||
Hosted access on the local machine:
|
Hosted access on the local machine:
|
||||||
|
|
||||||
- `http://localhost:4173/?mock=1`
|
- `http://localhost:4173/`
|
||||||
|
|
||||||
Hosted access on the LAN:
|
Hosted access on the LAN:
|
||||||
|
|
||||||
@@ -21,11 +22,5 @@ pnpm run dev:host
|
|||||||
|
|
||||||
Then open the machine IP on port `4173`.
|
Then open the machine IP on port `4173`.
|
||||||
|
|
||||||
## Modes
|
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`.
|
||||||
- `?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.
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"
|
|||||||
const bridgeRoot = dirname(fileURLToPath(import.meta.url))
|
const bridgeRoot = dirname(fileURLToPath(import.meta.url))
|
||||||
const webPanelRoot = resolve(bridgeRoot, "..")
|
const webPanelRoot = resolve(bridgeRoot, "..")
|
||||||
const distRoot = resolve(webPanelRoot, "dist")
|
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 bridgeOutfile = resolve(distRoot, "bridge.cjs")
|
||||||
const rendererScriptsRoot = resolve(bridgeRoot, "scripts", "default")
|
const rendererScriptsRoot = resolve(bridgeRoot, "scripts", "default")
|
||||||
const rendererScriptsOutdir = resolve(distRoot, "renderer-scripts")
|
const rendererScriptsOutdir = resolve(distRoot, "renderer-scripts")
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import demoSession from '../fixtures/demo-session.json' with { type: 'json' };
|
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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const rootDir = path.resolve(__dirname, '..');
|
const rootDir = path.resolve(__dirname, '..');
|
||||||
const distDir = path.join(rootDir, 'dist');
|
const distDir = path.join(rootDir, 'dist');
|
||||||
const DEFAULT_REMOTE_PORT = 3223;
|
const DEFAULT_REMOTE_PORT = webContract.defaultRemotePort;
|
||||||
const DEFAULT_REMOTE_HOST = '0.0.0.0';
|
const DEFAULT_REMOTE_HOST = webContract.defaultRemoteHost;
|
||||||
const REMOTE_BASE_PATH = '/remote/';
|
const REMOTE_BASE_PATH = webContract.basePath;
|
||||||
const REMOTE_WS_PATH = '/remote/ws';
|
const REMOTE_WS_PATH = webContract.webSocketPath;
|
||||||
const REMOTE_HEALTH_PATH = '/remote/api/health';
|
const REMOTE_HEALTH_PATH = webContract.healthPath;
|
||||||
const REMOTE_ASSETS_PREFIX = '/remote/assets/';
|
const REMOTE_ASSETS_PREFIX = webContract.assetsPath;
|
||||||
const host = process.env.HOST || DEFAULT_REMOTE_HOST;
|
const host = process.env.HOST || DEFAULT_REMOTE_HOST;
|
||||||
const port = Number(process.env.PORT || DEFAULT_REMOTE_PORT);
|
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) {
|
function jsonMessage(type, payload, requestId = null) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
type,
|
type,
|
||||||
version: 1,
|
version: webContract.protocolVersion,
|
||||||
requestId,
|
requestId,
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
@@ -145,13 +146,20 @@ wss.on('connection', (ws) => {
|
|||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(String(raw));
|
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') {
|
if (message?.type === 'hello') {
|
||||||
ws.send(
|
ws.send(
|
||||||
jsonMessage('hello_ack', {
|
jsonMessage('hello_ack', {
|
||||||
sessionId: `sess_${Date.now()}`,
|
sessionId: `sess_${Date.now()}`,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
serverVersion: '0.1.0-demo',
|
serverVersion: '0.1.0-demo',
|
||||||
protocolVersion: 1,
|
protocolVersion: webContract.protocolVersion,
|
||||||
}, message.requestId ?? null)
|
}, message.requestId ?? null)
|
||||||
);
|
);
|
||||||
sendSnapshot(ws);
|
sendSnapshot(ws);
|
||||||
@@ -160,7 +168,7 @@ wss.on('connection', (ws) => {
|
|||||||
|
|
||||||
if (message?.type === 'set_value') {
|
if (message?.type === 'set_value') {
|
||||||
const target = message.payload?.target;
|
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(
|
ws.send(
|
||||||
jsonMessage('set_value_result', {
|
jsonMessage('set_value_result', {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -209,4 +217,4 @@ wss.on('connection', (ws) => {
|
|||||||
|
|
||||||
server.listen(port, host, () => {
|
server.listen(port, host, () => {
|
||||||
console.log(`Wand web panel bridge listening on http://${host === DEFAULT_REMOTE_HOST ? 'localhost' : host}:${port}${REMOTE_BASE_PATH}`);
|
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 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({
|
const WS_OPCODE = Object.freeze({
|
||||||
TEXT: 1,
|
TEXT: 1,
|
||||||
@@ -22,23 +23,23 @@ const IPC_CHANNEL = Object.freeze({
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BRIDGE_LOG_FILE_NAME: 'wand-remote-bridge.log',
|
BRIDGE_LOG_FILE_NAME: 'wand-remote-bridge.log',
|
||||||
BRIDGE_PROTOCOL_VERSION: 1,
|
BRIDGE_PROTOCOL_VERSION: WEB_CONTRACT.protocolVersion,
|
||||||
BRIDGE_SERVER_VERSION: '0.2.0-wand',
|
BRIDGE_SERVER_VERSION: WEB_CONTRACT.serverVersion,
|
||||||
DEFAULT_REMOTE_HOST: '0.0.0.0',
|
DEFAULT_REMOTE_HOST: WEB_CONTRACT.defaultRemoteHost,
|
||||||
DEFAULT_REMOTE_PORT: 3223,
|
DEFAULT_REMOTE_PORT: WEB_CONTRACT.defaultRemotePort,
|
||||||
IPC_CHANNEL,
|
IPC_CHANNEL,
|
||||||
KNOWN_CHEAT_TYPES,
|
KNOWN_CHEAT_TYPES,
|
||||||
PORT_SCAN_RANGE: 30,
|
PORT_SCAN_RANGE: WEB_CONTRACT.portScanRange,
|
||||||
REMOTE_ASSETS_PREFIX: '/remote/assets/',
|
REMOTE_ASSETS_PREFIX: WEB_CONTRACT.assetsPath,
|
||||||
REMOTE_BASE_PATH: '/remote/',
|
REMOTE_BASE_PATH: WEB_CONTRACT.basePath,
|
||||||
REMOTE_COMMAND_REQUEST_CHANNEL: IPC_CHANNEL.COMMAND_REQUEST,
|
REMOTE_COMMAND_REQUEST_CHANNEL: IPC_CHANNEL.COMMAND_REQUEST,
|
||||||
REMOTE_COMMAND_RESPONSE_CHANNEL: IPC_CHANNEL.COMMAND_RESPONSE,
|
REMOTE_COMMAND_RESPONSE_CHANNEL: IPC_CHANNEL.COMMAND_RESPONSE,
|
||||||
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS: 15000,
|
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS: 15000,
|
||||||
REMOTE_GAME_STATUS_CHANNEL: IPC_CHANNEL.GAME_STATUS,
|
REMOTE_GAME_STATUS_CHANNEL: IPC_CHANNEL.GAME_STATUS,
|
||||||
REMOTE_HEALTH_PATH: '/remote/api/health',
|
REMOTE_HEALTH_PATH: WEB_CONTRACT.healthPath,
|
||||||
REMOTE_INSTALLED_APPS_API_PATH: '/remote/api/installed-apps',
|
REMOTE_INSTALLED_APPS_API_PATH: WEB_CONTRACT.installedAppsPath,
|
||||||
REMOTE_INSTALLED_APPS_CHANNEL: IPC_CHANNEL.INSTALLED_APPS,
|
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_INJECTION_DELAYS_MS: Object.freeze([500, 2000]),
|
||||||
RENDERER_SCRIPT_API_VERSION: 1,
|
RENDERER_SCRIPT_API_VERSION: 1,
|
||||||
RENDERER_SCRIPTS_DIR: 'renderer-scripts',
|
RENDERER_SCRIPTS_DIR: 'renderer-scripts',
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./bridge-modules/runtime.cjs');
|
const { createBridgeRuntime: createRuntime, ensureBridge: ensureRuntime } = require('./runtime');
|
||||||
const { installWandRuntime: installRuntime } = require('./bridge-modules/wand-runtime.cjs');
|
const { installWandRuntime: installRuntime } = require('./wand/runtime');
|
||||||
|
import type { BridgeOptions, ElectronPort } from './types';
|
||||||
|
|
||||||
function withDefaultPanelRoot(options = {}) {
|
function withDefaultPanelRoot(options: BridgeOptions = {}): BridgeOptions {
|
||||||
if (options.panelRoot) {
|
if (options.panelRoot) {
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
@@ -12,15 +13,15 @@ function withDefaultPanelRoot(options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBridgeRuntime(options = {}) {
|
function createBridgeRuntime(options: BridgeOptions = {}) {
|
||||||
return createRuntime(withDefaultPanelRoot(options));
|
return createRuntime(withDefaultPanelRoot(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureBridge(options = {}) {
|
function ensureBridge(options: BridgeOptions = {}) {
|
||||||
return ensureRuntime(withDefaultPanelRoot(options));
|
return ensureRuntime(withDefaultPanelRoot(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
function installWandRuntime(electron, options = {}) {
|
function installWandRuntime(electron: ElectronPort, options: BridgeOptions = {}) {
|
||||||
return installRuntime(electron, withDefaultPanelRoot(options));
|
return installRuntime(electron, withDefaultPanelRoot(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,7 +2,8 @@ const fs = require('node:fs');
|
|||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const path = require('node:path');
|
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) {
|
function writeLogLine(logFile, level, message, error) {
|
||||||
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
|
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
|
||||||
@@ -18,7 +19,7 @@ function writeLogLine(logFile, level, message, error) {
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBridgeLogger(options = {}) {
|
function createBridgeLogger(options: BridgeOptions = {}) {
|
||||||
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
|
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
|
||||||
const log = (level, message, error) => writeLogLine(logFile, level, message, error);
|
const log = (level, message, error) => writeLogLine(logFile, level, message, error);
|
||||||
log.file = logFile;
|
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 { KNOWN_CHEAT_TYPES } = require('../constants');
|
||||||
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('./utils.cjs');
|
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) {
|
function normalizeOption(option) {
|
||||||
if (typeof option === 'string' || typeof option === 'number') {
|
if (typeof option === 'string' || typeof option === 'number') {
|
||||||
@@ -29,7 +32,7 @@ function normalizeArgs(args) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = {};
|
const next: Record<string, unknown> = {};
|
||||||
if (typeof args.min === 'number') next.min = args.min;
|
if (typeof args.min === 'number') next.min = args.min;
|
||||||
if (typeof args.max === 'number') next.max = args.max;
|
if (typeof args.max === 'number') next.max = args.max;
|
||||||
if (typeof args.step === 'number') next.step = args.step;
|
if (typeof args.step === 'number') next.step = args.step;
|
||||||
@@ -60,7 +63,7 @@ function normalizeCheat(cheat, index) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = {
|
const normalized: Record<string, unknown> = {
|
||||||
uuid: safeString(cheat.uuid, `${target}-${index}`),
|
uuid: safeString(cheat.uuid, `${target}-${index}`),
|
||||||
target,
|
target,
|
||||||
type,
|
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) {
|
function summarizeInstalledAppsSource(rawSnapshot) {
|
||||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
|
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = [];
|
const parts: string[] = [];
|
||||||
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
|
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
|
||||||
const value = rawSnapshot.diagnostics[key];
|
const value = rawSnapshot.diagnostics[key];
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
@@ -269,26 +197,6 @@ function installedAppsSignature(snapshot) {
|
|||||||
.join('\n');
|
.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) {
|
function buildInstalledAppsDebugPayload(snapshot) {
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return {
|
return {
|
||||||
@@ -412,6 +320,7 @@ function normalizeSnapshot(rawSnapshot) {
|
|||||||
const trainerMeta = {
|
const trainerMeta = {
|
||||||
session: {
|
session: {
|
||||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
|
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
|
||||||
|
accessToken: safeString(rawSnapshot.accessToken),
|
||||||
},
|
},
|
||||||
trainer: {
|
trainer: {
|
||||||
trainerId,
|
trainerId,
|
||||||
@@ -437,6 +346,11 @@ function normalizeSnapshot(rawSnapshot) {
|
|||||||
trainerId,
|
trainerId,
|
||||||
values: isRecord(rawSnapshot.values) ? cloneValue(rawSnapshot.values) : {},
|
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 {
|
return {
|
||||||
trainerMeta,
|
trainerMeta,
|
||||||
@@ -479,5 +393,6 @@ module.exports = {
|
|||||||
normalizeRemoteCommandAction,
|
normalizeRemoteCommandAction,
|
||||||
normalizeRemoteCommandResult,
|
normalizeRemoteCommandResult,
|
||||||
normalizeSnapshot,
|
normalizeSnapshot,
|
||||||
|
normalizeTrainerValue,
|
||||||
summarizeInstalledAppsSource,
|
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 os = require('node:os');
|
||||||
const path = require('node:path');
|
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 IPV4_OCTET_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||||
const PHYSICAL_INTERFACE_NAME_PATTERN = /(?:ethernet|wi-?fi|wireless|wlan|lan|local area)/i;
|
const PHYSICAL_INTERFACE_NAME_PATTERN = /(?:ethernet|wi-?fi|wireless|wlan|lan|local area)/i;
|
||||||
@@ -38,11 +38,11 @@ function contentTypeFor(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAdvertisedUrls(port) {
|
function getAdvertisedUrls(port) {
|
||||||
const candidates = [];
|
const candidates: any[] = [];
|
||||||
const interfaces = os.networkInterfaces();
|
const interfaces = os.networkInterfaces();
|
||||||
let index = 0;
|
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) {
|
if (!entries) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ function isIpv4Family(family) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scoreIpv4Entry(name, entry) {
|
function scoreIpv4Entry(name, entry) {
|
||||||
const octets = parseIpv4(entry.address);
|
const octets = parseIpv4(entry.address) as number[];
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
|
||||||
if (isPrivateIpv4(octets)) {
|
if (isPrivateIpv4(octets)) {
|
||||||
@@ -116,7 +116,7 @@ function scoreIpv4Entry(name, entry) {
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseIpv4(address) {
|
function parseIpv4(address): number[] | null {
|
||||||
if (typeof address !== 'string') {
|
if (typeof address !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -13,40 +13,41 @@ const {
|
|||||||
REMOTE_INSTALLED_APPS_API_PATH,
|
REMOTE_INSTALLED_APPS_API_PATH,
|
||||||
REMOTE_WS_PATH,
|
REMOTE_WS_PATH,
|
||||||
WS_OPCODE,
|
WS_OPCODE,
|
||||||
} = require('./constants.cjs');
|
} = require('./constants');
|
||||||
const { createBridgeLogger } = require('./logger.cjs');
|
const { createBridgeLogger } = require('./logger');
|
||||||
const {
|
const {
|
||||||
buildInstalledAppsDebugPayload,
|
|
||||||
gameStatusSignature,
|
|
||||||
installedAppsSignature,
|
|
||||||
normalizeGameStatusSnapshot,
|
|
||||||
normalizeInstalledAppsSnapshot,
|
|
||||||
normalizeRemoteCommandAction,
|
normalizeRemoteCommandAction,
|
||||||
normalizeRemoteCommandResult,
|
normalizeRemoteCommandResult,
|
||||||
normalizeSnapshot,
|
} = require('./normalizers');
|
||||||
summarizeInstalledAppsSource,
|
const { createBridgeState } = require('./bridge-state');
|
||||||
} = require('./normalizers.cjs');
|
const { validateClientMessage, validateSetValueTarget } = require('./protocol-router');
|
||||||
const { getAdvertisedUrls, serveFile } = require('./static-server.cjs');
|
const { getAdvertisedUrls, serveFile } = require('./server-files');
|
||||||
const { cloneValue, isRecord, isValidPort, safeString } = require('./utils.cjs');
|
const { cloneValue, isValidPort, safeString } = require('./utils');
|
||||||
const { closeClient, createAcceptKey, makeFrame, parseFrame, sendJson } = require('./websocket.cjs');
|
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);
|
const preferredPort = Number(options.port || process.env.WAND_REMOTE_PORT || DEFAULT_REMOTE_PORT);
|
||||||
let port = isValidPort(preferredPort) ? preferredPort : 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 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 host = options.host || process.env.WAND_REMOTE_HOST || DEFAULT_REMOTE_HOST;
|
||||||
const panelRoot = options.panelRoot || path.dirname(__dirname);
|
const panelRoot = options.panelRoot || path.dirname(__dirname);
|
||||||
const clients = new Set();
|
const clients = new Set<any>();
|
||||||
const log = createBridgeLogger(options);
|
const log = createBridgeLogger(options);
|
||||||
let advertisedUrls = [];
|
let advertisedUrls: string[] = [];
|
||||||
let currentSnapshot = null;
|
let setValueHandler: any = null;
|
||||||
let currentInstalledApps = null;
|
let commandHandler: any = null;
|
||||||
let currentInstalledAppsSignature = null;
|
|
||||||
let currentGameStatus = null;
|
|
||||||
let currentGameStatusSignature = null;
|
|
||||||
let setValueHandler = null;
|
|
||||||
let commandHandler = null;
|
|
||||||
let listening = false;
|
let listening = false;
|
||||||
|
const bridgeState = createBridgeState({
|
||||||
|
clients,
|
||||||
|
log,
|
||||||
|
getServerInfo: () => ({
|
||||||
|
advertisedUrls,
|
||||||
|
installedAppsApiPath: REMOTE_INSTALLED_APPS_API_PATH,
|
||||||
|
listening,
|
||||||
|
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
function setAdvertisedPort(nextPort) {
|
function setAdvertisedPort(nextPort) {
|
||||||
port = nextPort;
|
port = nextPort;
|
||||||
@@ -54,112 +55,6 @@ function createBridgeRuntime(options = {}) {
|
|||||||
globalThis.__wandRemoteBridgeUrl = advertisedUrls.find((entry) => !entry.includes('localhost')) || advertisedUrls[0];
|
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) {
|
function setHandler(handler) {
|
||||||
setValueHandler = typeof handler === 'function' ? handler : null;
|
setValueHandler = typeof handler === 'function' ? handler : null;
|
||||||
}
|
}
|
||||||
@@ -168,25 +63,6 @@ function createBridgeRuntime(options = {}) {
|
|||||||
commandHandler = typeof handler === 'function' ? handler : null;
|
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) {
|
function handleRequest(request, response) {
|
||||||
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||||
|
|
||||||
@@ -209,13 +85,13 @@ function createBridgeRuntime(options = {}) {
|
|||||||
|
|
||||||
if (url.pathname === REMOTE_HEALTH_PATH) {
|
if (url.pathname === REMOTE_HEALTH_PATH) {
|
||||||
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||||
response.end(JSON.stringify(buildHealthPayload()));
|
response.end(JSON.stringify(bridgeState.buildHealthPayload()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === REMOTE_INSTALLED_APPS_API_PATH) {
|
if (url.pathname === REMOTE_INSTALLED_APPS_API_PATH) {
|
||||||
response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,19 +159,18 @@ function createBridgeRuntime(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSetValueMessage(client, message) {
|
async function handleSetValueMessage(client, message) {
|
||||||
const target = safeString(message.payload?.target);
|
const currentSnapshot = bridgeState.snapshot;
|
||||||
if (!currentSnapshot || !target || !(target in currentSnapshot.trainerValues.values)) {
|
const validation = validateSetValueTarget(message, currentSnapshot);
|
||||||
|
if (!validation.ok) {
|
||||||
sendJson(client, 'set_value_result', {
|
sendJson(client, 'set_value_result', {
|
||||||
ok: false,
|
ok: false,
|
||||||
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '',
|
trainerId: currentSnapshot?.trainerMeta?.trainer?.trainerId || '',
|
||||||
target,
|
target: safeString(message.payload?.target),
|
||||||
error: {
|
error: validation.error,
|
||||||
code: 'invalid_target',
|
|
||||||
message: 'Unknown cheat target.',
|
|
||||||
},
|
|
||||||
}, message.requestId ?? null);
|
}, message.requestId ?? null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { target } = validation;
|
||||||
|
|
||||||
if (!setValueHandler) {
|
if (!setValueHandler) {
|
||||||
sendJson(client, 'set_value_result', {
|
sendJson(client, 'set_value_result', {
|
||||||
@@ -315,7 +190,7 @@ function createBridgeRuntime(options = {}) {
|
|||||||
result = await Promise.resolve(setValueHandler({
|
result = await Promise.resolve(setValueHandler({
|
||||||
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
trainerId: currentSnapshot.trainerMeta.trainer.trainerId,
|
||||||
target,
|
target,
|
||||||
value: cloneValue(message.payload?.value),
|
value: cloneValue(validation.value),
|
||||||
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
|
cheatId: typeof message.payload?.cheatId === 'string' ? message.payload.cheatId : undefined,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -352,7 +227,14 @@ function createBridgeRuntime(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleClientMessage(client, message) {
|
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') {
|
if (message?.type === 'hello') {
|
||||||
|
client.handshaken = true;
|
||||||
sendJson(client, 'hello_ack', {
|
sendJson(client, 'hello_ack', {
|
||||||
sessionId: `sess_${Date.now()}`,
|
sessionId: `sess_${Date.now()}`,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
@@ -361,7 +243,7 @@ function createBridgeRuntime(options = {}) {
|
|||||||
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
remoteUrl: globalThis.__wandRemoteBridgeUrl,
|
||||||
advertisedUrls,
|
advertisedUrls,
|
||||||
}, message.requestId ?? null);
|
}, message.requestId ?? null);
|
||||||
sendSnapshot(client);
|
bridgeState.sendSnapshot(client);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +262,7 @@ function createBridgeRuntime(options = {}) {
|
|||||||
socket,
|
socket,
|
||||||
buffer: Buffer.alloc(0),
|
buffer: Buffer.alloc(0),
|
||||||
closed: false,
|
closed: false,
|
||||||
|
handshaken: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
clients.add(client);
|
clients.add(client);
|
||||||
@@ -510,32 +393,19 @@ function createBridgeRuntime(options = {}) {
|
|||||||
closeClient(client);
|
closeClient(client);
|
||||||
}
|
}
|
||||||
clients.clear();
|
clients.clear();
|
||||||
currentSnapshot = null;
|
bridgeState.clear();
|
||||||
currentInstalledApps = null;
|
|
||||||
currentInstalledAppsSignature = null;
|
|
||||||
currentGameStatus = null;
|
|
||||||
currentGameStatusSignature = null;
|
|
||||||
listening = false;
|
listening = false;
|
||||||
server.close();
|
server.close();
|
||||||
},
|
},
|
||||||
setCommandHandler,
|
setCommandHandler,
|
||||||
setHandler,
|
setHandler,
|
||||||
sync,
|
sync: bridgeState.sync,
|
||||||
syncGameStatus,
|
syncGameStatus: bridgeState.syncGameStatus,
|
||||||
syncInstalledApps,
|
syncInstalledApps: bridgeState.syncInstalledApps,
|
||||||
valueChanged,
|
valueChanged: bridgeState.valueChanged,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureBridge(options = {}) {
|
|
||||||
if (!globalThis.__wandRemoteBridgeRuntime) {
|
|
||||||
globalThis.__wandRemoteBridgeRuntime = createBridgeRuntime(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return globalThis.__wandRemoteBridgeRuntime;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createBridgeRuntime,
|
createBridgeServer,
|
||||||
ensureBridge,
|
|
||||||
};
|
};
|
||||||
@@ -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 fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('./constants.cjs');
|
const { RENDERER_INJECTION_DELAYS_MS, RENDERER_SCRIPT_API_VERSION, RENDERER_SCRIPTS_DIR } = require('../constants');
|
||||||
const { writeInstallLog } = require('./logger.cjs');
|
const { writeInstallLog } = require('../logger');
|
||||||
|
import type { BridgeOptions, ElectronPort } from '../types';
|
||||||
|
|
||||||
function loadRendererScripts(panelRoot, scriptsRoot) {
|
function loadRendererScripts(panelRoot, scriptsRoot) {
|
||||||
const root = scriptsRoot || path.join(panelRoot, RENDERER_SCRIPTS_DIR);
|
const root = scriptsRoot || path.join(panelRoot, RENDERER_SCRIPTS_DIR);
|
||||||
@@ -51,7 +52,7 @@ function buildRendererBootstrap(remoteUrl, scripts) {
|
|||||||
return `(() => {\n${header}\n${body}\n})();`;
|
return `(() => {\n${header}\n${body}\n})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function installRendererScripts(electron, runtime, options = {}) {
|
function installRendererScripts(electron: ElectronPort, runtime, options: BridgeOptions = {}) {
|
||||||
if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) {
|
if (globalThis.__wandRemoteBridgeRendererScriptsInstalled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
+38
-9
@@ -8,19 +8,25 @@ const {
|
|||||||
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS,
|
REMOTE_COMMAND_RESPONSE_TIMEOUT_MS,
|
||||||
REMOTE_GAME_STATUS_CHANNEL,
|
REMOTE_GAME_STATUS_CHANNEL,
|
||||||
REMOTE_INSTALLED_APPS_CHANNEL,
|
REMOTE_INSTALLED_APPS_CHANNEL,
|
||||||
} = require('./constants.cjs');
|
} = require('../constants');
|
||||||
const { writeInstallLog } = require('./logger.cjs');
|
const { writeInstallLog } = require('../logger');
|
||||||
const { ensureBridge } = require('./runtime.cjs');
|
const { ensureBridge } = require('../runtime');
|
||||||
const { installRendererScripts } = require('./renderer-scripts.cjs');
|
const { installRendererScripts } = require('./renderer-scripts');
|
||||||
const { safeString } = require('./utils.cjs');
|
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);
|
const runtime = ensureBridge(options);
|
||||||
if (!electron || !electron.ipcMain || !electron.app) {
|
if (!electron || !electron.ipcMain || !electron.app) {
|
||||||
throw new Error('Electron main-process API is required to install Wand runtime hooks.');
|
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();
|
const pendingCommandResponses = globalThis.__wandRemoteBridgePendingCommandResponses || new Map();
|
||||||
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
|
globalThis.__wandRemoteBridgeBoundRenderers = boundRenderers;
|
||||||
globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses;
|
globalThis.__wandRemoteBridgePendingCommandResponses = pendingCommandResponses;
|
||||||
@@ -77,8 +83,8 @@ function installIpcHandlers(electron, runtime, boundRenderers, pendingCommandRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
globalThis.__wandRemoteBridgeIpcInstalled = true;
|
globalThis.__wandRemoteBridgeIpcInstalled = true;
|
||||||
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (_event, snapshot) => {
|
electron.ipcMain.handle(IPC_CHANNEL.TRAINER_SNAPSHOT, (event, snapshot) => {
|
||||||
runtime.sync(snapshot);
|
void syncSnapshotWithAccessToken(runtime, event?.sender, snapshot);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
electron.ipcMain.handle(REMOTE_INSTALLED_APPS_CHANNEL, (_event, snapshot) => {
|
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);
|
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) {
|
function dispatchRemoteCommandToRenderer(sender, request, pendingCommandResponses) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const requestId = `remote_command_${typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : Date.now().toString(36)}`;
|
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 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';
|
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ function jsonMessage(type, payload, requestId = null) {
|
|||||||
|
|
||||||
function makeFrame(opcode, payload) {
|
function makeFrame(opcode, payload) {
|
||||||
const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
||||||
const header = [];
|
const header: number[] = [];
|
||||||
header.push(0x80 | (opcode & 0x0f));
|
header.push(0x80 | (opcode & 0x0f));
|
||||||
|
|
||||||
if (source.length < 126) {
|
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'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'src/locales']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
@@ -19,5 +19,16 @@ export default defineConfig([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="./src/main.tsx"></script>
|
<script type="module" src="./src/app/main.tsx"></script>
|
||||||
</body>
|
</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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:host": "vite --host 0.0.0.0",
|
"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",
|
"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": "vite preview",
|
||||||
"preview:host": "vite preview --host 0.0.0.0",
|
"preview:host": "vite preview --host 0.0.0.0",
|
||||||
"bridge": "node ./bridge/server.mjs"
|
"bridge:demo": "node ./bridge/dev-server.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@lingui/core": "^6.3.0",
|
||||||
|
"@lingui/react": "^6.3.0",
|
||||||
"preact": "^10.27.2",
|
"preact": "^10.27.2",
|
||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@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",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@testing-library/preact": "^3.2.4",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -28,10 +42,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"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;
|
export { PROTOCOL_VERSION } from './contract';
|
||||||
const NUMBER_GROUP_SEPARATOR_PATTERN = /[,\s]/g;
|
|
||||||
|
|
||||||
// String values mirror the wire protocol; do not rename the right-hand side.
|
// String values mirror the wire protocol; do not rename the right-hand side.
|
||||||
export enum ECheatType {
|
export enum ECheatType {
|
||||||
@@ -109,6 +108,7 @@ export interface TrainerSummary {
|
|||||||
export interface TrainerMetaPayload {
|
export interface TrainerMetaPayload {
|
||||||
session: {
|
session: {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
|
accessToken?: string;
|
||||||
};
|
};
|
||||||
trainer: TrainerSummary;
|
trainer: TrainerSummary;
|
||||||
schema: {
|
schema: {
|
||||||
@@ -237,50 +237,3 @@ export type IncomingMessage =
|
|||||||
|
|
||||||
export type OutgoingMessage = HelloMessage | SetValueMessage | RemoteCommandMessage;
|
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 { Icon, type IconName } from '@/shared/ui/Icon';
|
||||||
import { EConnectionStatus } from '../state';
|
|
||||||
|
import { cn } from '@/shared/lib/ui';
|
||||||
|
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
|
||||||
|
|
||||||
type FloatingDockProps = {
|
type FloatingDockProps = {
|
||||||
status: EConnectionStatus;
|
status: EConnectionStatus;
|
||||||
@@ -22,18 +25,19 @@ export const FloatingDock = ({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onOpenLibrary,
|
onOpenLibrary,
|
||||||
}: FloatingDockProps) => {
|
}: FloatingDockProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const live = status === EConnectionStatus.Connected;
|
const live = status === EConnectionStatus.Connected;
|
||||||
|
|
||||||
return (
|
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')}>
|
<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">
|
<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={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)">
|
<span className="max-w-30 truncate text-[11px] font-semibold text-(--deck-fg-2)">
|
||||||
{runningGameTitle || 'No session'}
|
{runningGameTitle || _(msg`No session`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<DockButton badge={rightHasBadge} icon="list" label="Library" onClick={onOpenLibrary} />
|
<DockButton badge={rightHasBadge} icon="list" label={_(msg`Library`)} onClick={onOpenLibrary} />
|
||||||
</div>
|
</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 = {
|
type PlaceholderStateProps = {
|
||||||
icon: IconName;
|
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 { 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 { Icon } from '@/shared/ui/Icon';
|
||||||
import { cn } from '@/lib/utils';
|
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';
|
import { StatusPill } from './StatusPill';
|
||||||
|
|
||||||
const ACCENT_OPTIONS = [
|
const ACCENT_OPTIONS: { value: string; label: MessageDescriptor; swatchClass: string }[] = [
|
||||||
{ value: '#3B82F6', label: 'Cobalt', swatchClass: 'bg-[#3B82F6]' },
|
{ value: '#3B82F6', label: msg`Cobalt`, swatchClass: 'bg-[#3B82F6]' },
|
||||||
{ value: DEFAULT_ACCENT_COLOR, label: 'Cyan', swatchClass: 'bg-[#00FFD5]' },
|
{ value: DEFAULT_ACCENT_COLOR, label: msg`Cyan`, swatchClass: 'bg-[#00FFD5]' },
|
||||||
{ value: '#FF2E63', label: 'Crimson', swatchClass: 'bg-[#FF2E63]' },
|
{ value: '#FF2E63', label: msg`Crimson`, swatchClass: 'bg-[#FF2E63]' },
|
||||||
{ value: '#A78BFA', label: 'Violet', swatchClass: 'bg-[#A78BFA]' },
|
{ value: '#A78BFA', label: msg`Violet`, swatchClass: 'bg-[#A78BFA]' },
|
||||||
{ value: '#7CFF5B', label: 'Lime', swatchClass: 'bg-[#7CFF5B]' },
|
{ value: '#7CFF5B', label: msg`Lime`, swatchClass: 'bg-[#7CFF5B]' },
|
||||||
{ value: '#FFB12E', label: 'Amber', swatchClass: 'bg-[#FFB12E]' },
|
{ value: '#FFB12E', label: msg`Amber`, swatchClass: 'bg-[#FFB12E]' },
|
||||||
{ value: '#ee00ff', label: 'Magenta', swatchClass: 'bg-[#ee00ff]' },
|
{ value: '#ee00ff', label: msg`Magenta`, swatchClass: 'bg-[#ee00ff]' },
|
||||||
];
|
];
|
||||||
|
|
||||||
type SettingsDrawerProps = {
|
type SettingsDrawerProps = {
|
||||||
@@ -43,14 +47,20 @@ export const SettingsDrawer = ({
|
|||||||
onDisconnect,
|
onDisconnect,
|
||||||
onWsUrlChange,
|
onWsUrlChange,
|
||||||
}: SettingsDrawerProps) => {
|
}: SettingsDrawerProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<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">
|
<header className="remote-glass-header flex items-center justify-between border-b px-3.5 py-3.5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-(--deck-fg)">Settings</h2>
|
<h2 className="text-lg font-bold text-(--deck-fg)">
|
||||||
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">wand remote · port {DEFAULT_REMOTE_PORT}</p>
|
<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>
|
</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" />
|
<Icon className="size-4" name="x" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -58,10 +68,10 @@ export const SettingsDrawer = ({
|
|||||||
<BridgeControl status={status} wsUrl={wsUrl} onConnect={onConnect} onDisconnect={onDisconnect} onWsUrlChange={onWsUrlChange} />
|
<BridgeControl status={status} wsUrl={wsUrl} onConnect={onConnect} onDisconnect={onDisconnect} onWsUrlChange={onWsUrlChange} />
|
||||||
{lastError ? <ErrorPanel message={lastError} /> : null}
|
{lastError ? <ErrorPanel message={lastError} /> : null}
|
||||||
|
|
||||||
<SectionHeader title="Session" />
|
<SectionHeader title={_(msg`Session`)} />
|
||||||
<SessionPanel currentGame={currentGame} currentTrainer={currentTrainer} />
|
<SessionPanel currentGame={currentGame} currentTrainer={currentTrainer} />
|
||||||
|
|
||||||
<SectionHeader title="Accent Color" />
|
<SectionHeader title={_(msg`Accent Color`)} />
|
||||||
<AccentPicker />
|
<AccentPicker />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,20 +87,24 @@ type BridgeControlProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }: BridgeControlProps) => {
|
const BridgeControl = ({ status, wsUrl, onConnect, onDisconnect, onWsUrlChange }: BridgeControlProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const live = status === EConnectionStatus.Connected;
|
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 handleInput = (event: FormEvent<HTMLInputElement>) => onWsUrlChange(event.currentTarget.value);
|
||||||
|
const buttonLabel = connecting ? '...' : _(live ? msg`STOP` : msg`GO`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<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} />
|
<StatusPill status={status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="remote-glass-control flex h-10 items-stretch overflow-hidden rounded-[10px] border">
|
<div className="remote-glass-control flex h-10 items-stretch overflow-hidden rounded-[10px] border">
|
||||||
<input
|
<input
|
||||||
value={wsUrl}
|
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}
|
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)"
|
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}
|
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')}
|
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}
|
onClick={live ? onDisconnect : onConnect}
|
||||||
>
|
>
|
||||||
{getBridgeButtonLabel(status)}
|
{buttonLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -119,7 +133,11 @@ const ErrorPanel = ({ message }: { message: string }) => {
|
|||||||
|
|
||||||
const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGame | null; currentTrainer: TrainerSummary | null }) => {
|
const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGame | null; currentTrainer: TrainerSummary | null }) => {
|
||||||
if (!currentGame) {
|
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;
|
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="remote-glass-control rounded-[10px] border p-3">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<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="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>
|
</div>
|
||||||
<h3 className="truncate text-sm font-semibold text-(--deck-fg)">{currentGame.title}</h3>
|
<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)">
|
<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 AccentPicker = () => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const [current, setCurrent] = useState(loadAccentColor);
|
const [current, setCurrent] = useState(loadAccentColor);
|
||||||
|
|
||||||
const applyAccent = (value: string) => {
|
const applyAccent = (value: string) => {
|
||||||
@@ -155,13 +176,15 @@ const AccentPicker = () => {
|
|||||||
return (
|
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)}>
|
<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)} />
|
<span className={cn('size-3.5 shrink-0 rounded-lg border border-white/10', option.swatchClass)} />
|
||||||
{option.label}
|
{_(option.label)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<label className="remote-glass-control flex h-9.5 items-center gap-2 rounded-[9px] border px-2.5">
|
<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>
|
<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)} />
|
<input type="color" value={current} className="size-5 rounded border-0 bg-transparent p-0" onChange={(event) => applyAccent(event.currentTarget.value)} />
|
||||||
</label>
|
</label>
|
||||||
@@ -177,15 +200,3 @@ const SectionHeader = ({ title }: { title: string }) => {
|
|||||||
</div>
|
</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 { msg } from '@lingui/core/macro';
|
||||||
import { cn } from '@/lib/utils';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { EConnectionStatus } from '../state';
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<EConnectionStatus, string> = {
|
import { Icon } from '@/shared/ui/Icon';
|
||||||
[EConnectionStatus.Connected]: 'LIVE',
|
import { cn } from '@/shared/lib/ui';
|
||||||
[EConnectionStatus.Connecting]: 'LINKING',
|
import { EConnectionStatus } from '@/remote-session/remote-session.reducer';
|
||||||
[EConnectionStatus.Error]: 'OFFLINE',
|
|
||||||
[EConnectionStatus.Idle]: 'OFFLINE',
|
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> = {
|
const STATUS_CLASSES: Record<EConnectionStatus, string> = {
|
||||||
[EConnectionStatus.Connected]: 'border-[color-mix(in_oklab,var(--deck-accent)_30%,transparent)] text-(--deck-accent)',
|
[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.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.Error]: 'border-white/10 text-(--deck-fg-4)',
|
||||||
[EConnectionStatus.Idle]: 'border-white/10 text-(--deck-fg-4)',
|
[EConnectionStatus.Idle]: 'border-white/10 text-(--deck-fg-4)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StatusPill = ({ status }: { status: EConnectionStatus }) => {
|
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 (
|
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])}>
|
<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}
|
{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}
|
{status === EConnectionStatus.Error ? <Icon className="size-3" name="alert" /> : null}
|
||||||
</div>
|
</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 { Icon } from '@/shared/ui/Icon';
|
||||||
import type { TrainerSummary } from '../protocol';
|
|
||||||
import type { EConnectionStatus } from '../state';
|
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';
|
import { StatusPill } from './StatusPill';
|
||||||
|
|
||||||
type TopBarProps = {
|
type TopBarProps = {
|
||||||
@@ -13,17 +17,19 @@ type TopBarProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TopBar = ({ status, currentGame, runningTrainer, onOpenSettings }: TopBarProps) => {
|
export const TopBar = ({ status, currentGame, runningTrainer, onOpenSettings }: TopBarProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="remote-glass-header sticky top-0 z-20 border-b px-3.5 pb-2.5 pt-3">
|
<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">
|
<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" />
|
<Icon className="size-[18px]" name="menu" />
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<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="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">
|
<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)">
|
<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>
|
</span>
|
||||||
{currentGame && runningTrainer?.gameVersion ? (
|
{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)">
|
<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';
|
export const DEFAULT_ACCENT_COLOR = '#00ffd5';
|
||||||
|
|
||||||
@@ -39,4 +39,4 @@ function normalizeAccentColor(value: unknown): string | null {
|
|||||||
|
|
||||||
const normalizedValue = value.trim().toLowerCase();
|
const normalizedValue = value.trim().toLowerCase();
|
||||||
return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null;
|
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 { formatHumanLabel } from '@/shared/lib/ui';
|
||||||
import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from './protocol';
|
import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from '../../../protocol/messages';
|
||||||
|
|
||||||
export type LibraryGame = {
|
export type LibraryGame = {
|
||||||
id: string;
|
id: string;
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { loadStringSet, saveStringSet } from './storage';
|
import { loadStringSet, saveStringSet } from '../../shared/storage';
|
||||||
import type { LibraryGame } from './game-library';
|
import type { LibraryGame } from '../model/games';
|
||||||
|
|
||||||
const STORAGE_KEY = 'wand-remote.pinned-games.v1';
|
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 { useState } from 'react';
|
||||||
|
|
||||||
import { getGameCoverLabel, type LibraryGame } from '../game-library';
|
import { getGameCoverLabel, type LibraryGame } from '../model/games';
|
||||||
|
|
||||||
type GameCoverProps = {
|
type GameCoverProps = {
|
||||||
game: LibraryGame;
|
game: LibraryGame;
|
||||||
+27
-15
@@ -1,11 +1,15 @@
|
|||||||
import { memo, useMemo, type ReactNode } from 'react';
|
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 { SearchInput } from '@/shared/ui/SearchInput';
|
||||||
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../game-library';
|
|
||||||
|
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../model/games';
|
||||||
import { GameCover } from './GameCover';
|
import { GameCover } from './GameCover';
|
||||||
import { SearchInput } from './SearchInput';
|
|
||||||
|
|
||||||
type LibraryDrawerProps = {
|
type LibraryDrawerProps = {
|
||||||
games: LibraryGame[];
|
games: LibraryGame[];
|
||||||
@@ -19,6 +23,7 @@ type LibraryDrawerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => {
|
const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const filteredGames = useMemo(() => filterLibraryGames(games, query), [games, query]);
|
const filteredGames = useMemo(() => filterLibraryGames(games, query), [games, query]);
|
||||||
const sections = useMemo(() => getLibrarySections(filteredGames), [filteredGames]);
|
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">
|
<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">
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="text-lg font-bold text-(--deck-fg)">Library</h2>
|
<h2 className="text-lg font-bold text-(--deck-fg)">
|
||||||
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">{games.length} games detected</p>
|
<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>
|
</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" />
|
<Icon className="size-4" name="x" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="border-b border-white/6 px-3.5 py-2.5">
|
<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>
|
||||||
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain pb-6">
|
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain pb-6">
|
||||||
{sections.running ? (
|
{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} />
|
<GameRow game={sections.running} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />
|
||||||
</GameSection>
|
</GameSection>
|
||||||
) : null}
|
) : null}
|
||||||
{sections.pinned.length > 0 ? (
|
{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} />)}
|
{sections.pinned.map((game) => <GameRow key={game.id} game={game} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />)}
|
||||||
</GameSection>
|
</GameSection>
|
||||||
) : null}
|
) : null}
|
||||||
{sections.rest.length > 0 ? (
|
{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} />)}
|
{sections.rest.map((game) => <GameRow key={game.id} game={game} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />)}
|
||||||
</GameSection>
|
</GameSection>
|
||||||
) : null}
|
) : null}
|
||||||
{filteredGames.length === 0 ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,6 +105,7 @@ type GameRowProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps) => {
|
const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const hours = formatHours(game.hours);
|
const hours = formatHours(game.hours);
|
||||||
const handlePin = () => onPin(game);
|
const handlePin = () => onPin(game);
|
||||||
const handlePlay = () => onPlay(game);
|
const handlePlay = () => onPlay(game);
|
||||||
@@ -110,11 +122,11 @@ const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<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 ? (
|
{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>
|
</div>
|
||||||
</article>
|
</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;
|
type Reviver<T> = (raw: unknown) => T | null;
|
||||||
|
|
||||||
+5
-2
@@ -1,6 +1,8 @@
|
|||||||
import type { ReactNode } from 'react';
|
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 = {
|
const DRAWER_SIDE_CLASSES = {
|
||||||
left: 'left-0 border-r',
|
left: 'left-0 border-r',
|
||||||
@@ -23,6 +25,7 @@ type DrawerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Drawer = ({ open, side, children, onClose }: DrawerProps) => {
|
export const Drawer = ({ open, side, children, onClose }: DrawerProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const sideClassName = DRAWER_SIDE_CLASSES[side];
|
const sideClassName = DRAWER_SIDE_CLASSES[side];
|
||||||
const closedClassName = DRAWER_CLOSED_CLASSES[side];
|
const closedClassName = DRAWER_CLOSED_CLASSES[side];
|
||||||
|
|
||||||
@@ -30,7 +33,7 @@ export const Drawer = ({ open, side, children, onClose }: DrawerProps) => {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="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')}
|
className={cn(DRAWER_OVERLAY_CLASS, open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
+6
-3
@@ -1,8 +1,10 @@
|
|||||||
import type { FormEvent } from 'react';
|
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 = {
|
type SearchInputProps = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -12,6 +14,7 @@ type SearchInputProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SearchInput = ({ value, placeholder, className, onChange }: SearchInputProps) => {
|
export const SearchInput = ({ value, placeholder, className, onChange }: SearchInputProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const handleInput = (event: FormEvent<HTMLInputElement>) => onChange(event.currentTarget.value);
|
const handleInput = (event: FormEvent<HTMLInputElement>) => onChange(event.currentTarget.value);
|
||||||
const handleClear = () => onChange('');
|
const handleClear = () => onChange('');
|
||||||
|
|
||||||
@@ -26,7 +29,7 @@ export const SearchInput = ({ value, placeholder, className, onChange }: SearchI
|
|||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
/>
|
/>
|
||||||
{value ? (
|
{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" />
|
<Icon className="size-3.5" name="x" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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';
|
import type { ControlInternalProps } from './shared';
|
||||||
|
|
||||||
export const ActionButton = ({ cheat, disabled, onChange }: ControlInternalProps) => {
|
export const ActionButton = ({ cheat, disabled, onChange }: ControlInternalProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const handleClick = () => onChange(1);
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { type ReactElement } from 'react';
|
import { type ReactElement } from 'react';
|
||||||
|
|
||||||
import type { CheatSchema } from '../protocol';
|
import type { CheatSchema } from '../../../protocol/messages';
|
||||||
import { ECheatType } from '../protocol';
|
import { ECheatType } from '../../../protocol/messages';
|
||||||
import { ActionButton } from './ActionButton';
|
import { ActionButton } from './ActionButton';
|
||||||
import { IncrementalControl } from './IncrementalControl';
|
import { IncrementalControl } from './IncrementalControl';
|
||||||
import { NumberControl } from './NumberControl';
|
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 { ActionButton } from './ActionButton';
|
||||||
import { StepButton, type ControlInternalProps } from './shared';
|
import { StepButton, type ControlInternalProps } from './shared';
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { type FormEvent } from 'react';
|
import { type FormEvent } from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/shared/lib/ui';
|
||||||
|
|
||||||
import { formatInputNumber, numericValue, stripNumberGrouping } from './format-number';
|
import { formatInputNumber, numericValue, stripNumberGrouping } from './format-number';
|
||||||
import { StepButton, type ControlInternalProps } from './shared';
|
import { StepButton, type ControlInternalProps } from './shared';
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { type FormEvent } from 'react';
|
import { type FormEvent } from 'react';
|
||||||
|
|
||||||
import type { CheatSchema } from '../protocol';
|
import type { CheatSchema } from '../../../protocol/messages';
|
||||||
import { resolveOption } from '../protocol';
|
import { resolveOption } from '../model/values';
|
||||||
import { formatNumber, numericValue } from './format-number';
|
import { formatNumber, numericValue } from './format-number';
|
||||||
import { SliderTrack, type ControlInternalProps } from './shared';
|
import { SliderTrack, type ControlInternalProps } from './shared';
|
||||||
|
|
||||||
+6
-5
@@ -1,17 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
import { Icon } from '@/components/ui/icon';
|
import { Icon } from '@/shared/ui/Icon';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/shared/lib/ui';
|
||||||
|
|
||||||
import type { CheatOption } from '../protocol';
|
import type { CheatOption } from '../../../protocol/messages';
|
||||||
import { resolveOption } from '../protocol';
|
import { resolveOption } from '../model/values';
|
||||||
import type { ControlInternalProps } from './shared';
|
import type { ControlInternalProps } from './shared';
|
||||||
|
|
||||||
export const SelectionControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => {
|
export const SelectionControl = ({ cheat, value, disabled, onChange }: ControlInternalProps) => {
|
||||||
const options = (cheat.args.options ?? []).map(resolveOption);
|
const options = (cheat.args.options ?? []).map(resolveOption);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
if (options.length === 0) {
|
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];
|
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';
|
import type { ControlInternalProps } from './shared';
|
||||||
|
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
import { type FormEvent } from 'react';
|
import { type FormEvent } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@/components/ui/icon';
|
import { Icon } from '@/shared/ui/Icon';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/shared/lib/ui';
|
||||||
|
|
||||||
import type { CheatSchema } from '../protocol';
|
import type { CheatSchema } from '../../../protocol/messages';
|
||||||
|
|
||||||
export type ControlInternalProps = {
|
export type ControlInternalProps = {
|
||||||
cheat: CheatSchema;
|
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 '@/shared/lib/ui';
|
||||||
import { formatHumanLabel } from '@/lib/utils';
|
import type { CheatSchema, TrainerMetaPayload, TrainerSummary } from '../../../protocol/messages';
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CategoryGroup = {
|
export type CategoryGroup = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -48,10 +7,6 @@ export type CategoryGroup = {
|
|||||||
cheats: CheatSchema[];
|
cheats: CheatSchema[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatCategoryName(category: string): string {
|
|
||||||
return CATEGORY_LABELS[category.toLowerCase()] ?? formatHumanLabel(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): CategoryGroup[] {
|
export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): CategoryGroup[] {
|
||||||
if (!trainerMeta) {
|
if (!trainerMeta) {
|
||||||
return [];
|
return [];
|
||||||
@@ -65,7 +20,7 @@ export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): C
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(grouped.entries())
|
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));
|
.sort((left, right) => left.label.localeCompare(right.label));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +41,7 @@ export function buildPinnedGroup(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: PINNED_CATEGORY_ID,
|
id: PINNED_CATEGORY_ID,
|
||||||
label: formatCategoryName(PINNED_CATEGORY_ID),
|
label: formatHumanLabel(PINNED_CATEGORY_ID),
|
||||||
cheats: pinnedCheats,
|
cheats: pinnedCheats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -125,6 +80,3 @@ export function getTrainerDisplayName(trainer: TrainerSummary): string {
|
|||||||
return trainer.displayName?.trim() || trainer.gameId || trainer.titleId || trainer.trainerId;
|
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/messages';
|
||||||
import type { TrainerSummary } from './protocol';
|
import { getTrainerStorageId, loadStringSet, saveStringSet } from '../../shared/storage';
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'wand-remote.pinned-cheats.v1:';
|
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 { ECheatType, type CheatSchema, type TrainerSummary } from '../../../protocol/messages';
|
||||||
import type { CheatSchema, TrainerSummary } from './protocol';
|
import { getTrainerStorageId, loadJson, saveJson } from '../../shared/storage';
|
||||||
import { ECheatType } from './protocol';
|
|
||||||
|
|
||||||
export type RemotePreset = {
|
export type RemotePreset = {
|
||||||
id: string;
|
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 { 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 type { CategoryGroup } from '../model/categories';
|
||||||
import { CategoryIcon, type CategoryGroup } from '../category';
|
import { CategoryIcon } from './CategoryIcon';
|
||||||
import type { CheatSchema } from '../protocol';
|
import { CATEGORY_LABELS } from './category-labels';
|
||||||
import { ECheatType } from '../protocol';
|
import type { CheatSchema } from '../../../protocol/messages';
|
||||||
|
import { ECheatType } from '../../../protocol/messages';
|
||||||
import { CheatTile } from './CheatTile';
|
import { CheatTile } from './CheatTile';
|
||||||
|
|
||||||
type CategorySectionProps = {
|
type CategorySectionProps = {
|
||||||
@@ -31,11 +35,18 @@ const CategorySectionBase = ({
|
|||||||
onCheatChange,
|
onCheatChange,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
}: CategorySectionProps) => {
|
}: CategorySectionProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
const [open, setOpen] = useState(openByDefault);
|
const [open, setOpen] = useState(openByDefault);
|
||||||
const enabledCount = useMemo(() => getEnabledToggleCount(group.cheats, values), [group.cheats, values]);
|
const enabledCount = useMemo(() => getEnabledToggleCount(group.cheats, values), [group.cheats, values]);
|
||||||
const toggleCount = useMemo(() => getToggleCount(group.cheats), [group.cheats]);
|
const toggleCount = useMemo(() => getToggleCount(group.cheats), [group.cheats]);
|
||||||
const handleToggle = () => setOpen((current) => !current);
|
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(
|
const cheatHandlers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
group.cheats.map((cheat) => ({
|
group.cheats.map((cheat) => ({
|
||||||
@@ -58,8 +69,8 @@ const CategorySectionBase = ({
|
|||||||
<CategoryIcon category={group.id} className="size-3.75" />
|
<CategoryIcon category={group.id} className="size-3.75" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-semibold">{group.label}</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)">{formatSummary(group.cheats.length, enabledCount, toggleCount)}</span>
|
<span className="mt-0.5 block font-mono text-[10.5px] text-(--deck-fg-4)">{summary}</span>
|
||||||
</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}
|
{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" />
|
<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 {
|
function getToggleCount(cheats: CheatSchema[]): number {
|
||||||
return cheats.filter((cheat) => cheat.type === ECheatType.Toggle).length;
|
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 { memo, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@/components/ui/icon';
|
import { Icon } from '@/shared/ui/Icon';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/shared/lib/ui';
|
||||||
|
|
||||||
import type { CheatSchema } from '../protocol';
|
import type { CheatSchema } from '../../../protocol/messages';
|
||||||
import { ECheatType } from '../protocol';
|
import { ECheatType } from '../../../protocol/messages';
|
||||||
import { CheatControl } from '../controls/CheatControl';
|
import { CheatControl } from '../controls/CheatControl';
|
||||||
|
|
||||||
type CheatTileProps = {
|
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