153 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|