mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-29 01:08:51 +00:00
1f5ba9fc95
bump version to 1.0.8.1
379 lines
16 KiB
TypeScript
379 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|