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
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:
co-authored by
Claude Opus 5.5
parent
906c015ca8
commit
300d9eab51
@@ -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}
|
||||
|
||||
@@ -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…" />;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user