Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONNECTIVITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,8 @@ The app is available for a new session when hub presence is not busy
backend not yet ready for play) and there is no consent prompt, reserved peer id,
buffered handshake, or live message handler (`isAvailableForNewSessionPrompt()`).
Backend readiness is owned by `InternalBlockchainInterface.isReadyForPlay()`
(simulator: connected; WalletConnect: a verified full-node peer, checked
privately). The app still connects to the hub normally while a backend is not
(simulator and Cloud Wallet: connected; WalletConnect: a verified full-node
peer, checked privately). The app still connects to the hub normally while a backend is not
ready; it simply advertises busy, and inbound `advisory_start` /
`session_proposal` must still be declined even if the game WebSocket is live. A
consent prompt is a temporary unavailable state for inbound matchmaking even
Expand Down
50 changes: 32 additions & 18 deletions FRONTEND_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,9 @@ means session obligation, walletless (`shouldReportHubBusy`), or that the active
blockchain backend is not yet ready for play (`blockchainReady === false`, folded
into `shouldReportHubBusy` / `shouldReportHubBusyPresence`). Readiness is owned by
the backend behind `InternalBlockchainInterface.isReadyForPlay()` /
`onPlayReadinessChange()`: the simulator is ready whenever connected, while
WalletConnect polls privately for a verified full-node peer (peer count never
leaves the backend). The app still connects to the hub normally while a backend
`onPlayReadinessChange()`: the simulator and Cloud Wallet are ready whenever
connected, while WalletConnect polls privately for a verified full-node peer
(peer count never leaves the backend). The app still connects to the hub normally while a backend
is not ready; it just advertises busy. Shell mirrors the backend's readiness into
`blockchainReadyRef` via `onPlayReadinessChange`, and a wallet disconnect clears
it (the backend can no longer vouch for readiness). The `HubConnection` uses a
Expand Down Expand Up @@ -455,7 +455,7 @@ are grouped under those phase-owned payloads:
| `unreadGame` | `boolean?` | Whether the Game tab has unread activity. |
| `walletAlert` | `boolean?` | Whether the Wallet tab should show an alert dot. |
| `hubAlert` | `boolean?` | Whether the Hub tab should show an alert dot. |
| `blockchainType` | `'simulator' \| 'walletconnect'?` | Which wallet backend is active or should be reconnected. |
| `blockchainType` | `'simulator' \| 'walletconnect' \| 'cloud'?` | Which wallet backend is active or should be reconnected. |
| `serializedGameSession` | `Uint8Array?` | Raw binary WASM game-session state via `serialize()`. |
| `gameSessionSchemaVersion` | `bigint?` | Rust-owned schema ID for `serializedGameSession`; currently `4`. Missing or mismatched IDs are unsupported and cleared before deserialization. |
| `pairingToken` | `string?` | Locally generated identity for the current peer-session/controller instance. It is persisted so pre-cradle setup or a full session resumes into the same instance, and it correlates Shell transition completion with that instance; it is not protocol authority. |
Expand Down Expand Up @@ -1110,13 +1110,20 @@ Shell manages wallet connections through two abstractions defined in

- **`InternalBlockchainInterface`** — the backend-specific implementation
(`RealBlockchainInterface` for WalletConnect, `FakeBlockchainInterface` for
the simulator). Each exposes `beginConnect()`, `disconnect()`,
`isConnected()`, `spend()`, etc.
the simulator, `CloudBlockchainInterface` for Cloud Wallet OAuth). Each
exposes `beginConnect()`, `disconnect()`, `isConnected()`, `spend()`, etc.
- **`ConnectionSetup`** — returned by `beginConnect()`. Contains a `uri` for
the QR code and a `finalize()` promise that resolves when the wallet is
paired. Optionally contains `fields` (a map of input descriptors) indicating
the backend needs extra user input before connecting (e.g. the simulator's
initial balance).
the QR code and a `finalize(values?)` promise that resolves when the wallet is
paired. Optionally contains `fields` (a `Record` of typed input descriptors,
each `{ type: 'string' | 'bigint', label, default }`) indicating the backend
needs extra user input before connecting, plus an optional `title`/
`description` for the setup modal. Examples: the simulator's initial balance
(`bigint`), and Cloud Wallet's OAuth `clientId` / API URL / UI URL (`string`).
Cloud Wallet sets `skipQr: true` and completes OAuth inside `finalize()` after
persisting the entered config via `cloudWalletConfig.ts` (kept separate from
the OAuth tokens in `cloudWalletAuth.ts`). All OAuth/GraphQL calls resolve the
client id and endpoints at call time through `getCloudWallet*` getters, so
UI-entered config takes effect without a rebuild.

**Design principle:** Shell must not branch on `blockchainType` for connection
logic. All differences between backends live behind the interface. A single
Expand All @@ -1125,16 +1132,23 @@ and poll interval; the rest of the flow is generic.

**Connection lifecycle:**

1. User picks "Simulator" or "Link Wallet" → `handleConnect(bcType)`.
1. User picks "Simulator", "Link Wallet", or "Cloud Wallet" →
`handleConnect(bcType)`.
2. `handleConnect` calls `iface.beginConnect(uniqueId)`, which returns a
`ConnectionSetup`.
3. If `setup.fields` is present, Shell shows the `SimulatorSetupModal` overlay
so the user can provide the required values, then `handleFinalize()` calls
`setup.finalize()`.
4. If `setup.fields` is absent (WalletConnect), Shell renders the QR code and
immediately awaits `setup.finalize()`, which resolves when the wallet scans.
5. After finalize resolves, `completeConnection()` activates polling and
switches to the Hub tab.
3. If `setup.fields` is present, Shell shows the generic `ConnectionSetupModal`
overlay so the user can provide the required values, then `handleFinalize(values)`
calls `setup.finalize(values)`. This path is used by both the simulator and
Cloud Wallet (the latter is `skipQr` yet still collects OAuth config first).
4. If `setup.skipQr` is set with no fields (a restored WC/Cloud session), Shell
awaits `setup.finalize()` without showing a QR panel or modal.
5. If `setup.fields` is absent and QR is required (WalletConnect pairing), Shell
renders the QR code and awaits `setup.finalize()`, which resolves when the
wallet scans.
6. After finalize resolves, `completeConnection()` activates polling and
switches to the Hub tab. Connect/finalize failures are surfaced on the Choose
Connection screen and inside the setup modal via `connectError`, rather than
silently resetting the chooser.

**Auto-reconnect:** Both backends implement their own WebSocket reconnect
following the shared connection discipline described in
Expand Down
11 changes: 10 additions & 1 deletion front-end/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import Shell from './components/Shell';
import OAuthCallback from './components/OAuthCallback';
import { CLOUD_WALLET_OAUTH_CALLBACK_PATH } from './constants/env';

const App = () => <Shell />;
function isOAuthCallbackPath(): boolean {
if (typeof window === 'undefined') return false;
const path = window.location.pathname.replace(/\/$/, '') || '/';
const target = CLOUD_WALLET_OAUTH_CALLBACK_PATH.replace(/\/$/, '') || '/oauth/callback';
return path === target || path.endsWith(target);
}

const App = () => (isOAuthCallbackPath() ? <OAuthCallback /> : <Shell />);

export default App;
188 changes: 188 additions & 0 deletions front-end/src/components/ConnectionSetupModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from './button';
import type { ConnectionField, ConnectionFieldValues } from '../types/ChiaGaming';

interface ConnectionSetupModalProps {
open: boolean;
title?: string;
description?: string;
fields?: Record<string, ConnectionField>;
onConnect: (values: ConnectionFieldValues) => void;
onCancel?: () => void;
connecting: boolean;
error?: string | null;
}

export function ConnectionSetupModal({
open,
title,
description,
fields,
onConnect,
onCancel,
connecting,
error,
}: ConnectionSetupModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(
null,
);
const offsetRef = useRef({ x: 0, y: 0 });

const fieldEntries = useMemo(() => (fields ? Object.entries(fields) : []), [fields]);
const [inputs, setInputs] = useState<Record<string, string>>({});

useEffect(() => {
if (!open) return;
const initial: Record<string, string> = {};
for (const [key, field] of Object.entries(fields ?? {})) {
initial[key] = field.type === 'bigint' ? field.default.toString() : field.default;
}
setInputs(initial);
offsetRef.current = { x: 0, y: 0 };
if (panelRef.current) panelRef.current.style.transform = 'translate(-50%, -50%)';
// Re-initialize whenever the modal opens or the field set changes.
}, [open, fields]);

const clampToContainer = useCallback((x: number, y: number) => {
const panel = panelRef.current;
if (!panel) return { x, y };
const container = panel.offsetParent as HTMLElement | null;
if (!container) return { x, y };

const pw = panel.offsetWidth;
const ph = panel.offsetHeight;
const cw = container.clientWidth;
const ch = container.clientHeight;

const minX = pw / 2 - cw / 2;
const maxX = cw / 2 - pw / 2;
const minY = ph / 2 - ch / 2;
const maxY = ch / 2 - ph / 2;

return {
x: minX < maxX ? Math.max(minX, Math.min(maxX, x)) : 0,
y: minY < maxY ? Math.max(minY, Math.min(maxY, y)) : 0,
};
}, []);

useEffect(() => {
const onMove = (e: MouseEvent) => {
if (!dragState.current || !panelRef.current) return;
e.preventDefault();
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
const rawX = dragState.current.origX + (e.clientX - dragState.current.startX);
const rawY = dragState.current.origY + (e.clientY - dragState.current.startY);
const { x, y } = clampToContainer(rawX, rawY);
offsetRef.current = { x, y };
panelRef.current.style.transform = `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`;
};
const onUp = () => {
dragState.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [clampToContainer]);

const handleDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragState.current = {
startX: e.clientX,
startY: e.clientY,
origX: offsetRef.current.x,
origY: offsetRef.current.y,
};
}, []);

const handleConnect = useCallback(() => {
const values: ConnectionFieldValues = {};
for (const [key, field] of fieldEntries) {
const raw = inputs[key] ?? '';
if (field.type === 'bigint') {
try {
values[key] = BigInt(raw.trim() === '' ? '0' : raw.trim());
} catch {
values[key] = field.default;
}
} else {
values[key] = raw;
}
}
onConnect(values);
}, [fieldEntries, inputs, onConnect]);

if (!open) return null;

return (
<div
ref={panelRef}
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10,
width: '22rem',
maxWidth: 'calc(100% - 2rem)',
}}
className="border border-canvas-border bg-canvas-bg shadow-xl rounded-xl p-5 flex flex-col items-stretch gap-4"
>
<div
onMouseDown={handleDragStart}
style={{ cursor: 'grab' }}
className="select-none w-full text-center"
>
<h2 className="text-lg font-semibold text-canvas-text-contrast leading-tight">
{title ?? 'Connect'}
</h2>
{description ? <p className="text-sm text-canvas-text mt-0.5">{description}</p> : null}
</div>

{fieldEntries.length > 0 ? (
<div className="flex flex-col gap-3 w-full">
{fieldEntries.map(([key, field]) => (
<label key={key} className="flex flex-col gap-1 text-sm text-canvas-text">
<span>{field.label}</span>
<input
type="text"
inputMode={field.type === 'bigint' ? 'numeric' : 'text'}
value={inputs[key] ?? ''}
onChange={(e) => setInputs((prev) => ({ ...prev, [key]: e.target.value }))}
disabled={connecting}
className="px-3 py-2 rounded-md bg-canvas-bg-subtle text-canvas-text border border-canvas-border outline-none"
/>
</label>
))}
</div>
) : null}

{error ? <p className="text-sm text-alert-text break-words">{error}</p> : null}

<div className="flex items-center justify-center gap-2">
{onCancel ? (
<Button variant="outline" onClick={onCancel} disabled={connecting}>
Cancel
</Button>
) : null}
<Button
variant="solid"
onClick={handleConnect}
disabled={connecting}
isLoading={connecting}
loadingText="Connecting&#x2026;"
>
Connect
</Button>
</div>
</div>
);
}
50 changes: 50 additions & 0 deletions front-end/src/components/OAuthCallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { handleOAuthCallbackPage } from '../hooks/cloudWalletOAuth';

/**
* Minimal handoff page for Cloud Wallet OAuth redirect.
* Posts the authorization code to the opener and shows a short status.
*/
export default function OAuthCallback() {
const [message, setMessage] = useState('Completing Cloud Wallet login…');
const [ok, setOk] = useState<boolean | null>(null);

useEffect(() => {
const result = handleOAuthCallbackPage();
setOk(result.status === 'ok');
setMessage(result.message);
if (result.status === 'ok') {
const timer = setTimeout(() => {
try {
window.close();
} catch {
// ignore
}
}, 400);
return () => clearTimeout(timer);
}
return undefined;
}, []);

return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1.5rem',
fontFamily: 'system-ui, sans-serif',
background: '#0f1419',
color: '#e7ecf1',
}}
>
<div style={{ maxWidth: '28rem', textAlign: 'center' }}>
<h1 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>
{ok === false ? 'Cloud Wallet login failed' : 'Cloud Wallet'}
</h1>
<p style={{ opacity: 0.85, lineHeight: 1.5 }}>{message}</p>
</div>
</div>
);
}
Loading
Loading