diff --git a/web-panel/CLAUDE.md b/web-panel/CLAUDE.md new file mode 100644 index 0000000..409c03b --- /dev/null +++ b/web-panel/CLAUDE.md @@ -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/ + / + 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 + + +``` + +Over: + +```tsx + + - {getBridgeButtonLabel(status)} + {buttonLabel} @@ -119,7 +133,11 @@ const ErrorPanel = ({ message }: { message: string }) => { const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGame | null; currentTrainer: TrainerSummary | null }) => { if (!currentGame) { - return
No active game session.
; + return ( +
+ No active game session. +
+ ); } const subtitleBase = currentTrainer?.displayName ?? currentGame.platform; @@ -130,7 +148,9 @@ const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGam
- Active Session + + Active Session +

{currentGame.title}

@@ -141,6 +161,7 @@ const SessionPanel = ({ currentGame, currentTrainer }: { currentGame: LibraryGam }; const AccentPicker = () => { + const { _ } = useLingui(); const [current, setCurrent] = useState(loadAccentColor); const applyAccent = (value: string) => { @@ -155,13 +176,15 @@ const AccentPicker = () => { return ( ); })}

@@ -177,15 +200,3 @@ const SectionHeader = ({ title }: { title: string }) => { ); }; - -function getBridgeButtonLabel(status: EConnectionStatus): string { - if (status === EConnectionStatus.Connected) { - return 'STOP'; - } - - if (status === EConnectionStatus.Connecting) { - return '...'; - } - - return 'GO'; -} diff --git a/web-panel/src/features/remote-panel/components/StatusPill.tsx b/web-panel/src/app/ui/StatusPill.tsx similarity index 52% rename from web-panel/src/features/remote-panel/components/StatusPill.tsx rename to web-panel/src/app/ui/StatusPill.tsx index bc3ab47..e626570 100644 --- a/web-panel/src/features/remote-panel/components/StatusPill.tsx +++ b/web-panel/src/app/ui/StatusPill.tsx @@ -1,28 +1,37 @@ -import { Icon } from '@/components/ui/icon'; -import { cn } from '@/lib/utils'; -import { EConnectionStatus } from '../state'; +import { msg } from '@lingui/core/macro'; +import type { MessageDescriptor } from '@lingui/core'; +import { useLingui } from '@lingui/react'; -const STATUS_LABELS: Record = { - [EConnectionStatus.Connected]: 'LIVE', - [EConnectionStatus.Connecting]: 'LINKING', - [EConnectionStatus.Error]: 'OFFLINE', - [EConnectionStatus.Idle]: 'OFFLINE', +import { Icon } from '@/shared/ui/Icon'; +import { cn } from '@/shared/lib/ui'; +import { EConnectionStatus } from '@/remote-session/remote-session.reducer'; + +const STATUS_LABELS: Record = { + [EConnectionStatus.Connected]: msg`LIVE`, + [EConnectionStatus.Connecting]: msg`LINKING`, + [EConnectionStatus.Reconnecting]: msg`LINKING`, + [EConnectionStatus.Error]: msg`OFFLINE`, + [EConnectionStatus.Idle]: msg`OFFLINE`, }; const STATUS_CLASSES: Record = { [EConnectionStatus.Connected]: 'border-[color-mix(in_oklab,var(--deck-accent)_30%,transparent)] text-(--deck-accent)', [EConnectionStatus.Connecting]: 'border-amber-300/30 text-amber-300', + [EConnectionStatus.Reconnecting]: 'border-amber-300/30 text-amber-300', [EConnectionStatus.Error]: 'border-white/10 text-(--deck-fg-4)', [EConnectionStatus.Idle]: 'border-white/10 text-(--deck-fg-4)', }; export const StatusPill = ({ status }: { status: EConnectionStatus }) => { - const live = status === EConnectionStatus.Connected || status === EConnectionStatus.Connecting; + const { _ } = useLingui(); + const live = status === EConnectionStatus.Connected + || status === EConnectionStatus.Connecting + || status === EConnectionStatus.Reconnecting; return (
{live ? : null} - {STATUS_LABELS[status]} + {_(STATUS_LABELS[status])} {status === EConnectionStatus.Error ? : null}
); diff --git a/web-panel/src/features/remote-panel/components/TopBar.tsx b/web-panel/src/app/ui/TopBar.tsx similarity index 64% rename from web-panel/src/features/remote-panel/components/TopBar.tsx rename to web-panel/src/app/ui/TopBar.tsx index 342ca06..105fbbb 100644 --- a/web-panel/src/features/remote-panel/components/TopBar.tsx +++ b/web-panel/src/app/ui/TopBar.tsx @@ -1,8 +1,12 @@ -import { Icon } from '@/components/ui/icon'; +import { msg } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { useLingui } from '@lingui/react'; -import type { LibraryGame } from '../game-library'; -import type { TrainerSummary } from '../protocol'; -import type { EConnectionStatus } from '../state'; +import { Icon } from '@/shared/ui/Icon'; + +import type { LibraryGame } from '@/library/model/games'; +import type { TrainerSummary } from '../../../protocol/messages'; +import type { EConnectionStatus } from '@/remote-session/remote-session.reducer'; import { StatusPill } from './StatusPill'; type TopBarProps = { @@ -13,17 +17,19 @@ type TopBarProps = { }; export const TopBar = ({ status, currentGame, runningTrainer, onOpenSettings }: TopBarProps) => { + const { _ } = useLingui(); + return (
-
WAND · REMOTE DECK
- {currentGame ? currentGame.title : 'Idle · no game'} + {currentGame ? currentGame.title : Idle · no game} {currentGame && runningTrainer?.gameVersion ? ( diff --git a/web-panel/src/app/ui/session-states.test.tsx b/web-panel/src/app/ui/session-states.test.tsx new file mode 100644 index 0000000..e09350f --- /dev/null +++ b/web-panel/src/app/ui/session-states.test.tsx @@ -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({ui}); + +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( 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( undefined} onOpenSettings={() => undefined} />); + expect(screen.getByText('Select a game')).toBeTruthy(); + }); + + it('renders an active trainer header', () => { + renderWithI18n( undefined} />); + expect(screen.getByText('Test Trainer')).toBeTruthy(); + expect(screen.getByText('Trainer Active')).toBeTruthy(); + }); +}); diff --git a/web-panel/src/app/use-dock-auto-hide.ts b/web-panel/src/app/use-dock-auto-hide.ts new file mode 100644 index 0000000..6673037 --- /dev/null +++ b/web-panel/src/app/use-dock-auto-hide.ts @@ -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) => { + 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 }; +} diff --git a/web-panel/src/app/use-remote-panel.ts b/web-panel/src/app/use-remote-panel.ts new file mode 100644 index 0000000..f1df464 --- /dev/null +++ b/web-panel/src/app/use-remote-panel.ts @@ -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, + }, + }; +} diff --git a/web-panel/src/features/remote-panel/accent-storage.ts b/web-panel/src/appearance/appearance-storage.ts similarity index 95% rename from web-panel/src/features/remote-panel/accent-storage.ts rename to web-panel/src/appearance/appearance-storage.ts index d38172e..1c7f49f 100644 --- a/web-panel/src/features/remote-panel/accent-storage.ts +++ b/web-panel/src/appearance/appearance-storage.ts @@ -1,4 +1,4 @@ -import { loadJson, saveJson } from './storage'; +import { loadJson, saveJson } from '../shared/storage'; export const DEFAULT_ACCENT_COLOR = '#00ffd5'; @@ -39,4 +39,4 @@ function normalizeAccentColor(value: unknown): string | null { const normalizedValue = value.trim().toLowerCase(); return HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : null; -} \ No newline at end of file +} diff --git a/web-panel/src/features/remote-panel/constants.ts b/web-panel/src/features/remote-panel/constants.ts deleted file mode 100644 index 173d0d1..0000000 --- a/web-panel/src/features/remote-panel/constants.ts +++ /dev/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}`; -} diff --git a/web-panel/src/features/remote-panel/message-handler.ts b/web-panel/src/features/remote-panel/message-handler.ts deleted file mode 100644 index b88ce2e..0000000 --- a/web-panel/src/features/remote-panel/message-handler.ts +++ /dev/null @@ -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, 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 }); -} diff --git a/web-panel/src/features/remote-panel/socket-client.ts b/web-panel/src/features/remote-panel/socket-client.ts deleted file mode 100644 index b497edc..0000000 --- a/web-panel/src/features/remote-panel/socket-client.ts +++ /dev/null @@ -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.'); - } - } -} diff --git a/web-panel/src/features/remote-panel/state.ts b/web-panel/src/features/remote-panel/state.ts deleted file mode 100644 index e0b88fb..0000000 --- a/web-panel/src/features/remote-panel/state.ts +++ /dev/null @@ -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; - pendingTargets: Record; - pinnedTargets: Record; - 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 } - | { type: 'valueChanged'; target: string; value: unknown } - | { type: 'setPending'; target: string; pending: boolean } - | { type: 'trainerChanged' } - | { type: 'setPinnedTargets'; pinned: Record } - | { 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, - }; - } -} diff --git a/web-panel/src/library/model/games.test.ts b/web-panel/src/library/model/games.test.ts new file mode 100644 index 0000000..d4bd3d7 --- /dev/null +++ b/web-panel/src/library/model/games.test.ts @@ -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({}); + }); +}); diff --git a/web-panel/src/features/remote-panel/game-library.ts b/web-panel/src/library/model/games.ts similarity index 97% rename from web-panel/src/features/remote-panel/game-library.ts rename to web-panel/src/library/model/games.ts index c435749..744aa12 100644 --- a/web-panel/src/features/remote-panel/game-library.ts +++ b/web-panel/src/library/model/games.ts @@ -1,5 +1,5 @@ -import { formatHumanLabel } from '@/lib/utils'; -import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from './protocol'; +import { formatHumanLabel } from '@/shared/lib/ui'; +import type { GameStatusPayload, InstalledAppSummary, TrainerSummary } from '../../../protocol/messages'; export type LibraryGame = { id: string; diff --git a/web-panel/src/features/remote-panel/game-pin-storage.ts b/web-panel/src/library/pinned-games/game-pin-storage.ts similarity index 81% rename from web-panel/src/features/remote-panel/game-pin-storage.ts rename to web-panel/src/library/pinned-games/game-pin-storage.ts index 1b0b34f..6875412 100644 --- a/web-panel/src/features/remote-panel/game-pin-storage.ts +++ b/web-panel/src/library/pinned-games/game-pin-storage.ts @@ -1,5 +1,5 @@ -import { loadStringSet, saveStringSet } from './storage'; -import type { LibraryGame } from './game-library'; +import { loadStringSet, saveStringSet } from '../../shared/storage'; +import type { LibraryGame } from '../model/games'; const STORAGE_KEY = 'wand-remote.pinned-games.v1'; diff --git a/web-panel/src/library/pinned-games/use-game-pins.ts b/web-panel/src/library/pinned-games/use-game-pins.ts new file mode 100644 index 0000000..a881528 --- /dev/null +++ b/web-panel/src/library/pinned-games/use-game-pins.ts @@ -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>({}); + + useEffect(() => { + setPinnedGameIds(loadPinnedGameIds()); + }, []); + + const togglePin = useCallback((game: LibraryGame) => { + setPinnedGameIds((current) => { + const next = togglePinnedGame(game, current); + savePinnedGameIds(next); + return next; + }); + }, []); + + return { pinnedGameIds, togglePin }; +} diff --git a/web-panel/src/features/remote-panel/components/GameCover.tsx b/web-panel/src/library/ui/GameCover.tsx similarity index 95% rename from web-panel/src/features/remote-panel/components/GameCover.tsx rename to web-panel/src/library/ui/GameCover.tsx index e4eeb6f..0e315dc 100644 --- a/web-panel/src/features/remote-panel/components/GameCover.tsx +++ b/web-panel/src/library/ui/GameCover.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { getGameCoverLabel, type LibraryGame } from '../game-library'; +import { getGameCoverLabel, type LibraryGame } from '../model/games'; type GameCoverProps = { game: LibraryGame; diff --git a/web-panel/src/features/remote-panel/components/LibraryDrawer.tsx b/web-panel/src/library/ui/LibraryDrawer.tsx similarity index 80% rename from web-panel/src/features/remote-panel/components/LibraryDrawer.tsx rename to web-panel/src/library/ui/LibraryDrawer.tsx index 9371418..4f7d3f9 100644 --- a/web-panel/src/features/remote-panel/components/LibraryDrawer.tsx +++ b/web-panel/src/library/ui/LibraryDrawer.tsx @@ -1,11 +1,15 @@ import { memo, useMemo, type ReactNode } from 'react'; +import { msg } from '@lingui/core/macro'; +import { Plural, Trans } from '@lingui/react/macro'; +import { useLingui } from '@lingui/react'; -import { Icon, type IconName } from '@/components/ui/icon'; +import { Icon, type IconName } from '@/shared/ui/Icon'; +import { cn } from '@/shared/lib/ui'; -import { cn } from '@/lib/utils'; -import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../game-library'; +import { SearchInput } from '@/shared/ui/SearchInput'; + +import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../model/games'; import { GameCover } from './GameCover'; -import { SearchInput } from './SearchInput'; type LibraryDrawerProps = { games: LibraryGame[]; @@ -19,6 +23,7 @@ type LibraryDrawerProps = { }; const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => { + const { _ } = useLingui(); const filteredGames = useMemo(() => filterLibraryGames(games, query), [games, query]); const sections = useMemo(() => getLibrarySections(filteredGames), [filteredGames]); @@ -26,34 +31,40 @@ const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, on
-

Library

-

{games.length} games detected

+

+ Library +

+

+ +

-
- +
{sections.running ? ( - + ) : null} {sections.pinned.length > 0 ? ( - + {sections.pinned.map((game) => )} ) : null} {sections.rest.length > 0 ? ( - + {sections.rest.map((game) => )} ) : null} {filteredGames.length === 0 ? ( -

No games match "{query}"

+

+ No games match "{query}" +

) : null}
@@ -94,6 +105,7 @@ type GameRowProps = { }; const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps) => { + const { _ } = useLingui(); const hours = formatHours(game.hours); const handlePin = () => onPin(game); const handlePlay = () => onPlay(game); @@ -110,11 +122,11 @@ const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps
- + {game.running ? ( - + ) : ( - + )}
diff --git a/web-panel/src/locales/en/messages.js b/web-panel/src/locales/en/messages.js new file mode 100644 index 0000000..4ccdcfe --- /dev/null +++ b/web-panel/src/locales/en/messages.js @@ -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\"]}")}; \ No newline at end of file diff --git a/web-panel/src/locales/en/messages.po b/web-panel/src/locales/en/messages.po new file mode 100644 index 0000000..4bfe34f --- /dev/null +++ b/web-panel/src/locales/en/messages.po @@ -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" diff --git a/web-panel/src/main.tsx b/web-panel/src/main.tsx deleted file mode 100644 index 27935f6..0000000 --- a/web-panel/src/main.tsx +++ /dev/null @@ -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( - - - , -); diff --git a/web-panel/src/po.d.ts b/web-panel/src/po.d.ts new file mode 100644 index 0000000..c08a49d --- /dev/null +++ b/web-panel/src/po.d.ts @@ -0,0 +1,4 @@ +declare module '*.po' { + import type { Messages } from '@lingui/core'; + export const messages: Messages; +} diff --git a/web-panel/src/remote-session/remote-session.client.ts b/web-panel/src/remote-session/remote-session.client.ts new file mode 100644 index 0000000..3a2133d --- /dev/null +++ b/web-panel/src/remote-session/remote-session.client.ts @@ -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}`; +} diff --git a/web-panel/src/remote-session/remote-session.i18n.ts b/web-panel/src/remote-session/remote-session.i18n.ts new file mode 100644 index 0000000..acef5dc --- /dev/null +++ b/web-panel/src/remote-session/remote-session.i18n.ts @@ -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 }; +}; + +export async function localizeTrainerMeta(payload: TrainerMetaPayload): Promise { + 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 | 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): 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 | null { + if (!value) { + return value ?? null; + } + + return strings[value] ?? value; +} diff --git a/web-panel/src/remote-session/remote-session.protocol.ts b/web-panel/src/remote-session/remote-session.protocol.ts new file mode 100644 index 0000000..be3ba73 --- /dev/null +++ b/web-panel/src/remote-session/remote-session.protocol.ts @@ -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 }; + } +} diff --git a/web-panel/src/remote-session/remote-session.reducer.test.ts b/web-panel/src/remote-session/remote-session.reducer.test.ts new file mode 100644 index 0000000..c546892 --- /dev/null +++ b/web-panel/src/remote-session/remote-session.reducer.test.ts @@ -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 }, + }; +} diff --git a/web-panel/src/remote-session/remote-session.reducer.ts b/web-panel/src/remote-session/remote-session.reducer.ts new file mode 100644 index 0000000..eecd669 --- /dev/null +++ b/web-panel/src/remote-session/remote-session.reducer.ts @@ -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; + confirmedValues: Record; + pendingWrites: Record; + 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 } + | { 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, +): 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.', + }; +} diff --git a/web-panel/src/remote-session/remote-session.urls.ts b/web-panel/src/remote-session/remote-session.urls.ts new file mode 100644 index 0000000..6044bf4 --- /dev/null +++ b/web-panel/src/remote-session/remote-session.urls.ts @@ -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}`; +} diff --git a/web-panel/src/remote-session/selectors.ts b/web-panel/src/remote-session/selectors.ts new file mode 100644 index 0000000..798ad7d --- /dev/null +++ b/web-panel/src/remote-session/selectors.ts @@ -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 { + return Object.fromEntries(Object.keys(state.pendingWrites).map((target) => [target, true])); +} diff --git a/web-panel/src/remote-session/use-remote-session.ts b/web-panel/src/remote-session/use-remote-session.ts new file mode 100644 index 0000000..3281229 --- /dev/null +++ b/web-panel/src/remote-session/use-remote-session.ts @@ -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(null); + const reconnectTimeoutRef = useRef(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()); +} diff --git a/web-panel/src/lib/utils.ts b/web-panel/src/shared/lib/ui.ts similarity index 100% rename from web-panel/src/lib/utils.ts rename to web-panel/src/shared/lib/ui.ts diff --git a/web-panel/src/shared/storage.test.ts b/web-panel/src/shared/storage.test.ts new file mode 100644 index 0000000..4dd9362 --- /dev/null +++ b/web-panel/src/shared/storage.test.ts @@ -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(); + }); +}); diff --git a/web-panel/src/features/remote-panel/storage.ts b/web-panel/src/shared/storage.ts similarity index 96% rename from web-panel/src/features/remote-panel/storage.ts rename to web-panel/src/shared/storage.ts index 720b952..8ab4ecb 100644 --- a/web-panel/src/features/remote-panel/storage.ts +++ b/web-panel/src/shared/storage.ts @@ -1,4 +1,4 @@ -import type { TrainerSummary } from './protocol'; +import type { TrainerSummary } from '../../protocol/messages'; type Reviver = (raw: unknown) => T | null; diff --git a/web-panel/src/features/remote-panel/components/Drawer.tsx b/web-panel/src/shared/ui/Drawer.tsx similarity index 87% rename from web-panel/src/features/remote-panel/components/Drawer.tsx rename to web-panel/src/shared/ui/Drawer.tsx index 9108e4f..105e36d 100644 --- a/web-panel/src/features/remote-panel/components/Drawer.tsx +++ b/web-panel/src/shared/ui/Drawer.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from 'react'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; -import { cn } from '@/lib/utils'; +import { cn } from '@/shared/lib/ui'; const DRAWER_SIDE_CLASSES = { left: 'left-0 border-r', @@ -23,6 +25,7 @@ type DrawerProps = { }; export const Drawer = ({ open, side, children, onClose }: DrawerProps) => { + const { _ } = useLingui(); const sideClassName = DRAWER_SIDE_CLASSES[side]; const closedClassName = DRAWER_CLOSED_CLASSES[side]; @@ -30,7 +33,7 @@ export const Drawer = ({ open, side, children, onClose }: DrawerProps) => { <> ) : null} diff --git a/web-panel/src/features/remote-panel/controls/ActionButton.tsx b/web-panel/src/trainer/controls/ActionButton.tsx similarity index 79% rename from web-panel/src/features/remote-panel/controls/ActionButton.tsx rename to web-panel/src/trainer/controls/ActionButton.tsx index 3aeb52f..5790498 100644 --- a/web-panel/src/features/remote-panel/controls/ActionButton.tsx +++ b/web-panel/src/trainer/controls/ActionButton.tsx @@ -1,10 +1,14 @@ -import { Icon } from '@/components/ui/icon'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; + +import { Icon } from '@/shared/ui/Icon'; import type { ControlInternalProps } from './shared'; export const ActionButton = ({ cheat, disabled, onChange }: ControlInternalProps) => { + const { _ } = useLingui(); const handleClick = () => onChange(1); - const label = typeof cheat.args.button === 'string' ? cheat.args.button : 'Apply'; + const label = typeof cheat.args.button === 'string' ? cheat.args.button : _(msg`Apply`); return ( - @@ -104,6 +107,7 @@ type PresetModalProps = { }; const PresetModal = ({ draftName, onClose, onDraftNameChange, onSubmit }: PresetModalProps) => { + const { _ } = useLingui(); const inputRef = useRef(null); const trimmedName = draftName.trim(); @@ -115,28 +119,32 @@ const PresetModal = ({ draftName, onClose, onDraftNameChange, onSubmit }: Preset const handleSubmit = (event: FormEvent) => { event.preventDefault(); - const nextName = trimmedName || DEFAULT_PRESET_NAME; + const nextName = trimmedName || _(msg`New preset`); onSubmit(nextName); }; return (
-
- - + +
diff --git a/web-panel/src/features/remote-panel/components/TrainerHeader.tsx b/web-panel/src/trainer/ui/TrainerHeader.tsx similarity index 67% rename from web-panel/src/features/remote-panel/components/TrainerHeader.tsx rename to web-panel/src/trainer/ui/TrainerHeader.tsx index 75bacad..a2cfb50 100644 --- a/web-panel/src/features/remote-panel/components/TrainerHeader.tsx +++ b/web-panel/src/trainer/ui/TrainerHeader.tsx @@ -1,10 +1,15 @@ -import { Icon } from '@/components/ui/icon'; -import { cn } from '@/lib/utils'; +import { msg } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { useLingui } from '@lingui/react'; -import { getTrainerDisplayName } from '../category'; -import type { LibraryGame } from '../game-library'; -import type { TrainerSummary } from '../protocol'; -import { GameCover } from './GameCover'; +import { Icon } from '@/shared/ui/Icon'; +import { cn } from '@/shared/lib/ui'; + +import type { LibraryGame } from '@/library/model/games'; +import { GameCover } from '@/library/ui/GameCover'; + +import { getTrainerDisplayName } from '../model/categories'; +import type { TrainerSummary } from '../../../protocol/messages'; type TrainerHeaderProps = { trainer: TrainerSummary; @@ -14,6 +19,8 @@ type TrainerHeaderProps = { }; export const TrainerHeader = ({ trainer, game, isPinned, onPin }: TrainerHeaderProps) => { + const { _ } = useLingui(); + return (
@@ -22,7 +29,7 @@ export const TrainerHeader = ({ trainer, game, isPinned, onPin }: TrainerHeaderP
- Trainer Active + Trainer Active

{getTrainerDisplayName(trainer)}

@@ -31,7 +38,7 @@ export const TrainerHeader = ({ trainer, game, isPinned, onPin }: TrainerHeaderP · #{trainer.trainerId}
-
diff --git a/web-panel/src/trainer/ui/category-labels.ts b/web-panel/src/trainer/ui/category-labels.ts new file mode 100644 index 0000000..6e71752 --- /dev/null +++ b/web-panel/src/trainer/ui/category-labels.ts @@ -0,0 +1,22 @@ +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; + +export const CATEGORY_LABELS: Record = { + challenge: msg`Challenge`, + character: msg`Character`, + cheats: msg`Cheats`, + crafting: msg`Crafting`, + enemies: msg`Enemies`, + game: msg`Game`, + inventory: msg`Inventory`, + items: msg`Items`, + physics: msg`Physics`, + pinned: msg`Pinned`, + player: msg`Player`, + resources: msg`Resources`, + stats: msg`Stats`, + teleport: msg`Teleport`, + vehicles: msg`Vehicles`, + weapons: msg`Weapons`, + world: msg`World`, +}; diff --git a/web-panel/tsconfig.json b/web-panel/tsconfig.json index e16e825..efe44b1 100644 --- a/web-panel/tsconfig.json +++ b/web-panel/tsconfig.json @@ -32,6 +32,8 @@ }, "include": [ "src", + "protocol", + "vitest.config.ts", "vite.config.ts" ] -} \ No newline at end of file +} diff --git a/web-panel/vite.config.ts b/web-panel/vite.config.ts index 3021b2c..21dae8c 100644 --- a/web-panel/vite.config.ts +++ b/web-panel/vite.config.ts @@ -1,10 +1,15 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import { lingui } from '@lingui/vite-plugin'; import { fileURLToPath, URL } from 'node:url'; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [ + react({ babel: { plugins: ['@lingui/babel-plugin-lingui-macro'] } }), + lingui(), + tailwindcss(), + ], base: './', resolve: { alias: [ diff --git a/web-panel/vitest.config.ts b/web-panel/vitest.config.ts new file mode 100644 index 0000000..a123f28 --- /dev/null +++ b/web-panel/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import viteConfig from './vite.config'; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: 'jsdom', + restoreMocks: true, + alias: { + 'react-dom/test-utils': 'preact/test-utils', + }, + // Inline @lingui/react so its bare `react` import resolves to preact/compat + // via the alias above instead of pulling in a second (real) React copy. + server: { deps: { inline: [/@lingui\/react/] } }, + }, + }), +);