import { designToken } from "@ekklesia/design-tokens"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import { type ReactElement, useCallback, useEffect, useRef, useState } from "react"; import { bootstrapDisplayHost, type DisplayHostBootstrap, type EkklesiaHostConfig, HostProtocolError, } from "../host-protocol.js"; import { DisplayUiFrame } from "./DisplayUiFrame.js"; import { type DisplayConnectivity, type DisplayConnectivitySource, watchDisplayConnectivity, } from "./offline-status.js"; import { PairingScreen } from "./PairingScreen.js"; type DisplayState = | { readonly status: "loading"; readonly generation: number } | { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" }; /** Changes only when the mounted display must be replaced, never merely because the network * came back. It keys the frame, so it is the one thing that can remount a running display. */ readonly generation: number; } /** No identity this display can use yet: show a pairing code rather than a human login form. */ | { readonly status: "pairing" } | { readonly status: "unavailable" } | { readonly status: "failed" }; export interface DisplayAppProps { readonly config: EkklesiaHostConfig; readonly moduleId: string; /** Test/composition seam only. Production uses the generic loader's native dynamic import. */ readonly importModule?: ModuleUiImporter; /** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point * supplies the real ones; omitting it leaves a display that simply never reports being offline. */ readonly connectivity?: DisplayConnectivitySource; /** How long the display must stay offline before the corner indicator appears. Test seam only. */ readonly offlineIndicatorDelayMs?: number; /** The shortest interval between two revalidations prompted by refused module requests. Test seam * only. */ readonly revalidationIntervalMs?: number; /** How often a running display re-resolves its descriptor on its own. Test seam only. */ readonly periodicRevalidationMs?: number; /** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam * only. */ readonly retryBaseMs?: number; } /** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only * an outage that outlasts this is worth the corner indicator. */ export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000; /** A module whose requests are being refused prompts at most one revalidation in this interval. A * display that is merely missing one permission must not turn every refused request into two more. */ export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000; /** A running display asks again this often, so a module upgraded on the server is picked up and a * revocation is noticed even by a module that makes no requests of its own. */ export const DISPLAY_PERIODIC_REVALIDATION_MS = 5 * 60_000; /** A display that could not start tries again on its own: from this delay, doubling to a minute. A * kiosk that boots before its server — after a power cut, say — must come up without anyone pressing * Retry. */ export const DISPLAY_RETRY_BASE_MS = 2_000; export const DISPLAY_RETRY_MAX_MS = 60_000; /** Whether a fresh resolution describes exactly the display already mounted. Compared structurally: * the descriptor and principal are small, server-produced, and carry nothing volatile, so any * difference — a new version, a new contribution, a different principal — is a real one. */ function sameDisplay(left: DisplayHostBootstrap, right: DisplayHostBootstrap): boolean { return ( JSON.stringify([left.descriptor, left.principal]) === JSON.stringify([right.descriptor, right.principal]) ); } /** `value`, but only once it has been continuously true for `delayMs`. Becoming false is immediate. */ function useSustained(value: boolean, delayMs: number): boolean { const [sustained, setSustained] = useState(value && delayMs <= 0); useEffect(() => { if (!value) { setSustained(false); return; } if (delayMs <= 0) { setSustained(true); return; } const timer = setTimeout(() => setSustained(true), delayMs); return () => clearTimeout(timer); }, [delayMs, value]); return sustained; } function isDenied(error: unknown): boolean { return ( error instanceof HostProtocolError && (error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED") ); } const displayStyles = { host: { height: "100%", minHeight: "100dvh", minWidth: 0, overflow: "hidden", width: "100%", }, message: { alignItems: "center", color: designToken("text.secondary"), display: "flex", flexDirection: "column", fontFamily: designToken("typography.family-sans"), gap: designToken("spacing.md"), height: "100%", justifyContent: "center", margin: 0, minHeight: "100dvh", padding: "1rem", textAlign: "center", }, retry: { background: "transparent", border: `${designToken("border.width")} solid currentColor`, borderRadius: designToken("radius.md"), color: "inherit", cursor: "pointer", font: "inherit", padding: `${designToken("spacing.xs")} ${designToken("spacing.md")}`, }, offline: { background: designToken("status.warning-background"), borderRadius: designToken("radius.pill"), color: designToken("text.warning"), fontFamily: designToken("typography.family-sans"), fontSize: designToken("typography.size-sm"), insetBlockStart: designToken("spacing.md"), insetInlineEnd: designToken("spacing.md"), padding: `${designToken("spacing.2xs")} ${designToken("spacing.md")}`, position: "absolute", }, } as const; /** * A small, honest indicator: this screen is not currently in touch with Ekklesia, or is running * from code it cached earlier. * * Deliberately unobtrusive and deliberately not an error. An unattended screen showing a full-page * failure because a switch rebooted is worse than one quietly saying so in a corner, and the screen * recovers on its own the moment connectivity returns. */ function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement { // Neutral wording: this is on a wall the congregation reads, and the product name is the // installation's to choose, not the platform's (ADR 0046). const label = props.connectivity.usingFallback ? props.connectivity.fallbackKind === "artifact" ? "Offline — showing the last downloaded display code" : "Offline — showing the last downloaded display" : "Offline — reconnecting"; return (
{label}
); } function DisplayMessage(props: { readonly children: string; readonly testId: string; readonly onRetry?: () => void; }): ReactElement { return (

{props.children}

{props.onRetry ? ( ) : undefined}
); } /** * The host for one display-capable module, with no Dashboard application mounted around it. * * Bootstrap is principal-first, so an anonymous browser never probes a module descriptor. The * descriptor itself is permission-protected server-side; only after it succeeds can an artifact URL * reach the generic loader. A changed `moduleId` or retry aborts the old fetch and keys a new frame, * so neither a late descriptor nor a late import can replace the current display. */ export function DisplayApp(props: DisplayAppProps): ReactElement { const [state, setState] = useState({ generation: 0, status: "loading" }); const [attempt, setAttempt] = useState(0); const [connectivity, setConnectivity] = useState({ online: true, usingFallback: false, }); const { config, connectivity: connectivitySource, importModule, moduleId, offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS, periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS, retryBaseMs = DISPLAY_RETRY_BASE_MS, revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS, } = props; /** Consecutive automatic retries since the display last started successfully. */ const failures = useRef(0); const stateRef = useRef(state); stateRef.current = state; const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({ inFlight: undefined, lastAt: 0, }); /** * Resolves the descriptor again **without** taking the running display down (ADR 0043). * * A display that is showing something keeps showing it while the question is asked. Only the * answer decides what happens: the same display means nothing changes at all; a new version or a * different principal replaces the frame; a refusal is a revoked or expired device and returns the * screen to pairing (ADR 0036); and any other failure is the network still settling, so the frame * stays. A display that is not showing anything simply starts over. */ const revalidate = useCallback( (reason: "online" | "authorization" | "periodic"): void => { const current = stateRef.current; if (current.status !== "ready") { if (reason === "online") { setAttempt((value) => value + 1); } return; } const tracker = revalidation.current; const nowMs = Date.now(); if ( tracker.inFlight !== undefined || (reason === "authorization" && nowMs - tracker.lastAt < revalidationIntervalMs) ) { return; } tracker.lastAt = nowMs; const abort = new AbortController(); tracker.inFlight = abort; void bootstrapDisplayHost(config, moduleId, abort.signal) .then((next) => { const latest = stateRef.current; if (abort.signal.aborted || latest.status !== "ready") { return; } if (!sameDisplay(latest.bootstrap, next)) { setState({ bootstrap: next, generation: latest.generation + 1, status: "ready" }); } }) .catch((error: unknown) => { if (!abort.signal.aborted && isDenied(error)) { setState({ status: "pairing" }); } }) .finally(() => { if (tracker.inFlight === abort) { tracker.inFlight = undefined; } }); }, [config, moduleId, revalidationIntervalMs], ); useEffect( () => () => { revalidation.current.inFlight?.abort(); revalidation.current.inFlight = undefined; }, [], ); const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]); // While running: ask again now and then, without disturbing anything when the answer is the same. useEffect(() => { if (state.status !== "ready" || periodicRevalidationMs <= 0) { return; } const timer = setInterval(() => revalidate("periodic"), periodicRevalidationMs); return () => clearInterval(timer); }, [periodicRevalidationMs, revalidate, state.status]); // Could not start: try again on its own, backing off. Pairing is not a failure — it has its own // polling — and a ready display has nothing to retry. useEffect(() => { if (state.status === "ready") { failures.current = 0; return; } if (state.status !== "failed" && state.status !== "unavailable") { return; } const delay = Math.min(DISPLAY_RETRY_MAX_MS, retryBaseMs * 2 ** Math.min(failures.current, 10)); failures.current += 1; const timer = setTimeout(() => setAttempt((value) => value + 1), delay); return () => clearTimeout(timer); }, [retryBaseMs, state.status]); useEffect(() => { if (!connectivitySource) { return; } const monitor = watchDisplayConnectivity(connectivitySource); setConnectivity(monitor.current()); const unsubscribe = monitor.subscribe((next) => { setConnectivity(next); // Nobody presses Retry on a lobby television. When the browser says the network is back, the // display resolves its descriptor again — which is also what stops a screen from sitting on // cached code after the server returns, since a fresh descriptor naming a new version replaces // the frame and the network-first worker fetches it (ADR 0043). The same answer leaves the // running display exactly where it was. if (next.online) { revalidate("online"); } }); return () => { unsubscribe(); monitor.close(); }; }, [connectivitySource, revalidate]); useEffect(() => { const abort = new AbortController(); revalidation.current.inFlight?.abort(); setState({ generation: attempt, status: "loading" }); void bootstrapDisplayHost(config, moduleId, abort.signal) .then((bootstrap) => { if (abort.signal.aborted) { return; } setState({ bootstrap, generation: attempt, status: "ready" }); }) .catch((error: unknown) => { if (abort.signal.aborted) { return; } // A denied or unauthenticated descriptor is the ordinary state of a screen nobody has // paired yet, not an error worth logging on every wall in the building. if (isDenied(error)) { setState({ status: "pairing" }); return; } console.error("[ekklesia] display host bootstrap failed", error); setState( error instanceof HostProtocolError && error.code === "DISPLAY_NOT_AVAILABLE" ? { status: "unavailable" } : { status: "failed" }, ); }); return () => abort.abort(); }, [attempt, config, moduleId]); const retry = (): void => setAttempt((current) => current + 1); const showOffline = useSustained( !connectivity.online || connectivity.usingFallback, offlineIndicatorDelayMs, ); return (
{showOffline ? : undefined} {state.status === "loading" ? ( Loading display… ) : undefined} {state.status === "pairing" ? ( ) : undefined} {state.status === "unavailable" ? ( This display is unavailable. ) : undefined} {state.status === "failed" ? ( This display could not be reached. ) : undefined} {state.status === "ready" ? ( ) : undefined}
); }