Manage paired displays: themed select, reassignment, live list, no revoked clutter
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s

Real-device review of the platform display flow found four defects in
display management:

- Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn
  single choice following the WAI-ARIA select-only combobox pattern,
  replacing the native select for "Show on this screen". It has a
  themed list, a chevron inside the control, keyboard support (arrows,
  Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only
  the list), pointer and touch support, and focus that stays on the
  control. It is an ek-control, so it keeps the 16px phone size.
- Newly paired displays appear without a reload. Approval doesn't
  create the display; the screen does when it claims its credential a
  few seconds later. The page now shows a "Waiting for the screen to
  connect" placeholder and refetches every second until the new display
  appears (for up to a minute), and refreshes the list every 30
  seconds.
- What a display shows can change after pairing. In the single-target
  Stage 1 model, a display's current target is its one active display
  permission, so no migration is needed.
  PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted
  origin and a bounded body, revokes its other active permission rows
  (kept as history) and grants the chosen module's declared display
  permission, in one transaction on the same device. The screen notices
  within about 30 seconds: a paired device on a module page that is no
  longer set to show it goes to /display/setup instead of re-pairing,
  and /display/setup opens the new target.
- Revoked displays leave the normal list at once. Their rows stay in
  the database as history.

Each display card now shows its name, Online/Offline/Not connected yet,
"Showing: ...", last seen and paired dates, and Change and Revoke.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 18:29:15 -05:00
co-authored by Claude Opus 5.5
parent 300d9eab51
commit d9dafcb0f7
18 changed files with 1892 additions and 197 deletions
@@ -11,7 +11,7 @@ import {
PageHeader,
type PageHeaderBack,
Panel,
Select,
SelectMenu,
TextInput,
} from "@ekklesia/ui";
import {
@@ -35,6 +35,7 @@ import {
listHostDisplayModules,
lookupDevicePairing,
revokeHostDevice,
setHostDisplayTarget,
} from "../host-protocol.js";
import { calendarDate, lastSeenPhrase } from "./display-time.js";
import { ShellIcon } from "./shell-icons.js";
@@ -81,6 +82,35 @@ type PairingStep =
/** The "Nothing yet" choice: a platform display that shows nothing until a module is set for it. */
const NO_MODULE = "";
/** "Show on this screen": nothing yet, or one of the installed display modules. */
function targetOptions(modules: readonly HostDisplayModule[]) {
return [
{ label: "Nothing yet", value: NO_MODULE },
...modules.map((module) => ({ label: module.name, value: module.moduleId })),
];
}
/** The module a display shows now: the one installed display module whose permission it holds. In
* this single-target model a display's active display permission is its current target (ADR 0065). */
function currentTarget(
device: HostDeviceSummary,
modules: readonly HostDisplayModule[],
): HostDisplayModule | undefined {
return modules.find((module) => device.permissions.includes(module.permissionId));
}
/** How long after it was last in touch a display still counts as online. The server records
* last-seen at most every 15 minutes (`DEVICE_LAST_SEEN_THROTTLE_MS`), so anything finer would
* claim more than is known. */
const ONLINE_WITHIN_MS = 30 * 60_000;
/** How long after a pairing is approved the page keeps checking for the screen to collect its
* credential, which is when the display comes into being (ADR 0036). */
const AWAIT_PAIRED_MS = 60_000;
/** How often the page refreshes its list on its own, so status and changes made elsewhere show. */
const LIST_REFRESH_MS = 30_000;
export interface DisplaysSettingsPageProps {
readonly config: EkklesiaHostConfig;
/** Back to Settings, or to where a validated return target says (ADR 0063). */
@@ -89,6 +119,10 @@ export interface DisplaysSettingsPageProps {
readonly moduleName: (moduleId: string) => string | undefined;
/** For tests: the clock "last seen" is measured against. */
readonly now?: () => Date;
/** For tests: how soon to check again for a display that was just approved. */
readonly pollMs?: number;
/** For tests: how often the list refreshes on its own (0 turns it off). */
readonly refreshMs?: number;
}
/**
@@ -263,24 +297,18 @@ 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."
hint="You can change this later from the display's Change action."
label="Show on this screen"
>
{(control) => (
<Select
<SelectMenu
{...control}
data-testid="device-pairing-shows"
disabled={busy}
onChange={(event) => setShows(event.target.value)}
onChange={setShows}
options={targetOptions(modules)}
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}
@@ -388,30 +416,30 @@ function DisplaySetupPanel(props: {
);
}
/**
* One active display: its one name, whether it is online (coarsely — see {@link ONLINE_WITHIN_MS}),
* what it shows now, and the two things an administrator does with it — change what it shows, and
* revoke it. Revoked displays are not listed: they are history, not something to manage.
*/
function DisplayCard(props: {
readonly device: HostDeviceSummary;
readonly now: Date;
readonly modules: DisplayModules;
readonly onRevoke: () => void;
readonly onChange: (event: MouseEvent<HTMLButtonElement>) => void;
readonly onRevoke: (event: MouseEvent<HTMLButtonElement>) => void;
}): ReactElement {
const { device, now } = props;
const { device, now, modules } = props;
const seen = device.lastSeenAt;
const status =
seen === undefined
? { label: "Not connected yet", tone: "neutral" as const }
: now.getTime() - seen.getTime() <= ONLINE_WITHIN_MS
? { label: "Online", tone: "success" as const }
: { label: "Offline", tone: "warning" as const };
const target = modules === undefined ? undefined : currentTarget(device, modules);
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) {
facts.push(`Last seen ${lastSeenPhrase(device.lastSeenAt, now)}`);
} else {
facts.push("Not seen since it was paired");
if (seen !== undefined) {
facts.push(`Last seen ${lastSeenPhrase(seen, now)}`);
}
if (device.pairedAt !== undefined) {
facts.push(`Paired ${calendarDate(device.pairedAt)}`);
@@ -424,38 +452,166 @@ function DisplayCard(props: {
<div className="ek-shell-display-card__copy">
<div className="ek-shell-display-card__title">
<span className="ek-shell-display-card__name">{device.name}</span>
<Badge tone={device.revoked ? "neutral" : "success"}>
{device.revoked ? "Revoked" : "Paired"}
</Badge>
<Badge tone={status.tone}>{status.label}</Badge>
</div>
<p className="ek-shell-display-card__facts">{facts.join(" · ")}</p>
{modules === undefined ? undefined : (
<p className="ek-shell-display-card__showing" data-testid={`device-${device.id}-showing`}>
Showing: <strong>{target?.name ?? "Nothing yet"}</strong>
</p>
)}
{facts.length === 0 ? undefined : (
<p className="ek-shell-display-card__facts">{facts.join(" · ")}</p>
)}
</div>
{device.revoked ? undefined : (
<div className="ek-shell-display-card__actions">
{modules === undefined ? undefined : (
<Button data-testid={`device-change-${device.id}`} onClick={props.onChange} size="sm">
Change
</Button>
)}
<Button data-testid={`device-revoke-${device.id}`} onClick={props.onRevoke} size="sm">
Revoke
</Button>
)}
</div>
</li>
);
}
/** "Change what Foyer TV shows": the one current target, nothing yet or an installed module. */
function ChangeTargetDialog(props: {
readonly config: EkklesiaHostConfig;
readonly device: HostDeviceSummary | undefined;
readonly modules: readonly HostDisplayModule[];
readonly onDismiss: () => void;
readonly onChanged: (device: HostDeviceSummary, shows: HostDisplayModule | undefined) => void;
}): ReactElement {
const { config, device, modules, onChanged } = props;
const initial =
device === undefined ? NO_MODULE : (currentTarget(device, modules)?.moduleId ?? NO_MODULE);
const [shows, setShows] = useState<string>(initial);
const [saving, setSaving] = useState(false);
const [failed, setFailed] = useState(false);
const selectRef = useRef<HTMLDivElement>(null);
const initialFocusRef = useRef<HTMLElement | null>(null);
// Each time it opens, it starts from what the display shows now.
useEffect(() => {
setShows(initial);
setFailed(false);
setSaving(false);
initialFocusRef.current =
selectRef.current?.querySelector<HTMLElement>('[role="combobox"]') ?? null;
}, [initial]);
const save = async (): Promise<void> => {
if (device === undefined) {
return;
}
setSaving(true);
setFailed(false);
try {
await setHostDisplayTarget(config, device.id, shows === NO_MODULE ? undefined : shows);
onChanged(
device,
modules.find((module) => module.moduleId === shows),
);
} catch {
setFailed(true);
setSaving(false);
}
};
return (
<Dialog
actions={
<>
<Button data-testid="device-change-cancel" disabled={saving} onClick={props.onDismiss}>
Cancel
</Button>
<Button
data-testid="device-change-save"
disabled={saving || shows === initial}
onClick={() => void save()}
variant="primary"
>
Save
</Button>
</>
}
data-testid="device-change-dialog"
description="The screen switches on its own within a minute. It stays the same display, with the same name."
dismissible={!saving}
initialFocusRef={initialFocusRef}
onDismiss={props.onDismiss}
open={device !== undefined}
title={`Change what ${device?.name ?? "this display"} shows`}
>
<div ref={selectRef}>
<Field label="Show on this screen">
{(control) => (
<SelectMenu
{...control}
data-testid="device-change-shows"
disabled={saving}
onChange={setShows}
options={targetOptions(modules)}
value={shows}
/>
)}
</Field>
</div>
{failed ? (
<InlineNotice tone="danger">
<span data-testid="device-change-error">
That change could not be saved. Try again in a moment.
</span>
</InlineNotice>
) : undefined}
</Dialog>
);
}
/** What the page last told the administrator, after a pairing, a change, or a revocation. */
type Outcome =
| { readonly kind: "paired"; readonly name: string; readonly shows: string | undefined }
| { readonly kind: "changed"; readonly name: string; readonly shows: string | undefined }
| { readonly kind: "revoked"; readonly name: string };
/**
* A pairing approved a moment ago. The display itself comes into being only when the screen
* collects its credential on its next poll (ADR 0036), a few seconds later, so the page checks again
* until a display it did not have before appears — and shows a placeholder in its place meanwhile.
*/
interface Awaiting {
readonly name: string;
readonly known: ReadonlySet<string>;
readonly until: number;
}
export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactElement {
const { config } = props;
const pollMs = props.pollMs ?? 1000;
const refreshMs = props.refreshMs ?? LIST_REFRESH_MS;
const [list, setList] = useState<ListState>({ kind: "loading" });
const [modules, setModules] = useState<DisplayModules>(undefined);
const [adding, setAdding] = useState(false);
const [paired, setPaired] = useState<
{ readonly name: string; readonly shows: string | undefined } | undefined
>(undefined);
const [outcome, setOutcome] = useState<Outcome | undefined>(undefined);
const [awaiting, setAwaiting] = useState<Awaiting | undefined>(undefined);
/** The display whose content an administrator is changing. */
const [changing, setChanging] = useState<HostDeviceSummary | 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);
const outcomeRef = useRef<HTMLDivElement>(null);
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
const dismissAdding = useCallback(() => setAdding(false), []);
const dismissChanging = useCallback(() => setChanging(undefined), []);
const refresh = useCallback(async (): Promise<void> => {
const refresh = useCallback(async (): Promise<readonly HostDeviceSummary[] | undefined> => {
try {
setList({ devices: await listHostDevices(config), kind: "ready" });
const devices = await listHostDevices(config);
setList({ devices, kind: "ready" });
return devices;
} catch (error) {
setList({
kind:
@@ -463,6 +619,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
? "denied"
: "failed",
});
return undefined;
}
}, [config]);
@@ -470,6 +627,15 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
void refresh();
}, [refresh]);
// The page keeps itself current — status, and changes made from another tab — on its own.
useEffect(() => {
if (refreshMs <= 0) {
return;
}
const timer = setInterval(() => void refresh(), refreshMs);
return () => clearInterval(timer);
}, [refresh, refreshMs]);
useEffect(() => {
const controller = new AbortController();
// What a display can be set to show. If it cannot be read, pairing still works — the display is
@@ -485,25 +651,88 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
return () => controller.abort();
}, [config]);
const revoke = async (deviceId: string): Promise<void> => {
/** Revoked displays are history, not something to manage: the list shows active ones only. */
const active = list.kind === "ready" ? list.devices.filter((device) => !device.revoked) : [];
// While a just-approved display has not appeared, check again shortly — each answer replaces the
// list, so it can never appear twice — and stop once it is there, or after a while regardless.
const arrived = awaiting !== undefined && active.some((device) => !awaiting.known.has(device.id));
// biome-ignore lint/correctness/useExhaustiveDependencies: each new list answer is what schedules the next check, so `list` is the trigger even though it is not read here.
useEffect(() => {
if (awaiting === undefined) {
return;
}
if (arrived || Date.now() > awaiting.until) {
setAwaiting(undefined);
return;
}
const timer = setTimeout(() => void refresh(), pollMs);
return () => clearTimeout(timer);
}, [arrived, awaiting, list, pollMs, refresh]);
// The control that started a revocation is gone with its card, so focus lands on what happened
// once it is on screen, without scrolling the page.
const focusOutcome = useRef(false);
useEffect(() => {
if (outcome !== undefined && focusOutcome.current) {
focusOutcome.current = false;
outcomeRef.current?.focus({ preventScroll: true });
}
}, [outcome]);
const showOutcome = (next: Outcome): void => {
focusOutcome.current = true;
setOutcome(next);
};
const revoke = async (device: HostDeviceSummary): Promise<void> => {
setRevoking(true);
try {
await revokeHostDevice(config, deviceId);
await revokeHostDevice(config, device.id);
} catch {
// Left to the refresh below rather than asserting a failure the operator cannot act on.
}
await refresh();
const devices = await refresh();
setRevoking(false);
setConfirming(undefined);
if (devices?.find((candidate) => candidate.id === device.id)?.revoked ?? true) {
showOutcome({ kind: "revoked", name: device.name });
}
};
const onPaired = (name: string, shows: string | undefined): void => {
setAdding(false);
setPaired({ name, shows });
setOutcome({ kind: "paired", name, shows });
setAwaiting({
known: new Set(list.kind === "ready" ? list.devices.map((device) => device.id) : []),
name,
until: Date.now() + AWAIT_PAIRED_MS,
});
// The new display appears without a page refresh (DESIGN.md §11.4).
void refresh();
};
const onChanged = (device: HostDeviceSummary, shows: HostDisplayModule | undefined): void => {
setChanging(undefined);
setOutcome({ kind: "changed", name: device.name, shows: shows?.moduleId });
void refresh();
};
/** Focuses the control that opens a dialog before it opens, so the dialog returns focus there —
* some browsers (Safari) do not focus a clicked button — without scrolling the page. */
const opener =
(open: () => void) =>
(event: MouseEvent<HTMLButtonElement>): void => {
event.currentTarget.focus({ preventScroll: true });
open();
};
const moduleLabel = (moduleId: string | undefined): string | undefined =>
moduleId === undefined
? undefined
: (modules?.find((module) => module.moduleId === moduleId)?.name ??
props.moduleName(moduleId) ??
moduleId);
const now = props.now?.() ?? new Date();
const canAdd = list.kind === "ready";
return (
@@ -517,30 +746,40 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
title="Displays"
/>
{paired === undefined ? undefined : (
<InlineNotice title="Display paired" tone="success">
<span data-testid="device-pairing-success">
{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>
{outcome === undefined ? undefined : (
<div data-testid="device-outcome" ref={outcomeRef} tabIndex={-1}>
{outcome.kind === "paired" ? (
<InlineNotice title="Display paired" tone="success">
<span data-testid="device-pairing-success">
{outcome.shows === undefined
? `${outcome.name} is paired. It shows its name until a module is set to show on it.`
: `${outcome.name} is paired and shows ${moduleLabel(outcome.shows)}.`}
</span>
</InlineNotice>
) : outcome.kind === "changed" ? (
<InlineNotice title="Display changed" tone="success">
<span data-testid="device-change-success">
{outcome.shows === undefined
? `${outcome.name} now shows nothing. It shows its name until a module is set.`
: `${outcome.name} now shows ${moduleLabel(outcome.shows)}.`}
</span>
</InlineNotice>
) : (
<InlineNotice title="Display revoked" tone="info">
<span data-testid="device-revoke-success">
{outcome.name} is revoked. Its screen returns to its pairing code.
</span>
</InlineNotice>
)}
</div>
)}
{canAdd ? (
<DisplaySetupPanel
onAdd={(event) => {
// Focus returns here when the dialog closes. Some browsers (Safari) do not focus a button
// that is clicked, so it is focused deliberately before the dialog opens — without
// scrolling, which would move a page whose button sits under the phone header.
event.currentTarget.focus({ preventScroll: true });
setPaired(undefined);
onAdd={opener(() => {
setOutcome(undefined);
setAdding(true);
}}
})}
url={displaySetupUrl(config)}
/>
) : undefined}
@@ -570,7 +809,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
) : undefined}
{list.kind === "ready" ? (
<Panel title="Paired displays">
{list.devices.length === 0 ? (
{active.length === 0 && awaiting === undefined ? (
<div data-testid="device-list-empty">
<EmptyState
description="Set up a screen above, and it appears here once it's approved."
@@ -579,15 +818,35 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
</div>
) : (
<ul className="ek-shell-display-list" data-testid="device-list">
{list.devices.map((device) => (
{active.map((device) => (
<DisplayCard
device={device}
key={device.id}
modules={modules}
now={now}
onRevoke={() => setConfirming(device)}
onChange={opener(() => {
setOutcome(undefined);
setChanging(device);
})}
onRevoke={opener(() => setConfirming(device))}
/>
))}
{awaiting !== undefined && !arrived ? (
<li
className="ek-shell-display-card ek-shell-display-card--pending"
data-testid="device-awaiting"
>
<span aria-hidden="true" className="ek-shell-display-card__icon">
<ShellIcon name="settingsDisplays" />
</span>
<div className="ek-shell-display-card__copy">
<span className="ek-shell-display-card__name">{awaiting.name}</span>
<p className="ek-shell-display-card__facts" role="status">
Waiting for the screen to connect…
</p>
</div>
</li>
) : undefined}
</ul>
)}
</Panel>
@@ -602,6 +861,16 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
open={adding}
/>
{modules === undefined ? undefined : (
<ChangeTargetDialog
config={config}
device={changing}
modules={modules}
onChanged={onChanged}
onDismiss={dismissChanging}
/>
)}
{/* Revoking strands the screen until someone re-pairs it in person, so the consequence is
spelled out before it happens — never a browser-native confirm. */}
<Dialog
@@ -617,7 +886,7 @@ export function DisplaysSettingsPage(props: DisplaysSettingsPageProps): ReactEle
<Button
data-testid="device-revoke-confirm"
disabled={revoking}
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
onClick={() => (confirming ? void revoke(confirming) : undefined)}
variant="danger"
>
Revoke display
@@ -338,6 +338,24 @@ export const SHELL_STYLESHEET = `
overflow-wrap: anywhere;
}
.ek-shell-display-card__showing {
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
.ek-shell-display-card__actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: ${designToken("spacing.xs")};
}
/* A display just approved, waiting for its screen to collect its credential. */
.ek-shell-display-card--pending {
border-style: dashed;
}
.ek-shell-display-card__facts {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
+80 -4
View File
@@ -3,9 +3,11 @@ import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import {
bootstrapDisplayHost,
DISPLAY_SETUP_PATH,
type DisplayHostBootstrap,
type EkklesiaHostConfig,
HostProtocolError,
loadDisplaySelf,
} from "../host-protocol.js";
import { DisplayUiFrame } from "./DisplayUiFrame.js";
import {
@@ -26,6 +28,9 @@ type DisplayState =
}
/** No identity this display can use yet: show a pairing code rather than a human login form. */
| { readonly status: "pairing" }
/** Still a paired display, but set to show something else now: on its way to the platform's
* setup page, which opens whatever it shows now (ADR 0065). */
| { readonly status: "moving" }
| { readonly status: "unavailable" }
| { readonly status: "failed" };
@@ -47,8 +52,17 @@ export interface DisplayAppProps {
/** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam
* only. */
readonly retryBaseMs?: number;
/** Opens another display page in place of this one. The entry point supplies `location.replace`;
* without it a display changed to show something else falls back to showing a pairing code. */
readonly openDisplay?: (url: string) => void;
/** How often a paired screen asks the platform what it should show. Test seam only. */
readonly assignmentCheckMs?: number;
}
/** A paired screen asks the platform this often whether it should still show this module, so a
* change in Settings → Displays reaches it within this interval (ADR 0065). */
export const DISPLAY_ASSIGNMENT_CHECK_MS = 30_000;
/** 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;
@@ -210,7 +224,32 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS,
retryBaseMs = DISPLAY_RETRY_BASE_MS,
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
openDisplay,
assignmentCheckMs = DISPLAY_ASSIGNMENT_CHECK_MS,
} = props;
/**
* Refused (ADR 0065): either the screen is not paired at all — revoked, expired, or never — and
* shows a pairing code as it always has, or it is still a paired display that is now set to show
* something else, and goes to the platform's setup page, which opens whatever it shows now. Only
* the platform can tell the two apart, so this asks it; a person previewing the page is never a
* device, and keeps the old behaviour.
*/
const onDenied = useCallback(async (): Promise<void> => {
if (openDisplay !== undefined) {
try {
const self = await loadDisplaySelf(config);
if (!self.displays.some((display) => display.moduleId === moduleId)) {
setState({ status: "moving" });
openDisplay(DISPLAY_SETUP_PATH);
return;
}
} catch {
// Not a paired display: pair, as before.
}
}
setState({ status: "pairing" });
}, [config, moduleId, openDisplay]);
/** Consecutive automatic retries since the display last started successfully. */
const failures = useRef(0);
const stateRef = useRef(state);
@@ -261,7 +300,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
})
.catch((error: unknown) => {
if (!abort.signal.aborted && isDenied(error)) {
setState({ status: "pairing" });
void onDenied();
}
})
.finally(() => {
@@ -270,7 +309,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
}
});
},
[config, moduleId, revalidationIntervalMs],
[config, moduleId, onDenied, revalidationIntervalMs],
);
useEffect(
@@ -292,6 +331,40 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
return () => clearInterval(timer);
}, [periodicRevalidationMs, revalidate, state.status]);
// A paired screen asks the platform, now and then, whether it should still show this module, so a
// change made in Settings → Displays reaches it without anyone touching the screen (ADR 0065). A
// person previewing the page is not a device, and is never moved.
const viewerIsDevice = state.status === "ready" && state.bootstrap.principal.kind === "device";
useEffect(() => {
if (!viewerIsDevice || openDisplay === undefined || assignmentCheckMs <= 0) {
return;
}
const abort = new AbortController();
const timer = setInterval(() => {
loadDisplaySelf(config, abort.signal).then(
(self) => {
if (
!abort.signal.aborted &&
!self.displays.some((display) => display.moduleId === moduleId)
) {
setState({ status: "moving" });
openDisplay(DISPLAY_SETUP_PATH);
}
},
(error: unknown) => {
// Revoked: the descriptor will say so too, and return the screen to pairing.
if (!abort.signal.aborted && isDenied(error)) {
revalidate("authorization");
}
},
);
}, assignmentCheckMs);
return () => {
abort.abort();
clearInterval(timer);
};
}, [assignmentCheckMs, config, moduleId, openDisplay, revalidate, viewerIsDevice]);
// 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(() => {
@@ -349,7 +422,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 (isDenied(error)) {
setState({ status: "pairing" });
void onDenied();
return;
}
console.error("[ekklesia] display host bootstrap failed", error);
@@ -360,7 +433,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
);
});
return () => abort.abort();
}, [attempt, config, moduleId]);
}, [attempt, config, moduleId, onDenied]);
const retry = (): void => setAttempt((current) => current + 1);
const showOffline = useSustained(
@@ -377,6 +450,9 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
{state.status === "pairing" ? (
<PairingScreen config={config} moduleId={moduleId} onRetry={retry} />
) : undefined}
{state.status === "moving" ? (
<DisplayMessage testId="display-host-moving">Switching this display…</DisplayMessage>
) : undefined}
{state.status === "unavailable" ? (
<DisplayMessage onRetry={retry} testId="display-host-unavailable">
This display is unavailable.
+6 -1
View File
@@ -76,7 +76,12 @@ createRoot(container).render(
// display can live at this address.
<DisplaySetupApp config={config} openDisplay={(url) => window.location.replace(url)} />
) : moduleId ? (
<DisplayApp config={config} connectivity={connectivity} moduleId={moduleId} />
<DisplayApp
config={config}
connectivity={connectivity}
moduleId={moduleId}
openDisplay={(url) => window.location.replace(url)}
/>
) : (
<div
data-testid="display-route-invalid"
+21 -1
View File
@@ -417,7 +417,7 @@ async function requestPlatformMutation(
rootRelativePath: string,
body: unknown,
signal?: AbortSignal,
method: "PATCH" | "POST" = "POST",
method: "PATCH" | "POST" | "PUT" = "POST",
): Promise<unknown> {
const performRequest = config.fetch ?? fetch;
let response: Response;
@@ -737,6 +737,26 @@ export async function loadDisplaySelf(
});
}
/**
* Changes what a paired display shows (`PUT /api/v1/devices/<id>/display`, ADR 0065): an installed
* display module, or `undefined` for nothing. The same display — same identity, name, and
* credential — before and after; the screen picks the change up on its own.
*/
export async function setHostDisplayTarget(
config: EkklesiaHostConfig,
deviceId: string,
moduleId: string | undefined,
signal?: AbortSignal,
): Promise<void> {
await requestPlatformMutation(
config,
`/api/v1/devices/${encodeURIComponent(deviceId)}/display`,
{ moduleId: moduleId ?? null },
signal,
"PUT",
);
}
/** One installed module as module lifecycle reports it (ADR 0028): what it is and whether it runs. */
export interface HostModuleLifecycleSummary {
readonly id: string;