import React from 'react'; import QRCode from 'qrcode'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { toast } from '@/components/ui'; import { Icon } from '@/components/icon/Icon'; import { copyTextToClipboard } from '@/lib/clipboard'; import { useI18n, type I18nKey } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; // OpenChamber-owned relay routes (registered before the generic OpenCode proxy). const RELAY_STATUS_ROUTE = '/api/openchamber/relay/status'; const RELAY_ENABLE_ROUTE = '/api/openchamber/relay/enable'; const RELAY_DISABLE_ROUTE = '/api/openchamber/relay/disable'; const RELAY_OFFER_ROUTE = '/api/openchamber/relay/offer'; const STATUS_POLL_INTERVAL_MS = 5_000; type RelayState = 'disabled' | 'connecting' | 'connected' | 'reconnecting' | 'error'; interface RelayStatus { enabled: boolean; state: RelayState; serverId: string; connectedClients: number; lastError?: string; } const RELAY_STATES = new Set(['disabled', 'connecting', 'connected', 'reconnecting', 'error']); // Authoritative fetch: returns null strictly on fetch/shape failure so callers // keep the previous status instead of treating a blip as "relay disabled". const fetchRelayStatus = async (signal?: AbortSignal): Promise => { let response: Response; try { response = await runtimeFetch(RELAY_STATUS_ROUTE, { method: 'GET', signal }); } catch { return null; } if (!response.ok) return null; const body = (await response.json().catch(() => null)) as Partial | null; if (!body || typeof body.enabled !== 'boolean' || typeof body.state !== 'string' || !RELAY_STATES.has(body.state)) { return null; } return { enabled: body.enabled, state: body.state as RelayState, serverId: typeof body.serverId === 'string' ? body.serverId : '', connectedClients: typeof body.connectedClients === 'number' ? body.connectedClients : 0, ...(typeof body.lastError === 'string' && body.lastError ? { lastError: body.lastError } : {}), }; }; const stateLabelKey = (state: RelayState): I18nKey => { switch (state) { case 'connecting': return 'settings.remoteInstances.relay.state.connecting'; case 'connected': return 'settings.remoteInstances.relay.state.connected'; case 'reconnecting': return 'settings.remoteInstances.relay.state.reconnecting'; case 'error': return 'settings.remoteInstances.relay.state.error'; default: return 'settings.remoteInstances.relay.state.disabled'; } }; const stateDotClass = (state: RelayState): string => { if (state === 'connected') { return 'bg-[var(--status-success)] animate-pulse'; } if (state === 'error') { return 'bg-[var(--status-error)] animate-pulse'; } if (state === 'connecting' || state === 'reconnecting') { return 'bg-[var(--status-warning)] animate-pulse'; } return 'bg-muted-foreground/40'; }; export const RelaySection: React.FC = () => { const { t } = useI18n(); const [status, setStatus] = React.useState(null); const [statusLoaded, setStatusLoaded] = React.useState(false); const [isToggling, setIsToggling] = React.useState(false); const [pairLabel, setPairLabel] = React.useState(''); const [includeToken, setIncludeToken] = React.useState(true); const [isPairing, setIsPairing] = React.useState(false); const [offerUrl, setOfferUrl] = React.useState(null); const [offerQrDataUrl, setOfferQrDataUrl] = React.useState(null); const [qrDialogOpen, setQrDialogOpen] = React.useState(false); const refreshStatus = React.useCallback(async (signal?: AbortSignal) => { const next = await fetchRelayStatus(signal); if (signal?.aborted) return; setStatusLoaded(true); // Preserve the last known status on fetch failure; never downgrade to // "disabled" because of a transient network error. if (next) setStatus(next); }, []); // Poll only while this section is mounted (page visible) and the document // is visible — no global polling. React.useEffect(() => { const controller = new AbortController(); void refreshStatus(controller.signal); const interval = window.setInterval(() => { if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { return; } void refreshStatus(controller.signal); }, STATUS_POLL_INTERVAL_MS); return () => { controller.abort(); window.clearInterval(interval); }; }, [refreshStatus]); const handleEnable = React.useCallback(async () => { setIsToggling(true); try { const response = await runtimeFetch(RELAY_ENABLE_ROUTE, { method: 'POST' }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } await refreshStatus(); } catch (err) { toast.error(t('settings.remoteInstances.relay.toast.enableFailed'), { description: err instanceof Error ? err.message : String(err), }); } finally { setIsToggling(false); } }, [refreshStatus, t]); const handleDisable = React.useCallback(async () => { const confirmed = window.confirm(t('settings.remoteInstances.relay.confirm.disable')); if (!confirmed) return; setIsToggling(true); try { const response = await runtimeFetch(RELAY_DISABLE_ROUTE, { method: 'POST' }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } setOfferUrl(null); setOfferQrDataUrl(null); await refreshStatus(); } catch (err) { toast.error(t('settings.remoteInstances.relay.toast.disableFailed'), { description: err instanceof Error ? err.message : String(err), }); } finally { setIsToggling(false); } }, [refreshStatus, t]); const handleCreateOffer = React.useCallback(async () => { setIsPairing(true); try { const response = await runtimeFetch(RELAY_OFFER_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ includeToken, ...(pairLabel.trim() ? { clientLabel: pairLabel.trim() } : {}), }), }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const result = (await response.json()) as { url?: unknown }; if (typeof result.url !== 'string' || !result.url) { throw new Error('Malformed offer response'); } setOfferUrl(result.url); // Relay offers are ~500 chars (encryption key JWK + token) — far denser than // direct-pairing QRs. Render at high resolution with low ECC; the fullscreen // dialog then displays it large enough for a phone camera to lock on. A small // inline QR of this density is unscannable (learned the hard way). setOfferQrDataUrl( await QRCode.toDataURL(result.url, { width: 1024, margin: 2, errorCorrectionLevel: 'L' }), ); setPairLabel(''); } catch (err) { toast.error(t('settings.remoteInstances.relay.toast.offerFailed'), { description: err instanceof Error ? err.message : String(err), }); } finally { setIsPairing(false); } }, [includeToken, pairLabel, t]); const handleCopyOffer = React.useCallback(() => { if (!offerUrl) return; void copyTextToClipboard(offerUrl).then((result) => { if (result.ok) { toast.success(t('settings.remoteInstances.relay.toast.linkCopied')); } }); }, [offerUrl, t]); const enabled = status?.enabled === true; const state: RelayState = status?.state ?? 'disabled'; const isConnected = state === 'connected'; return (

{t('settings.remoteInstances.relay.title')}

{t('settings.remoteInstances.relay.description')}

{!statusLoaded ? (

{t('settings.remoteInstances.relay.state.loading')}

) : !enabled ? (

{t('settings.remoteInstances.relay.enableHint')}

) : ( <>

{t(stateLabelKey(state))}

{(status?.connectedClients ?? 0) === 1 ? t('settings.remoteInstances.relay.status.clientsOne', { count: 1 }) : t('settings.remoteInstances.relay.status.clientsMany', { count: status?.connectedClients ?? 0 })}

{state === 'error' && status?.lastError ? (

{status.lastError}

) : null}

{t('settings.remoteInstances.relay.pair.title')}

setPairLabel(event.target.value)} placeholder={t('settings.remoteInstances.relay.pair.labelPlaceholder')} disabled={isPairing} />
{!includeToken ? (

{t('settings.remoteInstances.relay.pair.noTokenHint')}

) : null} {!isConnected ? (

{t('settings.remoteInstances.relay.pair.requiresConnected')}

) : null} {offerUrl ? (

{t('settings.remoteInstances.relay.pair.linkLabel')}

{offerUrl}
{offerQrDataUrl ? ( ) : null}

{t('settings.remoteInstances.relay.pair.warning')}

) : null}

{t('settings.remoteInstances.relay.pair.manageHint')}

)}
{t('settings.remoteInstances.relay.pair.qrDialogTitle')} {t('settings.remoteInstances.relay.pair.qrDialogDescription')} {offerQrDataUrl ? (
{t('settings.remoteInstances.relay.pair.qrAlt')}
) : null}
); };