Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d861c6bf0 |
@@ -27,7 +27,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
# The only registry-specific setting: where the train is published.
|
||||
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
|
||||
|
||||
jobs:
|
||||
package-release:
|
||||
|
||||
@@ -6,60 +6,27 @@ contents, because restated details drift.
|
||||
|
||||
## What this repository is
|
||||
|
||||
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:
|
||||
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
|
||||
monorepo:
|
||||
|
||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||
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).
|
||||
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.
|
||||
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||
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.
|
||||
presentation concern a module does not. They consume public API/client contracts only, never Core
|
||||
or module internals directly.
|
||||
|
||||
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
|
||||
@@ -76,7 +43,6 @@ 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`
|
||||
|
||||
@@ -86,16 +52,8 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
||||
|
||||
## Working conventions
|
||||
|
||||
- The canonical repositories are on the church Gitea under the `Zayin` organization:
|
||||
`ssh://[email protected]: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.
|
||||
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
|
||||
change branch-protection settings.
|
||||
- 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
|
||||
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
# 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://<api-hostname>/display/<module-id>
|
||||
```
|
||||
|
||||
For Signage that is `https://<api-hostname>/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://<api-hostname>/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://<api-hostname>/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 <kiosk-user> # 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. The credential lasts **180 days**
|
||||
from pairing and is not renewed automatically (ADR 0036); after that the screen returns to its pairing
|
||||
code and is paired again the same way. Note the pairing date of each screen.
|
||||
|
||||
## 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; pairing date recorded.
|
||||
- [ ] 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.
|
||||
@@ -207,10 +207,10 @@ Two builds of the same commit produce byte-identical tarballs, and CI proves it
|
||||
|
||||
## Publishing the train
|
||||
|
||||
The registry is the Gitea npm registry under the `River_of_Glory_Church` owner:
|
||||
The registry is the Gitea npm registry under the `Zayin` owner:
|
||||
|
||||
```text
|
||||
https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
https://git.zayin.net/api/packages/Zayin/npm/
|
||||
```
|
||||
|
||||
| Who | May |
|
||||
@@ -272,7 +272,7 @@ Evidence that one train was published, not a second package catalog, and never a
|
||||
{
|
||||
"publicationRecord": 1,
|
||||
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
|
||||
"registry": "https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/",
|
||||
"registry": "https://git.zayin.net/api/packages/Zayin/npm/",
|
||||
"finalTag": "latest",
|
||||
"completedAt": "<ISO time>",
|
||||
"packages": [
|
||||
@@ -297,7 +297,7 @@ Evidence that one train was published, not a second package catalog, and never a
|
||||
|
||||
- **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by
|
||||
the publish step. Nobody needs a token to read or install.
|
||||
- **The publisher** is a dedicated account, member only of a `River_of_Glory_Church` team whose one
|
||||
- **The publisher** is a dedicated account, member only of a `Zayin` team whose one
|
||||
permission is Packages: Write. Its token has only the `write:package` scope. Gitea's
|
||||
`write:package` also allows deleting packages; the tool never does, and nothing else uses the
|
||||
token.
|
||||
@@ -319,7 +319,7 @@ guarantee:
|
||||
- **A published version is never deleted,** by anyone, for any reason short of a security incident.
|
||||
- **A version number is never reused with other bytes.** A version found on the registry with other
|
||||
bytes is burned: investigate how it got there, and publish the train under a new version.
|
||||
- **No package cleanup rule** may be configured for the `River_of_Glory_Church` owner.
|
||||
- **No package cleanup rule** may be configured for the `Zayin` owner.
|
||||
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
|
||||
check that.
|
||||
|
||||
@@ -359,7 +359,7 @@ claim to be source that 0.1.2 never contained.
|
||||
Before it, an administrator of the Gitea instance:
|
||||
|
||||
1. creates the dedicated publisher account;
|
||||
2. creates a `River_of_Glory_Church` team, for example `package-publishers`, with only the Packages
|
||||
2. creates a `Zayin` team, for example `package-publishers`, with only the Packages
|
||||
unit at Write, and adds the account to it;
|
||||
3. creates a token for the account with only `write:package`, and stores it as the repository
|
||||
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
|
||||
|
||||
@@ -89,7 +89,7 @@ promises no API stability beyond the pre-1.0 SemVer policy. Only Ekklesia's rele
|
||||
the registry in your repository's committed `.npmrc`; that is all the configuration there is:
|
||||
|
||||
```ini
|
||||
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||
@ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/
|
||||
```
|
||||
|
||||
Then the module builds and packages like any other:
|
||||
|
||||
@@ -110,7 +110,7 @@ for user in publisher outsider; do
|
||||
password_variable="${user}_password"
|
||||
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
|
||||
done
|
||||
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
|
||||
owners=(Zayin ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
|
||||
for owner in "${owners[@]}"; do
|
||||
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
|
||||
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { Button, Dialog, Field, TextInput } from "@ekklesia/ui";
|
||||
import { Button, Field, TextInput } from "@ekklesia/ui";
|
||||
import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
@@ -61,10 +61,6 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [pairing, setPairing] = useState<PairingStep>({ step: "idle" });
|
||||
/** The device an administrator asked to revoke and has not yet confirmed. */
|
||||
const [confirming, setConfirming] = useState<HostDeviceSummary | undefined>(undefined);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const dismissConfirmation = useCallback(() => setConfirming(undefined), []);
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
@@ -110,15 +106,13 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
||||
};
|
||||
|
||||
const revoke = async (deviceId: string): Promise<void> => {
|
||||
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 (
|
||||
@@ -201,7 +195,7 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
||||
{device.revoked ? undefined : (
|
||||
<Button
|
||||
data-testid={`device-revoke-${device.id}`}
|
||||
onClick={() => setConfirming(device)}
|
||||
onClick={() => void revoke(device.id)}
|
||||
size="sm"
|
||||
>
|
||||
Revoke
|
||||
@@ -209,38 +203,6 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 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
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
data-testid="device-revoke-cancel"
|
||||
disabled={revoking}
|
||||
onClick={dismissConfirmation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="device-revoke-confirm"
|
||||
disabled={revoking}
|
||||
onClick={() => (confirming ? void revoke(confirming.id) : undefined)}
|
||||
variant="danger"
|
||||
>
|
||||
Revoke display
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onDismiss={dismissConfirmation}
|
||||
open={confirming !== undefined}
|
||||
title={`Revoke ${confirming?.name ?? "this display"}?`}
|
||||
>
|
||||
<p style={{ margin: 0 }}>
|
||||
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.
|
||||
</p>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import {
|
||||
bootstrapDisplayHost,
|
||||
type DisplayHostBootstrap,
|
||||
@@ -17,13 +17,7 @@ import { PairingScreen } from "./PairingScreen.js";
|
||||
|
||||
type DisplayState =
|
||||
| { readonly status: "loading"; readonly generation: number }
|
||||
| {
|
||||
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;
|
||||
}
|
||||
| { readonly status: "ready"; readonly bootstrap: DisplayHostBootstrap & { status: "ready" } }
|
||||
/** No identity this display can use yet: show a pairing code rather than a human login form. */
|
||||
| { readonly status: "pairing" }
|
||||
| { readonly status: "unavailable" }
|
||||
@@ -37,69 +31,6 @@ 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 = {
|
||||
@@ -155,13 +86,11 @@ 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 display"
|
||||
: "Offline — reconnecting";
|
||||
: "Offline — showing the last downloaded Ekklesia display"
|
||||
: "Offline — waiting for Ekklesia";
|
||||
return (
|
||||
<div data-testid="display-offline" role="status" style={displayStyles.offline}>
|
||||
{label}
|
||||
@@ -201,112 +130,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
online: true,
|
||||
usingFallback: false,
|
||||
});
|
||||
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]);
|
||||
const { config, connectivity: connectivitySource, importModule, moduleId } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectivitySource) {
|
||||
@@ -318,29 +142,27 @@ 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 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.
|
||||
// cached code after the server returns, since the fresh descriptor names the current version
|
||||
// and the network-first worker fetches it (ADR 0043).
|
||||
if (next.online) {
|
||||
revalidate("online");
|
||||
setAttempt((current) => current + 1);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
monitor.close();
|
||||
};
|
||||
}, [connectivitySource, revalidate]);
|
||||
}, [connectivitySource]);
|
||||
|
||||
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, generation: attempt, status: "ready" });
|
||||
setState({ bootstrap, status: "ready" });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (abort.signal.aborted) {
|
||||
@@ -348,7 +170,10 @@ 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)) {
|
||||
if (
|
||||
error instanceof HostProtocolError &&
|
||||
(error.code === "DISPLAY_ACCESS_DENIED" || error.code === "HOST_UNAUTHENTICATED")
|
||||
) {
|
||||
setState({ status: "pairing" });
|
||||
return;
|
||||
}
|
||||
@@ -363,14 +188,12 @@ 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 (
|
||||
<div data-testid="display-host" style={displayStyles.host}>
|
||||
{showOffline ? <OfflineIndicator connectivity={connectivity} /> : undefined}
|
||||
{connectivity.online && !connectivity.usingFallback ? undefined : (
|
||||
<OfflineIndicator connectivity={connectivity} />
|
||||
)}
|
||||
{state.status === "loading" ? (
|
||||
<DisplayMessage testId="display-host-loading">Loading display…</DisplayMessage>
|
||||
) : undefined}
|
||||
@@ -392,8 +215,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
||||
config={config}
|
||||
descriptor={state.bootstrap.descriptor}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${state.generation}`}
|
||||
onAuthorizationResponse={onAuthorizationResponse}
|
||||
key={`${state.bootstrap.descriptor.id}@${state.bootstrap.descriptor.version}:${attempt}`}
|
||||
principal={state.bootstrap.principal}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
@@ -35,9 +35,6 @@ 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 {
|
||||
@@ -97,10 +94,7 @@ const frameStyles = {
|
||||
function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<FrameStatus>("loading");
|
||||
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;
|
||||
const { config, descriptor, importModule, onRetry, principal } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -168,11 +162,6 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
||||
context: createModuleUiContext({
|
||||
config,
|
||||
descriptor,
|
||||
onAuthorizationResponse: (status) => {
|
||||
if (!cancelled) {
|
||||
authorizationRef.current?.(status);
|
||||
}
|
||||
},
|
||||
principal,
|
||||
...(importModule ? { importModule } : {}),
|
||||
}),
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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<void> {
|
||||
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(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
|
||||
});
|
||||
|
||||
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(<DevicePanel config={{ apiBaseUrl, fetch: platform.fetch }} />);
|
||||
});
|
||||
|
||||
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)");
|
||||
});
|
||||
});
|
||||
@@ -180,12 +180,6 @@ async function render(
|
||||
importModule?: (url: string) => Promise<unknown>,
|
||||
moduleId = "announcements",
|
||||
connectivity?: DisplayConnectivitySource,
|
||||
timing: {
|
||||
readonly offlineIndicatorDelayMs?: number;
|
||||
readonly revalidationIntervalMs?: number;
|
||||
} = {
|
||||
offlineIndicatorDelayMs: 0,
|
||||
},
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -193,7 +187,6 @@ async function render(
|
||||
config={config}
|
||||
{...(connectivity ? { connectivity } : {})}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
{...timing}
|
||||
moduleId={moduleId}
|
||||
/>,
|
||||
);
|
||||
@@ -933,297 +926,3 @@ 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<string, Set<() => 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<void> {
|
||||
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(
|
||||
<DisplayApp
|
||||
config={{ apiBaseUrl, fetch }}
|
||||
importModule={display.importModule}
|
||||
moduleId="announcements"
|
||||
offlineIndicatorDelayMs={0}
|
||||
retryBaseMs={20}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<DisplayApp
|
||||
config={{ apiBaseUrl, fetch: server.fetch }}
|
||||
importModule={display.importModule}
|
||||
moduleId="announcements"
|
||||
offlineIndicatorDelayMs={0}
|
||||
periodicRevalidationMs={40}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user