Files
Ekklesia/surfaces/shell/src/display/DisplayApp.tsx
T
ekklesia 2e0fe059c3
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s
feat: add display kiosk host
2026-08-23 20:50:56 -05:00

153 lines
5.0 KiB
TypeScript

import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react";
import {
bootstrapDisplayHost,
type DisplayHostBootstrap,
type EkklesiaHostConfig,
HostProtocolError,
} from "../host-protocol.js";
import { DisplayUiFrame } from "./DisplayUiFrame.js";
type DisplayState =
| { readonly status: "loading"; readonly generation: number }
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
| { readonly status: "signed-out" }
| { 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;
}
const displayStyles = {
host: {
height: "100%",
minHeight: "100dvh",
minWidth: 0,
overflow: "hidden",
width: "100%",
},
message: {
alignItems: "center",
color: "#57534e",
display: "flex",
flexDirection: "column",
fontFamily: "system-ui, sans-serif",
gap: "0.75rem",
height: "100%",
justifyContent: "center",
margin: 0,
minHeight: "100dvh",
padding: "1rem",
textAlign: "center",
},
retry: {
background: "transparent",
border: "1px solid currentColor",
borderRadius: "0.25rem",
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.75rem",
},
} as const;
function DisplayMessage(props: {
readonly children: string;
readonly testId: string;
readonly onRetry?: () => void;
}): ReactElement {
return (
<div aria-live="polite" data-testid={props.testId} style={displayStyles.message}>
<p style={{ margin: 0 }}>{props.children}</p>
{props.onRetry ? (
<button onClick={props.onRetry} style={displayStyles.retry} type="button">
Retry
</button>
) : undefined}
</div>
);
}
/**
* 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<DisplayState>({ generation: 0, status: "loading" });
const [attempt, setAttempt] = useState(0);
const { config, importModule, moduleId } = props;
useEffect(() => {
const abort = new AbortController();
setState({ generation: attempt, status: "loading" });
void bootstrapDisplayHost(config, moduleId, abort.signal)
.then((bootstrap) => {
if (abort.signal.aborted) {
return;
}
setState(
bootstrap.status === "signed-out"
? { status: "signed-out" }
: { bootstrap, status: "ready" },
);
})
.catch((error: unknown) => {
if (abort.signal.aborted) {
return;
}
console.error("[ekklesia] display host bootstrap failed", error);
setState(
error instanceof HostProtocolError &&
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "DISPLAY_NOT_AVAILABLE")
? { status: "unavailable" }
: error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED"
? { status: "signed-out" }
: { status: "failed" },
);
});
return () => abort.abort();
}, [attempt, config, moduleId]);
const retry = (): void => setAttempt((current) => current + 1);
return (
<div data-testid="display-host" style={displayStyles.host}>
{state.status === "loading" ? (
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
) : undefined}
{state.status === "signed-out" ? (
<DisplayMessage onRetry={retry} testId="display-host-signed-out">
Sign in to use this display.
</DisplayMessage>
) : undefined}
{state.status === "unavailable" ? (
<DisplayMessage onRetry={retry} testId="display-host-unavailable">
This display is unavailable.
</DisplayMessage>
) : undefined}
{state.status === "failed" ? (
<DisplayMessage onRetry={retry} testId="display-host-error">
This display could not be reached.
</DisplayMessage>
) : undefined}
{state.status === "ready" ? (
<DisplayUiFrame
config={config}
descriptor={state.bootstrap.descriptor}
{...(importModule ? { importModule } : {})}
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`}
principal={state.bootstrap.principal}
/>
) : undefined}
</div>
);
}