import { useCallback, useEffect, useRef, useState } from 'react'; import type { Config, Settings } from './types'; import { hydrateManagedState, isManagedState, MANAGED_KEY } from './lib/device.mjs'; export type ManagedState = { state: 'paired'; enabled: true; adminUrl: string; config: Config; device: { id: string; name: string; cityId: number; revision: number; settings: Settings } }; type Control = { state: string; enabled?: boolean; code?: string; expiresAt?: string; adminUrl?: string }; function cached(): ManagedState | null { try { const value = hydrateManagedState(JSON.parse(localStorage.getItem(MANAGED_KEY) || 'null')); return isManagedState(value) ? value : null; } catch { return null; } } const info = () => ({ width: innerWidth, height: innerHeight, version: 'board-0.1.0' }); export function useDeviceControl() { const [managed, setManaged] = useState(cached), [control, setControl] = useState({ state: 'loading' }); const [failed, setFailed] = useState(false), [retry, setRetry] = useState(0); const applied = useRef(0), current = useRef(managed); current.current = managed; const onApplied = useCallback((revision: number) => { applied.current = revision; try { localStorage.setItem(MANAGED_KEY, JSON.stringify(current.current)); } catch { /* Keep the current screen working even if persistent storage is unavailable. */ } void fetch('/api/device/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...info(), appliedRevision: revision }) }).catch(() => {}); }, []); useEffect(() => { let stopped = false, timer: ReturnType, controller: AbortController | undefined, lastHeartbeat = 0; let previousState = '', halted = false, running = false, firstRequest = true; async function call(path: string, body?: object) { controller = new AbortController(); const timeout = setTimeout(() => controller?.abort(), 12000); try { const response = await fetch(`/api/device/${path}`, { method: body ? 'POST' : 'GET', cache: 'no-store', signal: controller.signal, ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}) }); const value = await response.json(); if (!response.ok) throw new Error(value.error || 'devices_unavailable'); return value; } finally { clearTimeout(timeout); } } async function load() { if (stopped || halted || running) return; running = true; let delay = 15000; try { let value = await call(firstRequest && retry > 0 ? 'pair' : 'status', firstRequest && retry > 0 ? info() : undefined); if (stopped) return; firstRequest = false; if (value.enabled === false) { halted = true; if (!stopped) { setControl({ state: 'standalone', enabled: false }); setManaged(null); setFailed(false); } return; } if (value.state === 'unpaired' && previousState === 'pending') throw new Error('storage_unavailable'); if (['unpaired', 'expired'].includes(value.state)) { if (!stopped) { setManaged(null); applied.current = 0; localStorage.removeItem(MANAGED_KEY); } value = await call('pair', info()); } if (value.state === 'paired' && !value.config) value = await call('status'); if (value.state === 'paired') { value = hydrateManagedState(value); if (!isManagedState(value)) throw new Error('invalid_settings'); if (!stopped) { setManaged(previous => previous && previous.device.id === value.device.id && previous.device.revision === value.device.revision ? previous : value); setControl(value); setFailed(false); } if (Date.now() - lastHeartbeat >= 30000) { await call('heartbeat', { ...info(), appliedRevision: current.current?.device.id === value.device.id ? applied.current : 0 }); lastHeartbeat = Date.now(); } } else if (value.state === 'pending') { if (!/^\d{4}$/.test(value.code) || !Number.isFinite(Date.parse(value.expiresAt))) throw new Error('devices_unavailable'); delay = 3000; if (!stopped) { setControl(value); setManaged(null); setFailed(false); } } else throw new Error('devices_unavailable'); previousState = value.state; } catch (error) { if (!stopped) { const code = error instanceof Error ? error.message : 'devices_unavailable'; setFailed(true); if (['device_revoked', 'device_unpaired'].includes(code)) { halted = true; setFailed(false); setManaged(null); applied.current = 0; try { localStorage.removeItem(MANAGED_KEY); } catch { /* No stored copy. */ } setControl({ state: 'revoked' }); return; } if (code === 'storage_unavailable') halted = true; if (!current.current) setControl(previous => ({ ...previous, state: code === 'storage_unavailable' ? 'storage-error' : previous.code ? 'pending' : 'error' })); delay = 30000; } } finally { running = false; if (!stopped && !halted) timer = setTimeout(load, delay); } } void load(); const online = () => { clearTimeout(timer); void load(); }; window.addEventListener('online', online); return () => { stopped = true; clearTimeout(timer); controller?.abort(); window.removeEventListener('online', online); }; }, [retry]); return { managed, control, failed, onApplied, retry: () => setRetry(value => value + 1) }; }