Pair displays at the platform's own /display/setup, with or without modules
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s

Displays are platform-owned, but a screen could only start pairing on a
module's display page: every pairing request named a module, so an
installation with no module that uses displays could not set one up.

- /display/setup is the platform's own display page, served whenever
  the deployment has device support, whatever is installed. It pairs a
  platform display with no module. Once paired, it opens the module it
  is set to show; with none set, it shows its name and waits, rechecking
  so it stays alive and notices revocation. "setup" is now a reserved
  module id.
- Migration core:0009_platform_display_pairing lets a pairing request
  name no module. It relaxes module_id and permission_id and adds a
  both-or-neither check. It is additive and destroys nothing.
- POST /api/v1/device-pairing/requests accepts {} for a platform
  display. Approval accepts an optional moduleId, so the administrator
  can choose an installed display module and the device gets that
  module's declared permission. A module display's own request can't be
  redirected.
- GET /api/v1/device-pairing/self lets a paired device describe itself:
  its name and the module displays it may show. It renews the device's
  cookie.
- GET /api/v1/devices/setup is replaced by
  GET /api/v1/devices/display-modules: the modules a display can be set
  to show.
- Settings -> Displays always offers the /display/setup address, Copy
  setup link and Add display. Add display has an optional "Show on this
  screen" choice, and each paired display says what it shows.
- /display/<module-id> is unchanged, so released Signage 0.1.5 works as
  before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 16:43:02 -05:00
co-authored by Claude Opus 5.5
parent 906c015ca8
commit 300d9eab51
44 changed files with 3169 additions and 396 deletions
@@ -11,6 +11,7 @@ import {
PageHeader,
type PageHeaderBack,
Panel,
Select,
TextInput,
} from "@ekklesia/ui";
import {
@@ -25,12 +26,13 @@ import {
} from "react";
import {
approveDevicePairing,
displaySetupUrl,
type EkklesiaHostConfig,
type HostDeviceSummary,
type HostDisplaySetup,
type HostDisplayModule,
HostProtocolError,
listHostDevices,
listHostDisplaySetup,
listHostDisplayModules,
lookupDevicePairing,
revokeHostDevice,
} from "../host-protocol.js";
@@ -40,7 +42,11 @@ import { ShellIcon } from "./shell-icons.js";
/**
* Settings → Displays (ADR 0064, ADR 0065): the platform's own administration page for the screens
* paired with this installation — how to set up a new screen, the paired displays, adding one with
* the code its screen shows, and revoking one.
* the code its screen shows (and, optionally, which module it shows), and revoking one.
*
* Displays are the platform's, whatever is installed: a new screen opens the platform's own
* `/display/setup`, so it can be paired with no module at all, and a module later shows content on a
* display it is set to show rather than creating one.
*
* It is the device surface that used to sit in the sidebar, moved rather than rebuilt: the same
* platform-owned identity, the same endpoints, and the same rules (ADR 0036). One record per
@@ -58,16 +64,22 @@ type ListState =
| { readonly kind: "denied" }
| { readonly kind: "failed" };
type SetupState =
| { readonly kind: "loading" }
| { readonly kind: "ready"; readonly displays: readonly HostDisplaySetup[] }
| { readonly kind: "failed" };
/** The modules a display can be set to show, or `undefined` while unknown (loading or failed). */
type DisplayModules = readonly HostDisplayModule[] | undefined;
/**
* Where the Add display flow is. Once a screen is found, `asked` is the module display it asked
* from, or `undefined` for a platform display from `/display/setup`, which the administrator may set
* to show one of the installed display modules.
*/
type PairingStep =
| { readonly step: "code"; readonly error?: string }
| { readonly step: "finding" }
| { readonly step: "found"; readonly moduleId: string; readonly error?: string }
| { readonly step: "approving"; readonly moduleId: string };
| { readonly step: "found"; readonly asked: string | undefined; readonly error?: string }
| { readonly step: "approving"; readonly asked: string | undefined };
/** The "Nothing yet" choice: a platform display that shows nothing until a module is set for it. */
const NO_MODULE = "";
export interface DisplaysSettingsPageProps {
readonly config: EkklesiaHostConfig;
@@ -79,13 +91,17 @@ export interface DisplaysSettingsPageProps {
readonly now?: () => Date;
}
/** The "Add display" flow: the code from the screen, then the display's one name, then approval. */
/**
* The "Add display" flow: the code from the screen, then the display's one name — and, for a screen
* set up at `/display/setup`, which installed module it shows, or nothing yet — then approval.
*/
function AddDisplayDialog(props: {
readonly config: EkklesiaHostConfig;
readonly open: boolean;
readonly modules: DisplayModules;
readonly moduleName: (moduleId: string) => string | undefined;
readonly onDismiss: () => void;
readonly onPaired: (name: string) => void;
readonly onPaired: (name: string, shows: string | undefined) => void;
}): ReactElement {
const { config, onPaired } = props;
const formId = useId();
@@ -93,6 +109,7 @@ function AddDisplayDialog(props: {
const nameRef = useRef<HTMLInputElement>(null);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [shows, setShows] = useState<string>(NO_MODULE);
const [pairing, setPairing] = useState<PairingStep>({ step: "code" });
// A fresh flow every time it opens.
@@ -100,6 +117,7 @@ function AddDisplayDialog(props: {
if (props.open) {
setCode("");
setName("");
setShows(NO_MODULE);
setPairing({ step: "code" });
}
}, [props.open]);
@@ -120,7 +138,7 @@ function AddDisplayDialog(props: {
setPairing({ step: "finding" });
try {
const lookup = await lookupDevicePairing(config, code.trim());
setPairing({ moduleId: lookup.moduleId, step: "found" });
setPairing({ asked: lookup.moduleId, step: "found" });
} catch {
// One message for expired, consumed, mistyped, and never-existed alike.
setPairing({
@@ -133,23 +151,27 @@ function AddDisplayDialog(props: {
}
if (pairing.step === "found") {
const displayName = name.trim();
setPairing({ moduleId: pairing.moduleId, step: "approving" });
const asked = pairing.asked;
// A screen that asked from a module's own display already shows that module; a platform
// display shows what the administrator chose, if anything.
const chosen = asked === undefined && shows !== NO_MODULE ? shows : undefined;
setPairing({ asked, step: "approving" });
try {
await approveDevicePairing(config, code.trim(), displayName);
onPaired(displayName);
await approveDevicePairing(config, code.trim(), displayName, chosen);
onPaired(displayName, asked ?? chosen);
} catch {
setPairing({
asked,
error: "That display could not be approved. Its code may have expired.",
moduleId: pairing.moduleId,
step: "found",
});
}
}
};
const moduleId =
pairing.step === "found" || pairing.step === "approving" ? pairing.moduleId : undefined;
const naming = moduleId !== undefined;
const naming = pairing.step === "found" || pairing.step === "approving";
const asked = naming ? pairing.asked : undefined;
const modules = props.modules;
const error = pairing.step === "code" || pairing.step === "found" ? pairing.error : undefined;
return (
<Dialog
@@ -185,7 +207,7 @@ function AddDisplayDialog(props: {
description={
naming
? "Give it the name everyone will know it by. Every module that uses this screen shows the same name."
: "Open the display page on the screen you want to add. It shows a six-digit pairing code."
: "Enter the six-digit code the new screen shows."
}
dismissible={!busy}
initialFocusRef={codeRef}
@@ -215,9 +237,15 @@ function AddDisplayDialog(props: {
</Field>
{naming ? (
<>
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A display is asking to show <strong>{props.moduleName(moduleId) ?? moduleId}</strong>.
</p>
{asked !== undefined ? (
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A display is asking to show <strong>{props.moduleName(asked) ?? asked}</strong>.
</p>
) : (
<p data-testid="device-pairing-found" style={{ margin: 0 }}>
A new screen is waiting to be paired.
</p>
)}
<Field label="Display name">
{(control) => (
<TextInput
@@ -233,6 +261,35 @@ function AddDisplayDialog(props: {
/>
)}
</Field>
{asked === undefined && modules !== undefined && modules.length > 0 ? (
<Field
hint="You can pair it now and choose later; until then it shows its name and waits."
label="Show on this screen"
>
{(control) => (
<Select
{...control}
data-testid="device-pairing-shows"
disabled={busy}
onChange={(event) => setShows(event.target.value)}
value={shows}
>
<option value={NO_MODULE}>Nothing yet</option>
{modules.map((module) => (
<option key={module.moduleId} value={module.moduleId}>
{module.name}
</option>
))}
</Select>
)}
</Field>
) : undefined}
{asked === undefined && modules !== undefined && modules.length === 0 ? (
<p className="ek-shell-display-card__facts" data-testid="device-pairing-no-modules">
No installed module shows content on screens yet. The display is paired now and
waits, showing its name, until one is.
</p>
) : undefined}
</>
) : undefined}
{error === undefined ? undefined : (
@@ -245,20 +302,17 @@ function AddDisplayDialog(props: {
);
}
/** One display page to open on a screen, with a way to copy it there. */
function SetupLink(props: { readonly setup: HostDisplaySetup; readonly labelled: boolean }) {
/** The setup address to open on a screen, with a way to copy it there. */
function SetupLink(props: { readonly url: string }): ReactElement {
const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle");
return (
<div className="ek-shell-setup-link" data-testid={`display-setup-${props.setup.moduleId}`}>
{props.labelled ? (
<span className="ek-shell-setup-link__module">For {props.setup.name}</span>
) : undefined}
<div className="ek-shell-setup-link">
<span className="ek-shell-setup-link__row">
<span className="ek-shell-setup-link__url" data-testid="display-setup-url">
{props.setup.url}
{props.url}
</span>
<Button
data-testid={`display-setup-copy-${props.setup.moduleId}`}
data-testid="display-setup-copy"
onClick={(event) => {
// The clipboard of this page's own window: the shell reads no browser global here.
const clipboard = event.currentTarget.ownerDocument.defaultView?.navigator.clipboard;
@@ -266,14 +320,14 @@ function SetupLink(props: { readonly setup: HostDisplaySetup; readonly labelled:
setCopy("failed");
return;
}
clipboard.writeText(props.setup.url).then(
clipboard.writeText(props.url).then(
() => setCopy("copied"),
() => setCopy("failed"),
);
}}
size="sm"
>
Copy link
Copy setup link
</Button>
<span aria-live="polite" className="ek-shell-setup-link__status">
{copy === "copied"
@@ -288,74 +342,47 @@ function SetupLink(props: { readonly setup: HostDisplaySetup; readonly labelled:
}
/**
* Setting up a new screen, start to finish, in one place (ADR 0065): the address to open on the
* screen, then **Add display** to enter the code it shows — so the next step is always right under
* the one before, never back up the page. One sentence says what to do; the step-by-step version is
* there for whoever wants it, folded away.
* Setting up a new screen, start to finish, in one place (ADR 0065): the platform's own setup
* address to open on the screen, then **Add display** to enter the code it shows — so the next step
* is always right under the one before, never back up the page. One sentence says what to do; the
* step-by-step version is there for whoever wants it, folded away.
*
* It is platform setup, not any module's: each address is the platform's own display page for a
* module that shows content on screens, as the server lists them. With no such module installed it
* says so, and offers no Add display that could only fail. If the addresses cannot be read, Add
* display is still offered, since a screen may already be showing its code.
* It is the same whatever is installed. The address is `/display/setup` on the API origin, which
* pairs a platform display with no module; which module it shows, if any, is a choice made when it
* is approved.
*/
function DisplaySetupPanel(props: {
readonly setup: SetupState;
readonly url: string;
readonly onAdd: (event: MouseEvent<HTMLButtonElement>) => void;
}): ReactElement {
const { setup } = props;
const none = setup.kind === "ready" && setup.displays.length === 0;
const several = setup.kind === "ready" && setup.displays.length > 1;
return (
<Panel title="Set up a new screen">
<div data-testid="display-setup" style={{ display: "grid", gap: designToken("spacing.md") }}>
{setup.kind === "loading" ? <LoadingState label="Loading setup…" /> : undefined}
{setup.kind === "failed" ? (
<p className="ek-shell-display-card__facts" data-testid="display-setup-failed">
The setup address couldn't be loaded. Reload the page to try again.
</p>
) : undefined}
{none ? (
<p className="ek-shell-display-card__facts" data-testid="display-setup-none">
No installed module shows content on screens yet. Once one is installed from Discover,
its setup address appears here.
</p>
) : undefined}
{setup.kind === "ready" && !none ? (
<>
<p className="ek-shell-setup__lead">
Open {several ? "an address" : "this address"} on the new screen, then add it with the
code it shows.
</p>
{setup.displays.map((display) => (
<SetupLink key={display.moduleId} labelled={several} setup={display} />
))}
</>
) : undefined}
{none || setup.kind === "loading" ? undefined : (
<div>
<Button data-testid="add-display" onClick={props.onAdd} variant="primary">
<ShellIcon name="addDisplay" size={18} />
Add display
</Button>
</div>
)}
{setup.kind === "ready" && !none ? (
<details className="ek-shell-setup__more" data-testid="display-setup-steps">
<summary className="ek-focusable">Step-by-step instructions</summary>
<ol className="ek-shell-setup-steps">
<li>
On the screen, open the address above in its web browser. Opening it on this device
only previews it.
</li>
<li>The screen shows a six-digit pairing code.</li>
<li>
Choose <strong>Add display</strong>, enter the code, and give the screen the name
everyone will know it by.
</li>
<li>It appears under Paired displays once it's approved.</li>
</ol>
</details>
) : undefined}
<p className="ek-shell-setup__lead">
Open this address on the new screen, then add it with the code it shows.
</p>
<SetupLink url={props.url} />
<div>
<Button data-testid="add-display" onClick={props.onAdd} variant="primary">
<ShellIcon name="addDisplay" size={18} />
Add display
</Button>
</div>
<details className="ek-shell-setup__more" data-testid="display-setup-steps">
<summary className="ek-focusable">Step-by-step instructions</summary>
<ol className="ek-shell-setup-steps">
<li>
On the screen, open the address above in its web browser. Opening it on this device
only previews it.
</li>
<li>The screen shows a six-digit pairing code.</li>
<li>
Choose <strong>Add display</strong>, enter the code, and give the screen the name
everyone will know it by. You can also choose what it shows.
</li>
<li>It appears under Paired displays once it's approved.</li>
</ol>
</details>
</div>
</Panel>
);
@@ -364,10 +391,21 @@ function DisplaySetupPanel(props: {
function DisplayCard(props: {
readonly device: HostDeviceSummary;
readonly now: Date;
readonly modules: DisplayModules;
readonly onRevoke: () => void;
}): ReactElement {
const { device, now } = props;
const facts: string[] = [];
if (!device.revoked && props.modules !== undefined) {
const shows = props.modules.filter((module) =>
device.permissions.includes(module.permissionId),
);
facts.push(
shows.length === 0
? "Not showing a module yet"
: `Shows ${shows.map((module) => module.name).join(", ")}`,
);
}
if (device.revoked) {
// The badge says so; nothing about when it was last seen is worth acting on any more.
} else if (device.lastSeenAt !== undefined) {
@@ -404,9 +442,11 @@ function DisplayCard(props: {
export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement {
const { config } = props;
const [list, setList] = useState<ListState>({ kind: "loading" });
const [setup, setSetup] = useState<SetupState>({ kind: "loading" });
const [modules, setModules] = useState<DisplayModules>(undefined);
const [adding, setAdding] = useState(false);
const [paired, setPaired] = useState<string | undefined>(undefined);
const [paired, setPaired] = useState<
{ readonly name: string; readonly shows: string | undefined } | undefined
>(undefined);
/** The display an administrator asked to revoke and has not yet confirmed. */
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
const [revoking, setRevoking] = useState(false);
@@ -432,17 +472,15 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
useEffect(() => {
const controller = new AbortController();
listHostDisplaySetup(config, controller.signal).then(
(displays) => {
// What a display can be set to show. If it cannot be read, pairing still works — the display is
// paired showing nothing yet — so a failure here only hides the choice.
listHostDisplayModules(config, controller.signal).then(
(found) => {
if (!controller.signal.aborted) {
setSetup({ displays, kind: "ready" });
}
},
() => {
if (!controller.signal.aborted) {
setSetup({ kind: "failed" });
setModules(found);
}
},
() => undefined,
);
return () => controller.abort();
}, [config]);
@@ -459,9 +497,9 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
setConfirming(undefined);
};
const onPaired = (name: string): void => {
const onPaired = (name: string, shows: string | undefined): void => {
setAdding(false);
setPaired(name);
setPaired({ name, shows });
// The new display appears without a page refresh (DESIGN.md §11.4).
void refresh();
};
@@ -482,7 +520,13 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
{paired === undefined ? undefined : (
<InlineNotice title="Display paired" tone="success">
<span data-testid="device-pairing-success">
{paired} is paired and can be used by your modules.
{paired.shows === undefined
? `${paired.name} is paired. It shows its name until a module is set to show on it.`
: `${paired.name} is paired and shows ${
modules?.find((module) => module.moduleId === paired.shows)?.name ??
props.moduleName(paired.shows) ??
paired.shows
}.`}
</span>
</InlineNotice>
)}
@@ -497,7 +541,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
setPaired(undefined);
setAdding(true);
}}
setup={setup}
url={displaySetupUrl(config)}
/>
) : undefined}
@@ -539,6 +583,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
<DisplayCard
device={device}
key={device.id}
modules={modules}
now={now}
onRevoke={() => setConfirming(device)}
/>
@@ -551,6 +596,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
<AddDisplayDialog
config={config}
moduleName={props.moduleName}
modules={modules}
onDismiss={dismissAdding}
onPaired={onPaired}
open={adding}
+1 -3
View File
@@ -24,9 +24,7 @@
* — the scroll position too.
*/
export interface ScrollLockRelease {
(options?: { readonly restoreScroll?: boolean }): void;
}
export type ScrollLockRelease = (options?: { readonly restoreScroll?: boolean }) => void;
const LOCKED_PROPERTIES = ["overflow", "overscroll-behavior"] as const;
@@ -0,0 +1,141 @@
import { type ReactElement, useEffect, useState } from "react";
import {
type EkklesiaHostConfig,
type HostDisplaySelf,
HostProtocolError,
loadDisplaySelf,
} from "../host-protocol.js";
import { DisplayNotice, PairingScreen } from "./PairingScreen.js";
/**
* The platform's own display setup page, `/display/setup` (ADR 0065): where any new screen starts,
* whatever modules are installed.
*
* It asks the platform who this screen is (`/api/v1/device-pairing/self`):
*
* - **Not paired** (or revoked): it shows a pairing code, asking to be paired as a platform display
* with no module. An administrator adds it in Settings → Displays, naming it and, optionally,
* choosing which installed module it shows.
* - **Paired, set to show a module:** it opens that module's display page, which takes over from
* there exactly as a screen that had opened it directly.
* - **Paired, nothing set yet:** it shows its name and waits. It keeps asking, every so often, so it
* opens the module as soon as one is set, notices if it is revoked, and keeps its credential
* alive while it waits (ADR 0056).
*
* The screen never says why something failed: anything other than "not paired" is retried.
*/
/** `/display/<name>` for the platform's setup page: a reserved module id in Core. */
export const DISPLAY_SETUP_ROUTE_NAME = "setup";
/** How often a paired screen with nothing to show asks again. */
export const DISPLAY_SETUP_RECHECK_MS = 30_000;
/** How long to wait before asking again after a failure that was not "not paired". */
export const DISPLAY_SETUP_RETRY_MS = 5_000;
type SetupState =
| { readonly status: "loading" }
| { readonly status: "pairing" }
| { readonly status: "waiting"; readonly name: string }
| { readonly status: "opening" }
| { readonly status: "unreachable" };
export interface DisplaySetupAppProps {
readonly config: EkklesiaHostConfig;
/** Opens a module display page in place of this one. The entry point supplies
* `location.replace`, so Back on a kiosk never returns here. */
readonly openDisplay: (url: string) => void;
/** For tests: how often to ask again while paired with nothing to show. */
readonly recheckMs?: number;
/** For tests: how long to wait after a failure. */
readonly retryMs?: number;
}
function isNotPaired(error: unknown): boolean {
return (
error instanceof HostProtocolError &&
(error.code === "HOST_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
);
}
export function DisplaySetupApp(props: DisplaySetupAppProps): ReactElement {
const { config, openDisplay } = props;
const recheckMs = props.recheckMs ?? DISPLAY_SETUP_RECHECK_MS;
const retryMs = props.retryMs ?? DISPLAY_SETUP_RETRY_MS;
const [state, setState] = useState<SetupState>({ status: "loading" });
/** Bumped to ask again: after pairing, on a recheck, or after a failure. */
const [attempt, setAttempt] = useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: `attempt` is the trigger to ask again.
useEffect(() => {
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const again = (delay: number): void => {
timer = setTimeout(() => setAttempt((value) => value + 1), delay);
};
loadDisplaySelf(config, abort.signal).then(
(self: HostDisplaySelf) => {
if (abort.signal.aborted) {
return;
}
const first = self.displays[0];
if (first !== undefined) {
setState({ status: "opening" });
openDisplay(first.url);
return;
}
setState({ name: self.name, status: "waiting" });
again(recheckMs);
},
(error: unknown) => {
if (abort.signal.aborted) {
return;
}
if (isNotPaired(error)) {
setState({ status: "pairing" });
return;
}
setState((current) => (current.status === "waiting" ? current : { status: "unreachable" }));
again(retryMs);
},
);
return () => {
abort.abort();
if (timer !== undefined) {
clearTimeout(timer);
}
};
}, [attempt, config, openDisplay, recheckMs, retryMs]);
if (state.status === "pairing") {
return (
<PairingScreen
config={config}
hint="Enter this code in Settings → Displays → Add display. Waiting for approval…"
moduleId={undefined}
onRetry={() => setAttempt((value) => value + 1)}
/>
);
}
if (state.status === "waiting") {
return (
<DisplayNotice
headline={state.name}
hint="Nothing is set to show here yet."
testId="display-setup-waiting"
title="This screen is paired"
/>
);
}
if (state.status === "unreachable") {
return (
<DisplayNotice
hint="Trying again…"
testId="display-setup-unreachable"
title="Connecting to the server"
/>
);
}
return <DisplayNotice testId="display-setup-loading" title="Preparing this display…" />;
}
+33 -2
View File
@@ -48,12 +48,42 @@ type PairingState =
export interface PairingScreenProps {
readonly config: EkklesiaHostConfig;
readonly moduleId: string;
/** The module display this screen is on, or `undefined` on the platform's own `/display/setup`,
* which pairs a platform display with no module yet (ADR 0065). */
readonly moduleId: string | undefined;
/** What the screen tells whoever is setting it up. */
readonly hint?: string;
/** Called once the host has issued this device its session, so the display mounts itself without
* anyone touching the television. */
readonly onRetry: () => void;
}
/**
* A calm full-screen message in the pairing screen's own style: a title, an optional large line,
* and a hint. For the platform setup page's "paired, nothing to show yet" (ADR 0065).
*/
export function DisplayNotice(props: {
readonly title: string;
readonly headline?: string;
readonly hint?: string;
readonly testId: string;
}): ReactElement {
return (
<div data-testid={props.testId} style={pairingStyles.root}>
<p style={pairingStyles.title}>{props.title}</p>
{props.headline === undefined ? undefined : (
<p
data-testid={`${props.testId}-headline`}
style={{ ...pairingStyles.code, fontSize: "clamp(2rem, 8vw, 5rem)", letterSpacing: 0 }}
>
{props.headline}
</p>
)}
{props.hint === undefined ? undefined : <p style={pairingStyles.hint}>{props.hint}</p>}
</div>
);
}
export function PairingScreen(props: PairingScreenProps): ReactElement {
const { config, moduleId, onRetry } = props;
const [state, setState] = useState<PairingState>({ status: "requesting" });
@@ -137,7 +167,8 @@ export function PairingScreen(props: PairingScreenProps): ReactElement {
{state.code}
</p>
<p style={pairingStyles.hint}>
Enter this code in the Ekklesia dashboard to connect this screen. Waiting for approval…
{props.hint ??
"Enter this code in the Ekklesia dashboard to connect this screen. Waiting for approval…"}
</p>
</>
) : undefined}
+6 -1
View File
@@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client";
import type { EkklesiaHostConfig } from "../host-protocol.js";
import { parseDisplayRoute } from "../host-protocol.js";
import { DisplayApp } from "./DisplayApp.js";
import { DISPLAY_SETUP_ROUTE_NAME, DisplaySetupApp } from "./DisplaySetupApp.js";
import { type DisplayConnectivitySource, registerDisplayServiceWorker } from "./offline-status.js";
/**
@@ -70,7 +71,11 @@ createEkklesiaThemeController({
createRoot(container).render(
<>
<EkklesiaUiStyles />
{moduleId ? (
{moduleId === DISPLAY_SETUP_ROUTE_NAME ? (
// The platform's own setup page (ADR 0065). `setup` is a reserved module id, so no module's
// display can live at this address.
<DisplaySetupApp config={config} openDisplay={(url) => window.location.replace(url)} />
) : moduleId ? (
<DisplayApp config={config} connectivity={connectivity} moduleId={moduleId} />
) : (
<div
+98 -23
View File
@@ -456,13 +456,15 @@ async function requestPlatformMutation(
*/
export async function requestDevicePairing(
config: EkklesiaHostConfig,
moduleId: string,
/** The module display this screen is on, or `undefined` on the platform's own `/display/setup`,
* which pairs a platform display with no module yet (ADR 0065). */
moduleId: string | undefined,
signal?: AbortSignal,
): Promise<{ readonly code: string; readonly pollIntervalMs: number }> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/requests",
{ moduleId },
moduleId === undefined ? {} : { moduleId },
signal,
);
if (!hasExactKeys(payload, ["code", "expiresAt", "pollIntervalMs"])) {
@@ -524,11 +526,28 @@ function optionalTimestamp(value: unknown): Date | undefined {
return Number.isNaN(date.getTime()) ? undefined : date;
}
/** A null-or-string field from the server: the string, `undefined` for `null`, or an error. */
function nullableString(value: unknown): string | undefined {
if (value === null) {
return undefined;
}
const text = nonEmptyString(value);
if (text === undefined) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return text;
}
/**
* Looks up a screen waiting to be paired by the code it shows. `moduleId` is the module display it
* asked from, or `undefined` for a platform display from `/display/setup` (ADR 0065), for which the
* administrator may choose a module.
*/
export async function lookupDevicePairing(
config: EkklesiaHostConfig,
code: string,
signal?: AbortSignal,
): Promise<{ readonly moduleId: string; readonly permissionId: string }> {
): Promise<{ readonly moduleId: string | undefined; readonly permissionId: string | undefined }> {
const payload = await requestPlatformMutation(
config,
"/api/v1/device-pairing/lookups",
@@ -538,24 +557,27 @@ export async function lookupDevicePairing(
if (!hasExactKeys(payload, ["expiresAt", "moduleId", "permissionId", "requestedAt"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const moduleId = nonEmptyString(payload.moduleId);
const permissionId = nonEmptyString(payload.permissionId);
if (moduleId === undefined || permissionId === undefined) {
const moduleId = nullableString(payload.moduleId);
const permissionId = nullableString(payload.permissionId);
if ((moduleId === undefined) !== (permissionId === undefined)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ moduleId, permissionId });
}
/** Approves a waiting screen and names it. `moduleId`, for a platform display, is the installed
* display module the administrator chose for it to show (ADR 0065); omitted, it shows nothing yet. */
export async function approveDevicePairing(
config: EkklesiaHostConfig,
code: string,
deviceName: string,
moduleId?: string,
signal?: AbortSignal,
): Promise<void> {
await requestPlatformMutation(
config,
"/api/v1/device-pairing/approvals",
{ code, deviceName },
moduleId === undefined ? { code, deviceName } : { code, deviceName, moduleId },
signal,
);
}
@@ -622,46 +644,99 @@ export async function revokeHostDevice(
}
}
/** The root-relative address of the platform's own display setup page (ADR 0065). */
export const DISPLAY_SETUP_PATH = "/display/setup";
/**
* Where a new screen starts pairing, for one module that shows content on screens (ADR 0065): its
* name, and the absolute address of the platform display page a screen opens to show its pairing
* code. The address is the API origin's, because the display host lives there in both topologies
* The absolute address a new screen opens to start pairing, whatever is installed: the platform's
* `/display/setup` on the API origin, which is where the display host lives in both topologies
* (ADR 0045).
*/
export interface HostDisplaySetup {
export function displaySetupUrl(config: EkklesiaHostConfig): string {
return resolveApiUrl(config, DISPLAY_SETUP_PATH);
}
/** One installed module that shows content on screens: what a display can be set to show. */
export interface HostDisplayModule {
readonly moduleId: string;
readonly name: string;
readonly url: string;
/** The display permission a device needs to show it; a paired display holding it shows it. */
readonly permissionId: string;
}
/**
* The display pages a new screen can open (`GET /api/v1/devices/setup`), for Settings → Displays.
* Requires `core.devices.manage`, like the device list. An empty list means no installed module
* uses displays yet.
* The modules a display can be set to show (`GET /api/v1/devices/display-modules`, ADR 0065), for
* the Add display choice and for naming what each paired display shows. Requires
* `core.devices.manage`. An empty list means no installed module uses displays yet.
*/
export async function listHostDisplaySetup(
export async function listHostDisplayModules(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<readonly HostDisplaySetup[]> {
const payload = await requestPlatformJson(config, "/api/v1/devices/setup", signal, {
): Promise<readonly HostDisplayModule[]> {
const payload = await requestPlatformJson(config, "/api/v1/devices/display-modules", signal, {
forbidden: "HOST_ACCESS_DENIED",
});
if (!hasExactKeys(payload, ["displays"]) || !Array.isArray(payload.displays)) {
if (!hasExactKeys(payload, ["modules"]) || !Array.isArray(payload.modules)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze(
payload.displays.map((entry) => {
payload.modules.map((entry) => {
const moduleId = nonEmptyString((entry as { moduleId?: unknown }).moduleId);
const name = nonEmptyString((entry as { name?: unknown }).name);
const path = rootRelativePath(entry, "url");
if (!hasExactKeys(entry, ["moduleId", "name", "url"]) || !moduleId || !name || !path) {
const permissionId = nonEmptyString((entry as { permissionId?: unknown }).permissionId);
if (
!hasExactKeys(entry, ["moduleId", "name", "permissionId"]) ||
!moduleId ||
!name ||
!permissionId
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ moduleId, name, url: resolveApiUrl(config, path) });
return Object.freeze({ moduleId, name, permissionId });
}),
);
}
/** A paired display as it sees itself (ADR 0065): its name, and the module displays it may show. */
export interface HostDisplaySelf {
readonly name: string;
readonly displays: readonly { readonly moduleId: string; readonly url: string }[];
}
/**
* Asks the platform who this paired display is (`GET /api/v1/device-pairing/self`). Answered only
* for a device; anyone else — an unpaired or revoked screen, or a person — is `HOST_ACCESS_DENIED`.
* Each display's `url` is the root-relative module display page, on this same origin.
*/
export async function loadDisplaySelf(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<HostDisplaySelf> {
const payload = await requestPlatformJson(config, "/api/v1/device-pairing/self", signal, {
forbidden: "HOST_ACCESS_DENIED",
});
if (!hasExactKeys(payload, ["displays", "name"]) || !Array.isArray(payload.displays)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const name = nonEmptyString(payload.name);
if (name === undefined) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
displays: Object.freeze(
payload.displays.map((entry) => {
const moduleId = nonEmptyString((entry as { moduleId?: unknown }).moduleId);
const url = rootRelativePath(entry, "url");
if (!hasExactKeys(entry, ["moduleId", "url"]) || !moduleId || !url) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ moduleId, url });
}),
),
name,
});
}
/** One installed module as module lifecycle reports it (ADR 0028): what it is and whether it runs. */
export interface HostModuleLifecycleSummary {
readonly id: string;
+2
View File
@@ -18,6 +18,7 @@ export {
type DashboardNavigationEntry,
type DashboardNavigationGroup,
type DisplayHostBootstrap,
DISPLAY_SETUP_PATH,
type EkklesiaHostConfig,
type HostAccount,
type HostAccountPreferences,
@@ -30,6 +31,7 @@ export {
type HostProtocolErrorCode,
loadDashboardNavigation,
loadDashboardSettings,
loadDisplaySelf,
loadHostAccount,
loadHostPrincipal,
loadModuleDisplay,