fix(display): keep a live display across reconnects, and return a revoked one to pairing
Every browser 'online' event reset the display host to its loading state and mounted the module again: a fresh viewer session, a fresh stage, and every cached object URL released, on each Wi-Fi blip. And a device revoked while its display was open never noticed; it kept playing cached content and polling 403s until someone reloaded the page. The host now re-resolves its descriptor in the background (ADR 0043) and only replaces the frame when the answer actually changed: a new version or principal remounts, a refusal returns the screen to pairing (ADR 0036), and any other failure keeps the running display. A module request refused with 401/403 prompts the same revalidation, throttled, so revocation is noticed within one heartbeat. The offline indicator waits ten seconds before appearing, so a momentary blip shows nothing, and no longer names the platform on a lobby wall. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
06bad2fe36
commit
4bcdb578e6
@@ -1,6 +1,6 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
bootstrapDisplayHost,
|
||||
type DisplayHostBootstrap,
|
||||
@@ -17,7 +17,13 @@ import { PairingScreen } from "./PairingScreen.js";
|
||||
|
||||
type DisplayState =
|
||||
| { readonly status: "loading"; readonly generation: number }
|
||||
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
|
||||
| {
|
||||
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" }
|
||||
@@ -31,6 +37,54 @@ export interface DisplayAppProps {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
/** 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 = {
|
||||
@@ -86,11 +140,13 @@ const displayStyles = {
|
||||
* 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 Ekklesia display"
|
||||
: "Offline — waiting for Ekklesia";
|
||||
: "Offline — showing the last downloaded display"
|
||||
: "Offline — reconnecting";
|
||||
return (
|
||||
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
|
||||
{label}
|
||||
@@ -130,7 +186,83 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
online: true,
|
||||
usingFallback: false,
|
||||
});
|
||||
const { config, connectivity: connectivitySource, importModule, moduleId } = props;
|
||||
const {
|
||||
config,
|
||||
connectivity: connectivitySource,
|
||||
importModule,
|
||||
moduleId,
|
||||
offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS,
|
||||
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
|
||||
} = props;
|
||||
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"): 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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectivitySource) {
|
||||
@@ -142,27 +274,29 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
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 the fresh descriptor names the current version
|
||||
// and the network-first worker fetches it (ADR 0043).
|
||||
// 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) {
|
||||
setAttempt((current) => current + 1);
|
||||
revalidate("online");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
monitor.close();
|
||||
};
|
||||
}, [connectivitySource]);
|
||||
}, [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, status: "ready" });
|
||||
setState({ bootstrap, generation: attempt, status: "ready" });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (abort.signal.aborted) {
|
||||
@@ -170,10 +304,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
}
|
||||
// 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 (
|
||||
error instanceof HostProtocolError &&
|
||||
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
|
||||
) {
|
||||
if (isDenied(error)) {
|
||||
setState({ status: "pairing" });
|
||||
return;
|
||||
}
|
||||
@@ -188,12 +319,14 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
}, [attempt, config, moduleId]);
|
||||
|
||||
const retry = (): void => setAttempt((current) => current + 1);
|
||||
const showOffline = useSustained(
|
||||
!connectivity.online || connectivity.usingFallback,
|
||||
offlineIndicatorDelayMs,
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="display-host" style={displayStyles.host}>
|
||||
{connectivity.online && !connectivity.usingFallback ? undefined : (
|
||||
<OfflineIndicator connectivity={connectivity} />
|
||||
)}
|
||||
{showOffline ? <OfflineIndicator connectivity={connectivity} /> : undefined}
|
||||
{state.status === "loading" ? (
|
||||
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
||||
) : undefined}
|
||||
@@ -215,7 +348,8 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
config={config}
|
||||
descriptor={state.bootstrap.descriptor}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`}
|
||||
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${state.generation}`}
|
||||
onAuthorizationResponse={onAuthorizationResponse}
|
||||
principal={state.bootstrap.principal}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
@@ -35,6 +35,9 @@ export interface DisplayUiFrameProps {
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
/** Test/composition seam only. Production uses the generic loader's native dynamic import. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
/** Host-internal signal that a module request was refused, so the host can find out whether this
|
||||
* device was revoked. It never enters the module-facing context. */
|
||||
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
|
||||
}
|
||||
|
||||
interface DisplayUiAttemptProps extends DisplayUiFrameProps {
|
||||
@@ -94,7 +97,10 @@ const frameStyles = {
|
||||
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<FrameStatus>("loading");
|
||||
const { config, descriptor, importModule, onRetry, principal } = props;
|
||||
const { config, descriptor, importModule, onAuthorizationResponse, onRetry, principal } = props;
|
||||
// Read through a ref so a new callback identity can never remount a running display.
|
||||
const authorizationRef = useRef(onAuthorizationResponse);
|
||||
authorizationRef.current = onAuthorizationResponse;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -162,6 +168,11 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
||||
context: createModuleUiContext({
|
||||
config,
|
||||
descriptor,
|
||||
onAuthorizationResponse: (status) => {
|
||||
if (!cancelled) {
|
||||
authorizationRef.current?.(status);
|
||||
}
|
||||
},
|
||||
principal,
|
||||
...(importModule ? { importModule } : {}),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user