diff --git a/CLAUDE.md b/CLAUDE.md index 43e2af8..ffdab92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,27 +6,60 @@ contents, because restated details drift. ## What this repository is -Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript -monorepo: +Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the +API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built +as a strictly layered pnpm/TypeScript monorepo: - **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration - execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a - feature module, a surface, or an app. -- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product - packages. They own their own data and migrations, declare capabilities/permissions through - `@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a - surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional - primary and full-viewport display artifacts, authored in React against the same role-neutral - `@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while - `pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source. + execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never + imports a feature module, a surface, or an app. +- **Modules** are independently versioned product packages. First-party modules live in **their own + repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages + (ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage + outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the + example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns + its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through + `@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a + surface. Manifest v8 permits independently optional primary and full-viewport display artifacts, + authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the + Module Database ABI v1 (ADR 0050). - **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every - presentation concern a module does not. They consume public API/client contracts only, never Core - or module internals directly. + presentation concern a module does not. The shell **hosts** module UI through the generic loader; + it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only. These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just documented. Read `docs/architecture/repository-boundaries.md` and `docs/architecture/platform-architecture.md` before proposing any change that crosses a layer. +## Product direction lives beside this repository + +The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the +parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard +`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into +this repository to make them available. They are authoritative for product direction: read the +relevant stages and sections before designing a change. If this repository's documentation disagrees +with them, flag the conflict, correct the repository-local documentation where appropriate, and never +silently follow the stale text. Architectural decisions still change only through ADRs (below). + +Rules from those documents that every change here must respect: + +- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and + devices, accounts and identity, roles and authorization, branding, mail, push, storage providers, + and module installation/lifecycle — belong to the platform, and modules consume them. +- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from + installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may. + Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy. +- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light, + dark, and system modes. Normal UI colours come only from the shared design tokens + (`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour. +- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited + in product UI. Destructive actions use in-product confirmation proportional to their impact. +- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard + sidebar's *Display devices* panel) and Signage's own display roster are a transitional + implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not + build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell + redesign early, and keep existing paired displays working across any change. + ## ADRs are authoritative `docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are @@ -43,6 +76,7 @@ At minimum, read the current versions of: - `docs/architecture/decisions/README.md` and every ADR relevant to your change - `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md` - `README.md` (current platform status — what is and is not implemented) +- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above) - `docs/development/repository-workflow.md` and `docs/development/testing.md` - `CONTRIBUTING.md` @@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat ## Working conventions -- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never - change branch-protection settings. +- The canonical repositories are on the church Gitea under the `Zayin` organization: + `ssh://git@192.168.68.76:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and + `Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags` + and branch from `origin/main`, never from whatever is checked out. +- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never + merge your own PR, and never change branch-protection settings. +- Never create or push a release tag. Release tags belong to the human maintainer. +- A change that needs both a platform contract change and a module change is two PRs: Core first, on + its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy + platform logic into a module to avoid making the correct Core change. - Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than burying them in an implementation. - Add or update tests when you change a contract or architectural rule. New behavior needs new diff --git a/deploy/README.md b/deploy/README.md index 0ce04f1..11ec1af 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -17,6 +17,7 @@ specific to one organization. | [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. | | [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. | | [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). | +| [`display-kiosk.md`](display-kiosk.md) | Setting up an unattended display screen: launch, pairing, and recovery. | ## The reference deployment diff --git a/deploy/display-kiosk.md b/deploy/display-kiosk.md new file mode 100644 index 0000000..1ecd422 --- /dev/null +++ b/deploy/display-kiosk.md @@ -0,0 +1,130 @@ +# Setting up a display kiosk + +A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one +module's display page full screen and keeps it open unattended. This guide sets one up so it starts +on boot, stays paired, recovers from network and server outages on its own, and never opens a second +copy of itself. + +Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a +screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to +**Settings → Displays**. Nothing below changes when that happens. + +## What the screen needs + +- Chromium or Google Chrome, current stable. The display page is tested against Chromium. +- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie + in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or + "clear data on exit" profile, and never share the profile with a person's browsing. +- Network access to the installation's **API hostname** (the display page, its realtime stream, and + its module requests) and to the storage hostname that serves images. +- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a + wildly wrong one. + +## The address + +Every display-capable module has one display page on the API hostname: + +```text +https:///display/ +``` + +For Signage that is `https:///display/signage`. The Signage **Displays** tab shows the +exact address under **Copy setup address**. Open it on the screen itself; opening it on an +administrator's own computer only previews the display (and that browser appears in the roster as a +*Signed-in browser*, which can be removed once it is offline). + +## Launching the browser + +Run exactly **one** launcher, and launch the page as an app window with a fixed profile: + +```bash +chromium \ + --user-data-dir=/var/lib/display-kiosk/profile \ + --app=https:///display/signage \ + --kiosk \ + --no-first-run \ + --noerrdialogs \ + --disable-session-crashed-bubble \ + --hide-crash-restore-bubble \ + --disable-features=Translate \ + --autoplay-policy=no-user-gesture-required \ + --check-for-update-interval=31536000 +``` + +- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen. +- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage + and back it up with nothing: if it is lost, the screen simply shows a new pairing code. +- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart + entry. Chromium allows one process per profile; a second launch hands its URL to the running + process, which opens a **second window or tab** of the display. That is the usual cause of + "duplicate" displays. One launcher, one profile, one window. + +### Start on boot and restart on exit (systemd) + +Run the browser as an unprivileged user in that user's graphical session, from a single user unit: + +```ini +# ~/.config/systemd/user/display-kiosk.service +[Unit] +Description=Display kiosk +After=graphical-session.target network-online.target +Wants=network-online.target +PartOf=graphical-session.target + +[Service] +ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https:///display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000 +Restart=always +RestartSec=5 + +[Install] +WantedBy=graphical-session.target +``` + +```bash +systemctl --user daemon-reload +systemctl --user enable --now display-kiosk.service +loginctl enable-linger # with the desktop set to log this user in automatically +``` + +`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile — +and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown +is always safer for a browser profile. + +## Pairing + +1. Start the kiosk. With no credential it shows a **six-digit pairing code**. +2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve. +3. Within a few seconds the screen collects its credential and starts showing content. +4. In Signage → **Displays**, name the display and choose its group and audience. + +A paired screen stays paired across browser restarts and reboots, and for as long as it is in use: +its credential is renewed each time it checks in, and only expires after **180 days without being +used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked, +returns to its pairing code and is paired again the same way. + +## What happens when things go wrong + +| Event | What the screen does | +| --- | --- | +| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. | +| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. | +| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. | +| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. | +| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. | +| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. | +| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. | + +**Offline cold start is not supported yet.** A screen that is already showing content survives an +outage. A screen that *boots* while the server is completely unreachable can load its cached page but +cannot mount the module until the server answers, because the authorization it needs is never cached +(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is +reachable — nobody needs to touch it. + +## Checklist for a new kiosk + +- [ ] One systemd unit, no desktop autostart entry, no second launcher. +- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit. +- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled. +- [ ] Paired and named; assigned in Signage. +- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect. +- [ ] Reboot: the screen comes back paired without anyone touching it. diff --git a/scripts/lib/deploy-smoke-display.mjs b/scripts/lib/deploy-smoke-display.mjs index d155549..5a8f7fc 100644 --- a/scripts/lib/deploy-smoke-display.mjs +++ b/scripts/lib/deploy-smoke-display.mjs @@ -14,7 +14,7 @@ // stream-failure the first realtime connection is refused: is realtime re-established? // caching-probe can a display on the API origin store storage-origin images in Cache Storage? // stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline -// revoke revoke the paired kiosk while its display is open +// revoke revoke the paired kiosk while its display is open, then pair it again // // Every DevTools command and every page-side request has its own deadline, and a step has an overall // budget. A call that never answers fails the step with a line naming that exact call, because a call @@ -976,6 +976,32 @@ async function run() { result.stopped, `a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`, ); + // Revocation is recoverable: the same screen pairs again and resumes, keeping its place in the + // module's roster. It also leaves the kiosk paired for the steps that run after this one. + const rosterBefore = moduleId === "signage" ? (await signageViewers(owner.page)).length : 0; + if (await page.waitFor(find("display-pairing-code"), "a fresh pairing code after revocation")) { + const code = ( + await page.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code") + ).replace(/\D/gu, ""); + const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", { + code, + deviceName: "Repro kiosk", + }); + check(approved.status === 200, "the owner approves the revoked screen's new code"); + check( + await waitLive(page, "the re-paired display to go live"), + "a revoked screen is paired again and resumes without a reload", + ); + if (moduleId === "signage") { + const rosterAfter = (await signageViewers(owner.page)).length; + check( + rosterAfter === rosterBefore, + `re-pairing keeps the screen's existing roster entry (${rosterBefore} before, ${rosterAfter} after)`, + ); + } + } else { + check(false, "a revoked screen shows a fresh pairing code"); + } await page.close(); kiosk.close(); await finishOwner(owner); diff --git a/surfaces/shell/src/browser/DevicePanel.tsx b/surfaces/shell/src/browser/DevicePanel.tsx index 0e2a44d..15a9555 100644 --- a/surfaces/shell/src/browser/DevicePanel.tsx +++ b/surfaces/shell/src/browser/DevicePanel.tsx @@ -1,5 +1,5 @@ import { designToken } from "@ekklesia/design-tokens"; -import { Button, Field, TextInput } from "@ekklesia/ui"; +import { Button, Dialog, Field, TextInput } from "@ekklesia/ui"; import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react"; import { approveDevicePairing, @@ -61,6 +61,10 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { const [code, setCode] = useState(""); const [name, setName] = useState(""); const [pairing, setPairing] = useState({ step: "idle" }); + /** The device an administrator asked to revoke and has not yet confirmed. */ + const [confirming, setConfirming] = useState(undefined); + const [revoking, setRevoking] = useState(false); + const dismissConfirmation = useCallback(() => setConfirming(undefined), []); const refresh = useCallback(async (): Promise => { try { @@ -106,13 +110,15 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { }; const revoke = async (deviceId: string): Promise => { + setRevoking(true); try { await revokeHostDevice(config, deviceId); - await refresh(); } catch { // Left to the next refresh rather than asserting a failure the operator cannot act on. } await refresh(); + setRevoking(false); + setConfirming(undefined); }; return ( @@ -195,7 +201,7 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined { {device.revoked ? undefined : ( + + + } + onDismiss={dismissConfirmation} + open={confirming !== undefined} + title={`Revoke ${confirming?.name ?? "this display"}?`} + > +

+ The screen stops showing content within a few seconds and returns to its pairing code. + This cannot be undone: to use it again, pair it from the screen with a new code. +

+ ); } diff --git a/surfaces/shell/src/display/DisplayApp.tsx b/surfaces/shell/src/display/DisplayApp.tsx index e4f291f..bcf8f00 100644 --- a/surfaces/shell/src/display/DisplayApp.tsx +++ b/surfaces/shell/src/display/DisplayApp.tsx @@ -1,6 +1,6 @@ import { designToken } from "@ekklesia/design-tokens"; import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; -import { type ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useCallback, useEffect, useRef, useState } from "react"; import { bootstrapDisplayHost, type DisplayHostBootstrap, @@ -17,7 +17,13 @@ import { PairingScreen } from "./PairingScreen.js"; type DisplayState = | { readonly status: "loading"; readonly generation: number } - | { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } } + | { + readonly status: "ready"; + readonly bootstrap: DisplayHostBootstrap & { status: "ready" }; + /** Changes only when the mounted display must be replaced, never merely because the network + * came back. It keys the frame, so it is the one thing that can remount a running display. */ + readonly generation: number; + } /** No identity this display can use yet: show a pairing code rather than a human login form. */ | { readonly status: "pairing" } | { readonly status: "unavailable" } @@ -31,6 +37,69 @@ export interface DisplayAppProps { /** Where connectivity and cache-fallback signals come from (ADR 0043). The browser entry point * supplies the real ones; omitting it leaves a display that simply never reports being offline. */ readonly connectivity?: DisplayConnectivitySource; + /** How long the display must stay offline before the corner indicator appears. Test seam only. */ + readonly offlineIndicatorDelayMs?: number; + /** The shortest interval between two revalidations prompted by refused module requests. Test seam + * only. */ + readonly revalidationIntervalMs?: number; + /** How often a running display re-resolves its descriptor on its own. Test seam only. */ + readonly periodicRevalidationMs?: number; + /** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam + * only. */ + readonly retryBaseMs?: number; +} + +/** 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; + +/** A module whose requests are being refused prompts at most one revalidation in this interval. A + * display that is merely missing one permission must not turn every refused request into two more. */ +export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000; + +/** A running display asks again this often, so a module upgraded on the server is picked up and a + * revocation is noticed even by a module that makes no requests of its own. */ +export const DISPLAY_PERIODIC_REVALIDATION_MS = 5 * 60_000; + +/** A display that could not start tries again on its own: from this delay, doubling to a minute. A + * kiosk that boots before its server — after a power cut, say — must come up without anyone pressing + * Retry. */ +export const DISPLAY_RETRY_BASE_MS = 2_000; +export const DISPLAY_RETRY_MAX_MS = 60_000; + +/** Whether a fresh resolution describes exactly the display already mounted. Compared structurally: + * the descriptor and principal are small, server-produced, and carry nothing volatile, so any + * difference — a new version, a new contribution, a different principal — is a real one. */ +function sameDisplay(left: DisplayHostBootstrap, right: DisplayHostBootstrap): boolean { + return ( + JSON.stringify([left.descriptor, left.principal]) === + JSON.stringify([right.descriptor, right.principal]) + ); +} + +/** `value`, but only once it has been continuously true for `delayMs`. Becoming false is immediate. */ +function useSustained(value: boolean, delayMs: number): boolean { + const [sustained, setSustained] = useState(value && delayMs <= 0); + useEffect(() => { + if (!value) { + setSustained(false); + return; + } + if (delayMs <= 0) { + setSustained(true); + return; + } + const timer = setTimeout(() => setSustained(true), delayMs); + return () => clearTimeout(timer); + }, [delayMs, value]); + return sustained; +} + +function isDenied(error: unknown): boolean { + return ( + error instanceof HostProtocolError && + (error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED") + ); } const displayStyles = { @@ -86,11 +155,13 @@ const displayStyles = { * recovers on its own the moment connectivity returns. */ function OfflineIndicator(props: { readonly connectivity: DisplayConnectivity }): ReactElement { + // Neutral wording: this is on a wall the congregation reads, and the product name is the + // installation's to choose, not the platform's (ADR 0046). const label = props.connectivity.usingFallback ? props.connectivity.fallbackKind === "artifact" ? "Offline — showing the last downloaded display code" - : "Offline — showing the last downloaded Ekklesia display" - : "Offline — waiting for Ekklesia"; + : "Offline — showing the last downloaded display" + : "Offline — reconnecting"; return (
{label} @@ -130,7 +201,112 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { online: true, usingFallback: false, }); - const { config, connectivity: connectivitySource, importModule, moduleId } = props; + const { + config, + connectivity: connectivitySource, + importModule, + moduleId, + offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS, + periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS, + retryBaseMs = DISPLAY_RETRY_BASE_MS, + revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS, + } = props; + /** Consecutive automatic retries since the display last started successfully. */ + const failures = useRef(0); + const stateRef = useRef(state); + stateRef.current = state; + const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({ + inFlight: undefined, + lastAt: 0, + }); + + /** + * Resolves the descriptor again **without** taking the running display down (ADR 0043). + * + * A display that is showing something keeps showing it while the question is asked. Only the + * answer decides what happens: the same display means nothing changes at all; a new version or a + * different principal replaces the frame; a refusal is a revoked or expired device and returns the + * screen to pairing (ADR 0036); and any other failure is the network still settling, so the frame + * stays. A display that is not showing anything simply starts over. + */ + const revalidate = useCallback( + (reason: "online" | "authorization" | "periodic"): void => { + const current = stateRef.current; + if (current.status !== "ready") { + if (reason === "online") { + setAttempt((value) => value + 1); + } + return; + } + const tracker = revalidation.current; + const nowMs = Date.now(); + if ( + tracker.inFlight !== undefined || + (reason === "authorization" && nowMs - tracker.lastAt < revalidationIntervalMs) + ) { + return; + } + tracker.lastAt = nowMs; + const abort = new AbortController(); + tracker.inFlight = abort; + void bootstrapDisplayHost(config, moduleId, abort.signal) + .then((next) => { + const latest = stateRef.current; + if (abort.signal.aborted || latest.status !== "ready") { + return; + } + if (!sameDisplay(latest.bootstrap, next)) { + setState({ bootstrap: next, generation: latest.generation + 1, status: "ready" }); + } + }) + .catch((error: unknown) => { + if (!abort.signal.aborted && isDenied(error)) { + setState({ status: "pairing" }); + } + }) + .finally(() => { + if (tracker.inFlight === abort) { + tracker.inFlight = undefined; + } + }); + }, + [config, moduleId, revalidationIntervalMs], + ); + + useEffect( + () => () => { + revalidation.current.inFlight?.abort(); + revalidation.current.inFlight = undefined; + }, + [], + ); + + const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]); + + // While running: ask again now and then, without disturbing anything when the answer is the same. + useEffect(() => { + if (state.status !== "ready" || periodicRevalidationMs <= 0) { + return; + } + const timer = setInterval(() => revalidate("periodic"), periodicRevalidationMs); + return () => clearInterval(timer); + }, [periodicRevalidationMs, revalidate, state.status]); + + // 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(() => { + if (state.status === "ready") { + failures.current = 0; + return; + } + if (state.status !== "failed" && state.status !== "unavailable") { + return; + } + const delay = Math.min(DISPLAY_RETRY_MAX_MS, retryBaseMs * 2 ** Math.min(failures.current, 10)); + failures.current += 1; + const timer = setTimeout(() => setAttempt((value) => value + 1), delay); + return () => clearTimeout(timer); + }, [retryBaseMs, state.status]); useEffect(() => { if (!connectivitySource) { @@ -142,27 +318,29 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { setConnectivity(next); // Nobody presses Retry on a lobby television. When the browser says the network is back, the // display resolves its descriptor again — which is also what stops a screen from sitting on - // cached code after the server returns, since the fresh descriptor names the current version - // and the network-first worker fetches it (ADR 0043). + // cached code after the server returns, since a fresh descriptor naming a new version replaces + // the frame and the network-first worker fetches it (ADR 0043). The same answer leaves the + // running display exactly where it was. if (next.online) { - setAttempt((current) => current + 1); + revalidate("online"); } }); return () => { unsubscribe(); monitor.close(); }; - }, [connectivitySource]); + }, [connectivitySource, revalidate]); useEffect(() => { const abort = new AbortController(); + revalidation.current.inFlight?.abort(); setState({ generation: attempt, status: "loading" }); void bootstrapDisplayHost(config, moduleId, abort.signal) .then((bootstrap) => { if (abort.signal.aborted) { return; } - setState({ bootstrap, status: "ready" }); + setState({ bootstrap, generation: attempt, status: "ready" }); }) .catch((error: unknown) => { if (abort.signal.aborted) { @@ -170,10 +348,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 ( - error instanceof HostProtocolError && - (error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED") - ) { + if (isDenied(error)) { setState({ status: "pairing" }); return; } @@ -188,12 +363,14 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { }, [attempt, config, moduleId]); const retry = (): void => setAttempt((current) => current + 1); + const showOffline = useSustained( + !connectivity.online || connectivity.usingFallback, + offlineIndicatorDelayMs, + ); return (
- {connectivity.online && !connectivity.usingFallback ? undefined : ( - - )} + {showOffline ? : undefined} {state.status === "loading" ? ( Loading display… ) : undefined} @@ -215,7 +392,8 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { config={config} descriptor={state.bootstrap.descriptor} {...(importModule ? { importModule } : {})} - key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`} + key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${state.generation}`} + onAuthorizationResponse={onAuthorizationResponse} principal={state.bootstrap.principal} /> ) : undefined} diff --git a/surfaces/shell/src/display/DisplayUiFrame.tsx b/surfaces/shell/src/display/DisplayUiFrame.tsx index d9a3792..3f458e9 100644 --- a/surfaces/shell/src/display/DisplayUiFrame.tsx +++ b/surfaces/shell/src/display/DisplayUiFrame.tsx @@ -35,6 +35,9 @@ export interface DisplayUiFrameProps { readonly principal: ModuleUiPrincipal; /** Test/composition seam only. Production uses the generic loader's native dynamic import. */ readonly importModule?: ModuleUiImporter; + /** Host-internal signal that a module request was refused, so the host can find out whether this + * device was revoked. It never enters the module-facing context. */ + readonly onAuthorizationResponse?: (status: 401 | 403) => void; } interface DisplayUiAttemptProps extends DisplayUiFrameProps { @@ -94,7 +97,10 @@ const frameStyles = { function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement { const containerRef = useRef(null); const [status, setStatus] = useState("loading"); - const { config, descriptor, importModule, onRetry, principal } = props; + const { config, descriptor, importModule, onAuthorizationResponse, onRetry, principal } = props; + // Read through a ref so a new callback identity can never remount a running display. + const authorizationRef = useRef(onAuthorizationResponse); + authorizationRef.current = onAuthorizationResponse; useEffect(() => { const container = containerRef.current; @@ -162,6 +168,11 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement { context: createModuleUiContext({ config, descriptor, + onAuthorizationResponse: (status) => { + if (!cancelled) { + authorizationRef.current?.(status); + } + }, principal, ...(importModule ? { importModule } : {}), }), diff --git a/surfaces/shell/tests/device-panel.test.tsx b/surfaces/shell/tests/device-panel.test.tsx new file mode 100644 index 0000000..5eb1b74 --- /dev/null +++ b/surfaces/shell/tests/device-panel.test.tsx @@ -0,0 +1,106 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DevicePanel } from "../src/browser/DevicePanel.js"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +const apiBaseUrl = "https://ekklesia.example.test"; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); +} + +function server() { + let revoked = false; + const revokes: string[] = []; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/devices")) { + return json({ + devices: [{ id: "device-1", name: "Foyer TV", permissions: [], revoked }], + }); + } + if (url.endsWith("/api/v1/devices/device-1/revoke")) { + revoked = true; + revokes.push(url); + return new Response(null, { status: 204 }); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + return { fetch, revokes }; +} + +function query(testId: string): HTMLElement | null { + return container.querySelector(`[data-testid="${testId}"]`); +} + +async function click(testId: string): Promise { + await act(async () => { + query(testId)?.click(); + for (let index = 0; index < 5; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }); +} + +/** + * Revoking a display strands that screen until someone re-pairs it in person, so the panel explains + * the consequence in its own dialog first — never with a browser-native confirm (DESIGN §21). + */ +describe("display device revocation", () => { + it("asks in a product dialog before revoking, and does nothing on cancel", async () => { + const platform = server(); + const confirm = vi.fn(() => true); + Object.assign(window, { confirm }); + await act(async () => { + root.render(); + }); + + await click("device-revoke-device-1"); + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("Revoke Foyer TV?"); + expect(dialog?.textContent).toContain("pairing code"); + expect(platform.revokes).toEqual([]); + + await click("device-revoke-cancel"); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(platform.revokes).toEqual([]); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("revokes only after the destructive action is confirmed", async () => { + const platform = server(); + await act(async () => { + root.render(); + }); + + await click("device-revoke-device-1"); + await click("device-revoke-confirm"); + + expect(platform.revokes).toHaveLength(1); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(query("device-device-1")?.textContent).toContain("(revoked)"); + }); +}); diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index a5df29b..f5e956d 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -180,6 +180,12 @@ async function render( importModule?: (url: string) => Promise, moduleId = "announcements", connectivity?: DisplayConnectivitySource, + timing: { + readonly offlineIndicatorDelayMs?: number; + readonly revalidationIntervalMs?: number; + } = { + offlineIndicatorDelayMs: 0, + }, ): Promise { await act(async () => { root.render( @@ -187,6 +193,7 @@ async function render( config={config} {...(connectivity ? { connectivity } : {})} {...(importModule ? { importModule } : {})} + {...timing} moduleId={moduleId} />, ); @@ -926,3 +933,297 @@ describe("display host — offline behaviour", () => { expect(query("display-pairing")).not.toBeNull(); }); }); + +/** + * Stage 0 regressions: a running display survives its network, and a revoked one returns to pairing. + * + * Before these, every browser `online` event tore the module down and mounted it again (a fresh + * viewer session, a fresh stage, every cached object URL released), and a device revoked while its + * display was open kept playing until someone reloaded the page. + */ +describe("display host — continuity across reconnects and revocation", () => { + function controllableConnectivity() { + const listeners = new Map void>>(); + return { + source: { + addEventListener(type: "online" | "offline", listener: () => void) { + const existing = listeners.get(type) ?? new Set(); + existing.add(listener); + listeners.set(type, existing); + }, + initialOnline: true, + removeEventListener(type: "online" | "offline", listener: () => void) { + listeners.get(type)?.delete(listener); + }, + } satisfies DisplayConnectivitySource, + async emit(type: "online" | "offline") { + await act(async () => { + for (const listener of listeners.get(type) ?? []) { + listener(); + } + }); + }, + }; + } + + async function flush(): Promise { + await act(async () => { + for (let index = 0; index < 5; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }); + } + + /** A server whose descriptor answer can be changed mid-test, counting descriptor requests. */ + function changingServer() { + let descriptor: { status: number; body: unknown } = { body: descriptorPayload(), status: 200 }; + let descriptorCalls = 0; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/module-displays/announcements")) { + descriptorCalls += 1; + return json(descriptor.body, descriptor.status); + } + if (url.endsWith("/api/v1/device-pairing/requests")) { + return json( + { code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 }, + 201, + ); + } + if (url.endsWith("/api/v1/device-pairing/claims")) { + return json({ status: "pending" }); + } + if (url.endsWith("/api/v1/modules/announcements/refused")) { + return json({ code: "AUTHORIZATION_DENIED" }, 403); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + return { + descriptorCalls: () => descriptorCalls, + fetch, + set(status: number, body: unknown) { + descriptor = { body, status }; + }, + }; + } + + function countingArtifact(version = "1.2.0") { + const counts = { mounts: 0, unmounts: 0 }; + let context: ModuleUiHostContext | undefined; + const importModule = async (url: string) => + artifact("announcements", url.includes("/2.0.0/") ? "2.0.0" : version, { + mount(element, mounted) { + counts.mounts += 1; + context = mounted; + element.textContent = "live"; + return () => { + counts.unmounts += 1; + }; + }, + }); + return { context: () => context, counts, importModule }; + } + + it("does not remount a live display when the network drops and returns", async () => { + const server = changingServer(); + const connectivity = controllableConnectivity(); + const display = countingArtifact(); + await render( + { apiBaseUrl, fetch: server.fetch }, + display.importModule, + "announcements", + connectivity.source, + ); + await flush(); + expect(display.counts.mounts).toBe(1); + const before = server.descriptorCalls(); + + await connectivity.emit("offline"); + await connectivity.emit("online"); + await flush(); + + // The descriptor is still resolved again (ADR 0043) — but the same answer changes nothing. + expect(server.descriptorCalls()).toBeGreaterThan(before); + expect(display.counts).toEqual({ mounts: 1, unmounts: 0 }); + expect(query("display-host-loading")).toBeNull(); + }); + + it("keeps the running display when the descriptor cannot be reached yet", async () => { + const server = changingServer(); + const connectivity = controllableConnectivity(); + const display = countingArtifact(); + await render( + { apiBaseUrl, fetch: server.fetch }, + display.importModule, + "announcements", + connectivity.source, + ); + await flush(); + + server.set(503, { code: "UNAVAILABLE" }); + await connectivity.emit("offline"); + await connectivity.emit("online"); + await flush(); + + expect(display.counts).toEqual({ mounts: 1, unmounts: 0 }); + expect(query("display-host-error")).toBeNull(); + }); + + it("replaces the display when the server now names a new version", async () => { + const server = changingServer(); + const connectivity = controllableConnectivity(); + const display = countingArtifact(); + await render( + { apiBaseUrl, fetch: server.fetch }, + display.importModule, + "announcements", + connectivity.source, + ); + await flush(); + + server.set(200, descriptorPayload("announcements", "2.0.0")); + await connectivity.emit("offline"); + await connectivity.emit("online"); + await flush(); + + expect(display.counts.unmounts).toBe(1); + expect(display.counts.mounts).toBe(2); + }); + + it("returns a device revoked while its display is open to pairing, without a reload", async () => { + const server = changingServer(); + const display = countingArtifact(); + await render({ apiBaseUrl, fetch: server.fetch }, display.importModule, "announcements"); + await flush(); + expect(query("display-module-container")).not.toBeNull(); + + // The administrator revokes the device. Its next module request is refused. + server.set(403, { code: "AUTHORIZATION_DENIED" }); + await act(async () => { + await display.context()?.api.request("refused"); + }); + await flush(); + + expect(display.counts.unmounts).toBe(1); + expect(query("display-pairing")).not.toBeNull(); + }); + + it("does not remount when a refused request turns out not to be a revocation", async () => { + const server = changingServer(); + const display = countingArtifact(); + await render( + { apiBaseUrl, fetch: server.fetch }, + display.importModule, + "announcements", + undefined, + { + offlineIndicatorDelayMs: 0, + revalidationIntervalMs: 60_000, + }, + ); + await flush(); + const before = server.descriptorCalls(); + + await act(async () => { + await display.context()?.api.request("refused"); + await display.context()?.api.request("refused"); + await display.context()?.api.request("refused"); + }); + await flush(); + + // One revalidation for the burst, and the display stays exactly where it was. + expect(server.descriptorCalls()).toBe(before + 1); + expect(display.counts).toEqual({ mounts: 1, unmounts: 0 }); + }); + + it("does not show the offline indicator for a momentary blip", async () => { + const server = changingServer(); + const connectivity = controllableConnectivity(); + await render( + { apiBaseUrl, fetch: server.fetch }, + countingArtifact().importModule, + "announcements", + connectivity.source, + { offlineIndicatorDelayMs: 60 }, + ); + await flush(); + + await connectivity.emit("offline"); + await connectivity.emit("online"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 120)); + }); + expect(query("display-offline")).toBeNull(); + + await connectivity.emit("offline"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 120)); + }); + expect(query("display-offline")?.textContent).toBe("Offline — reconnecting"); + }); + + it("starts on its own when the server comes up after the screen did", async () => { + // A kiosk that boots before its server: the network is up, so no `online` event will ever come. + let up = false; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/module-displays/announcements")) { + return up ? json(descriptorPayload()) : json({ code: "UNAVAILABLE" }, 503); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + const display = countingArtifact(); + await act(async () => { + root.render( + , + ); + }); + await flush(); + expect(query("display-host-error")).not.toBeNull(); + + up = true; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + await flush(); + expect(display.counts.mounts).toBe(1); + expect(query("display-host-error")).toBeNull(); + }); + + it("re-resolves a running display periodically, remounting only for a new version", async () => { + const server = changingServer(); + const display = countingArtifact(); + await act(async () => { + root.render( + , + ); + }); + await flush(); + const before = server.descriptorCalls(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + await flush(); + expect(server.descriptorCalls()).toBeGreaterThan(before); + expect(display.counts).toEqual({ mounts: 1, unmounts: 0 }); + + server.set(200, descriptorPayload("announcements", "2.0.0")); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + await flush(); + expect(display.counts.mounts).toBe(2); + }); +});