/** * Device-management state (ticket #77, lot F2). * * Owns the list, the ephemeral pairing code and the revoke/rename actions, so * {@link DevicesScreen} stays a rendering concern. Transport-neutral: every call * goes through the injected {@link DeviceGateway}, which is the Tauri adapter on * desktop and the HTTP one on web. * * Revoking the **current** device ends this session, so the screen must not try * to refresh afterwards — the hook reports it through `sessionEnded` and the * mounting surface decides what that means (web: back to pairing; desktop: never * happens, no device is ever `isCurrentDevice`). */ import { useCallback, useEffect, useRef, useState } from "react"; import type { GatewayError, PairedDevice, PairingCode } from "@/domain"; import { useGateways } from "@/app/di"; /** How often the list is re-read while a code is live, to catch a new pairing. */ const PAIRING_POLL_MS = 3000; /** How long a freshly-paired row stays highlighted. */ const HIGHLIGHT_MS = 2500; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } export interface UseDevices { devices: PairedDevice[] | null; error: string | null; /** An action is in flight; drives the confirm dialogs' pending state. */ busy: boolean; /** Narrower than `busy`: only a code generation, so `Appairer` alone spins. */ generating: boolean; /** The live code, or `null` when no code has been generated (or it was dismissed). */ code: PairingCode | null; /** Device ids to highlight — the ones that appeared while a code was live. */ freshDeviceIds: string[]; /** Set once the current device's own session has been revoked. */ sessionEnded: boolean; refresh(): Promise; generateCode(): Promise; dismissCode(): void; rename(deviceId: string, name: string): Promise; revoke(device: PairedDevice): Promise; revokeAll(): Promise; } export function useDevices(): UseDevices { const { device: gateway } = useGateways(); const [devices, setDevices] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [generating, setGenerating] = useState(false); const [code, setCode] = useState(null); const [freshDeviceIds, setFreshDeviceIds] = useState([]); const [sessionEnded, setSessionEnded] = useState(false); // Read inside the poll callback without making it a dependency (which would // restart the interval on every list change). const knownIds = useRef | null>(null); const load = useCallback(async (): Promise => { try { const list = await gateway.listDevices(); setDevices(list); setError(null); return list; } catch (e) { setError(describe(e)); return null; } }, [gateway]); const refresh = useCallback(async () => { const list = await load(); if (list) knownIds.current = new Set(list.map((d) => d.deviceId)); }, [load]); useEffect(() => { void refresh(); }, [refresh]); // While a code is live, a new device may pair at any moment and the backend // pushes no event for it (B3 scope), so poll. The interval exists only for the // few minutes the code is valid, never in steady state. useEffect(() => { if (!code) return; const timer = setInterval(() => { void (async () => { const list = await load(); if (!list) return; const known = knownIds.current; knownIds.current = new Set(list.map((d) => d.deviceId)); if (!known) return; const fresh = list.filter((d) => !known.has(d.deviceId)).map((d) => d.deviceId); if (fresh.length > 0) setFreshDeviceIds(fresh); })(); }, PAIRING_POLL_MS); return () => clearInterval(timer); }, [code, load]); // The highlight is an acknowledgement, not a state: let it fade on its own. useEffect(() => { if (freshDeviceIds.length === 0) return; const timer = setTimeout(() => setFreshDeviceIds([]), HIGHLIGHT_MS); return () => clearTimeout(timer); }, [freshDeviceIds]); const run = useCallback( async (action: () => Promise): Promise => { setBusy(true); setError(null); try { await action(); return true; } catch (e) { setError(describe(e)); return false; } finally { setBusy(false); } }, [], ); const generateCode = useCallback(async () => { setGenerating(true); try { await run(async () => { // Generating invalidates the previous code server-side; mirror that by // replacing it here rather than stacking panels. setCode(await gateway.createPairingCode()); }); } finally { setGenerating(false); } }, [gateway, run]); const dismissCode = useCallback(() => setCode(null), []); const rename = useCallback( async (deviceId: string, name: string) => { if (await run(() => gateway.renameDevice(deviceId, name))) await refresh(); }, [gateway, run, refresh], ); const revoke = useCallback( async (target: PairedDevice) => { // Read `isCurrentDevice` *before* the call: afterwards the session may be // gone and the list unreadable. const wasCurrent = target.isCurrentDevice; if (!(await run(() => gateway.revokeDevice(target.deviceId)))) return; if (wasCurrent) setSessionEnded(true); else await refresh(); }, [gateway, run, refresh], ); const revokeAll = useCallback(async () => { const includedCurrent = devices?.some((d) => d.isCurrentDevice) ?? false; if (!(await run(() => gateway.revokeAllDevices()))) return; if (includedCurrent) setSessionEnded(true); else await refresh(); }, [devices, gateway, run, refresh]); return { devices, error, busy, generating, code, freshDeviceIds, sessionEnded, refresh, generateCode, dismissCode, rename, revoke, revokeAll, }; }