12 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 97de441b5f ci: pin the Stage 0 platform harness and run every display scenario
CI / lint (pull_request) Failing after 1m21s
CI / typecheck (pull_request) Failing after 1m21s
CI / test (pull_request) Failing after 1m24s
CI / build (pull_request) Failing after 1m23s
CI / package (pull_request) Failing after 1m21s
CI / integration (pull_request) Skipped
Pin the integration harness to Zayin/Ekklesia PR 92, whose display host keeps
a live display across reconnects and returns a revoked one to pairing, and
fetch it from the repository's canonical Zayin location. With those fixes the
connectivity-blip, caching-probe, and revoke scenarios pass, so CI now runs
them too; previously they were left out because they failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:48:13 -05:00
ekklesiaandClaude Opus 5.5 7c17282616 fix(ui): keep Signage state consistent across tabs, and confirm destructive actions
Activating a timeline from Playback left Timelines showing it as inactive,
because each tab read its own stale copy of the channel groups; operators
read that as activation not sticking. Tabs also mounted every panel afresh, so
each switch flashed a spinner and collapsed the page before regrowing. Panels
now share one stale-while-revalidate resource cache, and Playback follows the
management realtime channel so Now showing tracks the wall.

Deleting an image, a timeline, a slot, a source, or an audience happened on a
single click; each is now a two-step in-place confirmation (DESIGN 21.3).

The audience key was presented as a field to fill in. Audiences are now named
in plain words, explained (images look the same for every audience), and the
content key is derived from the name with an Advanced override. The roster
labels signed-in browsers and lets an offline entry be removed after
confirmation, so test tabs and retired screens no longer accumulate. The
preview keeps nothing in the Dashboard origin's storage, and the narrow-screen
notice no longer names the platform.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:32:04 -05:00
ekklesiaandClaude Opus 5.5 67d89f1020 fix(display): keep blackout across reloads and follow the display's current assignment
A kiosk that reloaded during a blackout restored its last frame from local
state without the blackout, so the hidden slide flashed on the wall until the
server answered.

A display resolved its channel once, at registration. Moving it to another
group or audience, or deleting the channel it was on, never reached a running
screen: it kept showing the old channel, or fell into offline continuation
against a 404 forever. The display now fetches its state by viewer, so the
server resolves the current assignment on every fetch, and the heartbeat reply
names the assigned channel so a reassignment lands within one heartbeat. A
display removed from the roster registers again once, on backoff; only
Signage's own not-found means removal, so a foreign 404 can never cause a
registration loop. The channel route stays for the preview and for display
code a kiosk cached earlier. A replaced realtime subscription can no longer
schedule a stray resubscription when it reports closing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:32:04 -05:00
ekklesiaandClaude Opus 5.5 6bf9167f85 docs: add CLAUDE.md and mark display management as transitional
Signage had no agent guidance at all. Add a CLAUDE.md that records the
standalone-module architecture, that the parent ROADMAP.md and DESIGN.md are
authoritative, the ownership, branding, token, dark-mode, no-native-dialog and
plain-language rules, that display pairing is transitional until Stage 1
Settings -> Displays, and the branch-per-PR, no-push-to-main, no-release-tag
workflow.

The README listed a mobile management UI as out of scope, which contradicts
the design contract's responsive requirement; it is a Stage 1 deferral, and
now says so alongside how displays work today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 21:32:04 -05:00
The_River 86d49846ff Merge pull request 'chore: release signage 0.1.4' (#17) from release/0.1.4 into main
CI / lint (push) Successful in 12s
CI / typecheck (push) Successful in 9s
CI / test (push) Successful in 16s
CI / build (push) Successful in 11s
CI / package (push) Successful in 13s
CI / integration (push) Successful in 7m14s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/17
Reviewed-by: The_River <[email protected]>
2026-10-11 04:18:30 +00:00
ekklesia a8c35db201 chore: release signage 0.1.4
CI / lint (pull_request) Successful in 13s
CI / typecheck (pull_request) Successful in 10s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 11s
CI / package (pull_request) Successful in 14s
CI / integration (pull_request) Successful in 7m19s
2026-10-10 23:02:55 -05:00
The_River 91bdba3136 Merge pull request 'ci: separate manual Signage release publishing' (#16) from fix/manual-release-workflow into main
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 12s
CI / test (push) Successful in 17s
CI / build (push) Successful in 11s
CI / package (push) Successful in 14s
CI / integration (push) Successful in 7m28s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/16
Reviewed-by: The_River <[email protected]>
2026-10-11 03:59:22 +00:00
ekklesia 86121d7931 ci: separate manual release publishing
CI / lint (pull_request) Successful in 12s
CI / typecheck (pull_request) Successful in 10s
CI / test (pull_request) Successful in 17s
CI / build (pull_request) Successful in 11s
CI / package (pull_request) Successful in 13s
CI / integration (pull_request) Successful in 7m19s
2026-10-10 22:38:36 -05:00
The_River 8a7e96dd61 Merge pull request 'ci: publish the exact tested Signage module package' (#15) from fix/release-module-assets into main
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 10s
CI / test (push) Successful in 17s
CI / build (push) Successful in 11s
CI / package (push) Successful in 14s
CI / release (push) Canceled after 0s
CI / integration (push) Canceled after 6m50s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/15
Reviewed-by: The_River <[email protected]>
2026-10-11 03:05:00 +00:00
ekklesia 844bbcf2f1 ci: publish tested module release assets
CI / lint (pull_request) Successful in 12s
CI / typecheck (pull_request) Successful in 9s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 11s
CI / package (pull_request) Successful in 14s
CI / integration (pull_request) Successful in 7m26s
CI / release (pull_request) Skipped
2026-10-10 21:16:05 -05:00
The_River 88a536464a Merge pull request 'chore: release signage 0.1.3' (#14) from chore/release-0.1.3 into main
CI / lint (push) Successful in 10s
CI / typecheck (push) Successful in 10s
CI / test (push) Successful in 16s
CI / build (push) Successful in 11s
CI / integration (push) Successful in 7m36s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/14
Reviewed-by: The_River <[email protected]>
2026-10-10 17:34:55 +00:00
ekklesiaandClaude Opus 5.5 dd656eb18a chore: release signage 0.1.3
CI / lint (pull_request) Successful in 11s
CI / typecheck (pull_request) Successful in 11s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 7m17s
0.1.3 ships the external package integration validated in #13 (Pass 2):
the canonical linux-x64-glibc Module Package installed and exercised
end to end by CI, with bounded external runtime image processing.

Only the version moves: the manifest, package.json, the server runtime,
both browser artifacts, the settings payload's moduleVersion, the test
fixtures, and the README status line. compatibility stays
platform ^0.2.0 and moduleSdk ^0.2.0; Core 0.2.4 already satisfies it,
so the range is not tightened. pnpm-lock.yaml carries no root version
and is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 12:20:06 -05:00
41 changed files with 4856 additions and 3096 deletions
+50 -2
View File
@@ -15,6 +15,8 @@ name: CI
# #
# The server is Gitea 1.27, which honours `concurrency` cancellation; `permissions` and # The server is Gitea 1.27, which honours `concurrency` cancellation; `permissions` and
# `timeout-minutes` are declared so the workflow is correct wherever they are enforced. # `timeout-minutes` are declared so the workflow is correct wherever they are enforced.
# Artifact actions v3 are deliberate: Gitea implements that protocol, while v4 refuses non-GitHub
# servers. This workflow never publishes or mutates a release; publishing is a separate manual gate.
on: on:
pull_request: pull_request:
@@ -59,7 +61,9 @@ jobs:
run: pnpm lint run: pnpm lint
- name: Check that CI cleans up every stack it starts - name: Check that CI cleans up every stack it starts
run: bash ci/check-cleanup.sh run: |
bash ci/check-cleanup.sh
python3 -m unittest ci/test_release_assets.py
typecheck: typecheck:
name: typecheck name: typecheck
@@ -150,6 +154,42 @@ jobs:
- name: Validate the built module and its migrations - name: Validate the built module and its migrations
run: pnpm validate run: pnpm validate
# Build the canonical pair once. Integration downloads these exact bytes and may not repackage.
package:
name: package
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
sha256: ${{ steps.canonical.outputs.sha256 }}
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js at the repository's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Install (frozen lockfile)
run: |
corepack enable
corepack install
pnpm install --frozen-lockfile
- name: Build the canonical Module Package pair
id: canonical
run: bash ci/package.sh
- name: Hand the canonical pair to its consumers
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: signage-module-package
path: build/module-package/
if-no-files-found: error
retention-days: 30
# The real linux-x64-glibc Signage Module Package inside Ekklesia, end to end. The pinned platform # The real linux-x64-glibc Signage Module Package inside Ekklesia, end to end. The pinned platform
# builds one generic image with no bundled module source, verifies and installs Signage into # builds one generic image with no bundled module source, verifies and installs Signage into
# persistent module storage, applies its migrations, and drives its split-origin Dashboard and # persistent module storage, applies its migrations, and drives its split-origin Dashboard and
@@ -160,10 +200,12 @@ jobs:
needs: needs:
- test - test
- build - build
- package
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
env: env:
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
EXPECTED_PACKAGE_SHA256: ${{ needs.package.outputs.sha256 }}
# Names this job's stacks, so its final step removes exactly these (the platform's # Names this job's stacks, so its final step removes exactly these (the platform's
# scripts/lib/ci-project.sh). # scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: signage-integration EKKLESIA_CI_JOB_KEY: signage-integration
@@ -184,6 +226,12 @@ jobs:
corepack install corepack install
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
- name: Receive the canonical Module Package pair
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: signage-module-package
path: build/module-package/
- name: Fetch the pinned Ekklesia integration harness - name: Fetch the pinned Ekklesia integration harness
run: bash ci/fetch-platform.sh run: bash ci/fetch-platform.sh
@@ -192,7 +240,7 @@ jobs:
- name: Sweep stale CI stacks from the runner - name: Sweep stale CI stacks from the runner
run: bash build/platform/scripts/ci-sweep.sh --stale run: bash build/platform/scripts/ci-sweep.sh --stale
- name: Build, install, and run the external Signage package - name: Install and run the prebuilt external Signage package
run: bash ci/integration.sh run: bash ci/integration.sh
# Whatever happened above, this job's own stacks do not outlive it. If the first step failed, # Whatever happened above, this job's own stacks do not outlive it. If the first step failed,
+180
View File
@@ -0,0 +1,180 @@
name: Publish Signage Release
# Builds, proves, and publishes one Signage release. This is deliberately separate from CI: only an
# operator's workflow_dispatch on a selected branch can start it. The normal release ref is main.
# Every local gate, including the full external-package integration and ADR 0055 redistribution
# verification, finishes before the release/tag is created or an existing release is touched.
on:
workflow_dispatch:
inputs:
version:
description: The Signage version to publish, equal to package and module metadata.
required: true
type: string
permissions:
contents: read
# Never cancel a publication part way, and never run two at once. A rerun safely resumes an
# identical partial upload, but refuses a tag, release, or asset that differs from this run.
concurrency:
group: publish-signage-release
cancel-in-progress: false
jobs:
release-gate:
name: release gate
runs-on: ubuntu-latest
timeout-minutes: 75
outputs:
sha256: ${{ steps.canonical.outputs.sha256 }}
env:
DOCKER_BUILDKIT: "1"
EKKLESIA_CI_JOB_KEY: signage-publish-release
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TAG: v${{ inputs.version }}
RELEASE_REVISION: ${{ github.sha }}
steps:
- name: Check out the operator-selected revision
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js at the repository's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Install the reviewed build and release tooling
run: |
corepack enable
corepack install
pnpm install --frozen-lockfile
# Match Core's manual publisher: releases come only from reviewed main. The source version is
# checked before the expensive gate, and the selected commit remains the immutable tag target.
- name: Validate requested version and selected revision
run: |
[[ "$GITHUB_REF" == refs/heads/main ]] || {
echo "publish: select main; got $GITHUB_REF" >&2
exit 1
}
[[ "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "publish: version must be X.Y.Z; got $RELEASE_VERSION" >&2
exit 1
}
[[ "$RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]]
[[ "$(git rev-parse HEAD)" == "$RELEASE_REVISION" ]]
[[ -z "$(git status --porcelain)" ]]
node --input-type=module -e '
import { readFileSync } from "node:fs";
const requested = process.env.RELEASE_VERSION;
const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version;
const moduleVersion = JSON.parse(readFileSync("ekklesia.module.json", "utf8")).version;
if (packageVersion !== requested || moduleVersion !== requested) {
console.error(`publish: requested ${requested}, package ${packageVersion}, module ${moduleVersion}`);
process.exit(1);
}
console.log(`publish: source metadata agrees on ${requested}`);
'
# This is the only package build in the workflow. Every later step consumes this directory.
- name: Build the canonical Module Package pair once
id: canonical
run: bash ci/package.sh build/release-assets
- name: Verify the canonical Module Package
env:
EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }}
run: |
pnpm validate
python3 ci/release-assets.py verify-package \
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
- name: Fetch the pinned Ekklesia integration harness
run: bash ci/fetch-platform.sh
- name: Sweep stale CI stacks from the runner
run: bash build/platform/scripts/ci-sweep.sh --stale
- name: Run full integration with the exact release package
env:
EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }}
run: bash ci/integration.sh "$GITHUB_WORKSPACE/build/release-assets"
- name: Remove this publication's CI stacks
if: always()
run: |
if [ -f build/platform/scripts/ci-sweep.sh ]; then
bash build/platform/scripts/ci-sweep.sh --job
fi
- name: Build and verify required redistribution assets
env:
EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }}
run: |
package="$(find build/release-assets -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz')"
pnpm exec ekklesia-corresponding-source acquire \
--package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources"
pnpm exec ekklesia-corresponding-source build \
--package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources" \
--out build/release-assets
source="${package%.tar.gz}-corresponding-source.tar.gz"
metadata="${package%.tar.gz}.redistribution.json"
pnpm exec ekklesia-corresponding-source verify \
--package "$package" --source "$source" --metadata "$metadata"
python3 ci/release-assets.py verify \
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
# The write-authorized job receives only the completely proven bundle. Artifact actions v3
# are required by Gitea; v4 refuses servers other than github.com.
- name: Hand the proven release bundle to the publisher
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: signage-release-assets
path: build/release-assets/
if-no-files-found: error
retention-days: 90
publish:
name: publish
needs: release-gate
if: github.ref == 'refs/heads/main'
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 15
env:
EXPECTED_PACKAGE_SHA256: ${{ needs.release-gate.outputs.sha256 }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TAG: v${{ inputs.version }}
RELEASE_REVISION: ${{ github.sha }}
steps:
# The checked-out helper is the same reviewed revision the gate proved. Credentials are not
# persisted; the automatic token is exposed only to the final API call below.
- name: Check out the proven revision
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Receive the proven release bundle
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: signage-release-assets
path: build/release-assets/
- name: Recheck the received release bundle
run: |
python3 ci/release-assets.py verify \
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
# Only this final step can mutate Gitea. It creates v<version> at the selected commit and a
# prerelease if absent, then uploads the already-tested bytes. A rerun resumes only identical
# state. Every asset is read back without Authorization and checked byte for byte.
- name: Create the release and publish the exact tested assets
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
python3 ci/release-assets.py publish \
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
+1
View File
@@ -4,5 +4,6 @@ dist/
.turbo/ .turbo/
coverage/ coverage/
build/ build/
__pycache__/
*.log *.log
.DS_Store .DS_Store
+82
View File
@@ -0,0 +1,82 @@
# CLAUDE.md
Guidance for Claude Code (and other coding agents) working in this repository. It points at
authoritative sources rather than restating them, because restated details drift. Inspect the actual
source, tests, `package.json`, and CI workflow before relying on anything here or on memory of a
prior session.
## What this repository is
Signage is a first-party Ekklesia **module** in its own repository (`Zayin/Signage`). It is not part of
the platform monorepo and vendors none of it: it builds against the published `@ekklesia/*`
module-development packages (platform ADRs 0053 and 0054), ships as one Ekklesia Module Package
(ADR 0049), and reaches PostgreSQL only through the Module Database ABI v1 executor the host hands it
(ADR 0050). Read `README.md` for what exists and the scope boundaries.
Signage owns its domain: channel groups, audience channels, timelines, slots, signage assignments,
playback rules, native images, and **its own management and display UI**. The platform shell hosts
that UI through the generic module loader and never rebuilds it; do not move Signage screens into the
platform.
## 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` in the standard
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them in.
They are authoritative for product direction. If this repository's documentation disagrees with them,
flag the conflict and correct the repository-local documentation; never silently follow stale text.
Rules from those documents that apply to every change here:
- **Shared resources belong to the platform.** Displays/devices, identity, branding, mail, storage,
and module lifecycle are the platform's. Signage *consumes* displays; it assigns content to them.
- **The current display management is transitional.** Signage's viewer roster, display naming, and
pairing instructions, together with the platform's sidebar *Display devices* panel, are what exists
today. Stage 1 moves pairing and the one global display name to the platform's
**Settings → Displays**, and Signage then selects an existing display. Fix defects in the current
flow, keep existing paired displays, viewer identities, cached content, and assignments working, and
do **not** implement the Stage 1 Settings/Displays architecture early.
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia" (platform ADR 0046).
- **Visual direction.** Simple and minimal; responsive; light, dark, and system modes. Normal UI
colours come only from the shared design tokens (`@ekklesia/design-tokens`) and shared elements
(`@ekklesia/ui`) — never a hard-coded colour. The display artifact's pure-black stage, blackout,
and slate are content presentation, not themed UI.
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited.
Destructive actions use in-product confirmation proportional to impact: a two-step in-place
confirmation for moderate actions, the shared `Dialog` for significant ones.
- **Plain language.** Normal church staff must understand the UI without implementation terms; do not
expose an internal identifier merely because it exists.
## ADRs
`docs/architecture/decisions/` holds Signage's own ADRs; the platform's ADRs live in the Ekklesia
repository. Accepted ADRs are immutable except for status and cross-reference corrections. A changed
decision gets a new ADR that supersedes the old one; never rewrite a historical ADR to fit a change.
## Working conventions
- Canonical remote: `ssh://[email protected]:2222/Zayin/Signage.git` (the platform is
`Zayin/Ekklesia.git`). Local clones can be behind: `git fetch --prune --tags` and branch from
`origin/main`.
- One branch per PR off an up-to-date `origin/main`. Never push to `main`, never merge your own PR,
and never create or push a release tag — release tags belong to the human maintainer.
- A fix that needs a platform contract change is two PRs: the Ekklesia change first, then Signage,
with the compatibility boundary explicit. Do not copy platform logic into Signage to avoid it.
- Every defect fix gets a regression test. Signage never reads the environment, opens its own socket,
builds an Ekklesia URL, or applies a migration; all of that reaches it through the host.
## Verification
`package.json` and `.gitea/workflows/ci.yml` are the source of truth. From a clean checkout:
```bash
pnpm install --frozen-lockfile
pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm validate
bash ci/check-cleanup.sh
```
The CI `integration` job installs the real Module Package into the pinned platform
(`ci/platform.env`) and drives the Dashboard and a kiosk display in real browsers, running the display
scenarios listed in `ci/display-scenarios` (`bash ci/package.sh`, `bash ci/fetch-platform.sh`,
`bash ci/integration.sh`; it needs Docker). Unit tests passing is not evidence that a display or
browser behaviour works: verify display behaviour through the integration harness.
+21 -4
View File
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
## Status ## Status
Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since Version 0.1.4 is feature-complete for its declared scope, and Signage has run in production since
0.1.1. What exists today: 0.1.1. What exists today:
- The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and - The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and
@@ -139,8 +139,25 @@ description of the platform ABI.
| Transitions, offline continuation, playback controls | Dynamic change within one slide | | Transitions, offline continuation, playback controls | Dynamic change within one slide |
| The management and display UI | Its own internal multi-stage behaviour | | The management and display UI | Its own internal multi-stage behaviour |
## Displays today, and what Stage 1 changes
Displays are platform-owned. What exists now is transitional: a screen is paired in the Dashboard
sidebar's *Display devices* panel, and Signage keeps its own roster of the screens that have checked
in — their names, the group and audience each is assigned to, and their health. A running display
follows its assignment as soon as an administrator changes it, and a revoked display returns to its
pairing code. Entries for screens that are offline (a retired television, a test tab) can be removed
from the roster.
The product roadmap moves pairing and the one global display name to the platform's
**Settings → Displays** in Stage 1; Signage will then select an existing display instead of naming its
own. Until then, existing paired displays, their viewer identities, cached content, and assignments are
preserved across every Signage upgrade.
The management screen is currently desktop-only (the narrow-screen notice below 1024 px). The product
design requires phone and tablet administration and dark mode; that arrives with the Stage 1 shared
responsive Dashboard and design system rather than as a Signage-only layout.
## Licence and scope ## Licence and scope
Deployment, the module store, a theme editor, a mobile management UI, video and audio, MQTT, Home Deployment, the module store, a theme editor, video and audio, MQTT, Home Assistant, Companion, and any
Assistant, Companion, and any migration of legacy Announcements data are explicitly out of scope for migration of legacy Announcements data are explicitly out of scope for this repository.
this repository.
+50
View File
@@ -11,6 +11,7 @@ set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
workflow="$root/.gitea/workflows/ci.yml" workflow="$root/.gitea/workflows/ci.yml"
publish_workflow="$root/.gitea/workflows/publish-release.yml"
failures=0 failures=0
check() { check() {
if eval "$1"; then if eval "$1"; then
@@ -44,6 +45,55 @@ check 'grep -Eq "^EKKLESIA_PLATFORM_REF=refs/(pull/[0-9]+/head|heads/main)$" "$r
check 'grep -q "rev-parse refs/remotes/pinned" "$root/ci/fetch-platform.sh" && grep -q "does not resolve to EKKLESIA_PLATFORM_COMMIT" "$root/ci/fetch-platform.sh"' \ check 'grep -q "rev-parse refs/remotes/pinned" "$root/ci/fetch-platform.sh" && grep -q "does not resolve to EKKLESIA_PLATFORM_COMMIT" "$root/ci/fetch-platform.sh"' \
"the fetch verifies that the pinned ref resolves to the pinned commit" "the fetch verifies that the pinned ref resolves to the pinned commit"
# Normal CI builds the canonical archive once and hands those exact bytes to integration through a
# Gitea Actions v3 artifact. It must contain no release trigger, permission, or mutation path.
package_job="$(awk '/^ package:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
check 'grep -q "pull_request:" "$workflow" && grep -q "push:" "$workflow"' \
"normal CI still runs for pull requests and main pushes"
check '! grep -q "workflow_dispatch:" "$workflow" && ! grep -q "tags:" "$workflow"' \
"normal CI has no manual-publish or tag trigger"
check '! grep -q "contents: write" "$workflow" && ! grep -q "github.token" "$workflow"' \
"normal CI has no release-specific permission or token"
check '! grep -q "release-assets.py publish" "$workflow" && ! grep -q "ekklesia-corresponding-source" "$workflow"' \
"normal CI cannot publish or prepare release assets"
check 'grep -q "bash ci/package.sh" <<<"$package_job" && grep -q "actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de" <<<"$package_job"' \
"the package job builds and transfers one canonical artifact"
check 'grep -q "actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b" <<<"$job" && ! grep -Eq "ci/package.sh|ekklesia-pack-module" <<<"$job"' \
"integration downloads that artifact and never rebuilds it"
check 'grep -q "needs.package.outputs.sha256" <<<"$job" && grep -q "GITHUB_OUTPUT" "$root/ci/package.sh"' \
"integration binds the downloaded bytes to the package job's digest"
# Publishing is an explicit operator action. One job validates its dispatch input and selected ref,
# builds the package exactly once, integration-tests that directory, prepares redistribution files,
# and only then gives the same directory to the Gitea publisher.
gate_job="$(awk '/^ release-gate:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")"
publish_job="$(awk '/^ publish:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")"
check 'grep -q "workflow_dispatch:" "$publish_workflow" && ! grep -q "pull_request:" "$publish_workflow" && ! grep -q "push:" "$publish_workflow"' \
"publishing is workflow_dispatch only and cannot run for a PR or push"
check 'grep -q "inputs:" "$publish_workflow" && grep -q " version:" "$publish_workflow" && grep -q "required: true" "$publish_workflow"' \
"manual publishing requires an explicit version input"
check 'grep -q "refs/heads/main" <<<"$gate_job" && grep -q "packageVersion.*requested" <<<"$gate_job" && grep -q "moduleVersion.*requested" <<<"$gate_job"' \
"the publish gate requires selected main and validates both source versions"
check '[[ "$(grep -c "bash ci/package.sh" "$publish_workflow")" -eq 1 ]]' \
"the publish workflow builds the canonical Module Package exactly once"
check 'grep -q "ci/integration.sh.*build/release-assets" <<<"$gate_job" && grep -q "name: signage-release-assets" <<<"$gate_job" && grep -q "name: signage-release-assets" <<<"$publish_job" && grep -q "release-assets.py publish" <<<"$publish_job"' \
"integration proves the package handed unchanged to the publisher"
check 'grep -q "EXPECTED_PACKAGE_SHA256:.*steps.canonical.outputs.sha256" <<<"$gate_job" && grep -q "EXPECTED_PACKAGE_SHA256:.*needs.release-gate.outputs.sha256" <<<"$publish_job"' \
"the publish gate binds consumers to the canonical package digest"
check 'grep -q "ekklesia-corresponding-source verify" <<<"$gate_job" && grep -q "needs: release-gate" <<<"$publish_job"' \
"corresponding-source verification finishes before the publishing job"
check 'grep -q "GITHUB_TOKEN:.*github.token" <<<"$publish_job" && grep -q "contents: write" <<<"$publish_job" && ! grep -q "contents: write" <<<"$gate_job"' \
"only the final manual publishing job receives release write authority"
# The manual workflow also owns a Docker integration stack, so it follows the same cleanup rule.
publish_stale="$(grep -n -F -- 'ci-sweep.sh --stale' <<<"$gate_job" | cut -d: -f1 || true)"
publish_run="$(grep -n -F -- 'bash ci/integration.sh' <<<"$gate_job" | cut -d: -f1 || true)"
publish_own="$(grep -n -F -- 'ci-sweep.sh --job' <<<"$gate_job" | cut -d: -f1 || true)"
check '[[ -n "$publish_stale" && -n "$publish_run" ]] && ((publish_stale < publish_run))' \
"manual publishing sweeps stale stacks before integration"
check '[[ -n "$publish_own" && -n "$publish_run" ]] && ((publish_own > publish_run))' \
"manual publishing always has a post-integration cleanup step"
((failures == 0)) || { ((failures == 0)) || {
echo "check-cleanup: ${failures} check(s) failed." >&2 echo "check-cleanup: ${failures} check(s) failed." >&2
exit 1 exit 1
+3
View File
@@ -5,3 +5,6 @@ two-pages
session-blip session-blip
stream-failure stream-failure
stream-recycle stream-recycle
connectivity-blip
caching-probe
revoke
+20 -13
View File
@@ -1,40 +1,47 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds Signage as the canonical linux-x64-glibc Module Package, then gives that exact release pair # Gives the canonical linux-x64-glibc Module Package pair from the package job to the pinned
# to the pinned platform's generic deployment smoke. The platform image contains no Signage source: # platform's generic deployment smoke. The platform image contains no Signage source:
# its verifier extracts the package into installation-owned module storage before migrations, # its verifier extracts the package into installation-owned module storage before migrations,
# discovery, browser UI/display checks, restart, container recreation, disable/re-enable, removal, # discovery, browser UI/display checks, restart, container recreation, disable/re-enable, removal,
# and reinstall are exercised against disposable PostgreSQL and object-storage services. # and reinstall are exercised against disposable PostgreSQL and object-storage services.
# #
# bash ci/package.sh
# bash ci/fetch-platform.sh # bash ci/fetch-platform.sh
# bash ci/integration.sh # bash ci/integration.sh
set -Eeuo pipefail set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
platform="$root/build/platform" platform="$root/build/platform"
release="$root/build/module-package" release="${1:-$root/build/module-package}"
[[ -f "$platform/scripts/smoke-deploy.sh" ]] || { [[ -f "$platform/scripts/smoke-deploy.sh" ]] || {
echo "integration: run bash ci/fetch-platform.sh first." >&2 echo "integration: run bash ci/fetch-platform.sh first." >&2
exit 1 exit 1
} }
source_revision="${GITHUB_SHA:-$(git -C "$root" rev-parse HEAD 2>/dev/null || true)}" mapfile -t archives < <(find "$release" -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz' -print)
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || { [[ "${#archives[@]}" -eq 1 ]] || {
echo "integration: no full source revision is available for the package." >&2 echo "integration: expected exactly one prebuilt Module Package archive." >&2
exit 1 exit 1
} }
archive="${archives[0]}"
rm -rf "$release" checksum="$archive.sha256"
mkdir -p "$release" [[ -f "$checksum" ]] || {
pnpm --dir "$root" build echo "integration: missing $(basename "$checksum")." >&2
pnpm --dir "$root" exec ekklesia-pack-module --module "$root" --target linux-x64-glibc \ exit 1
--out "$release" --license-file "$root/LICENSE" --source-revision "$source_revision" }
(cd "$release" && sha256sum --check --strict "$(basename "$checksum")")
digest="$(sha256sum "$archive" | cut -d' ' -f1)"
if [[ -n "${EXPECTED_PACKAGE_SHA256:-}" && "$digest" != "$EXPECTED_PACKAGE_SHA256" ]]; then
echo "integration: prebuilt package sha256 $digest differs from the package job's $EXPECTED_PACKAGE_SHA256." >&2
exit 1
fi
scenarios="$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$root/ci/display-scenarios" | tr '\n' ' ')" scenarios="$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$root/ci/display-scenarios" | tr '\n' ' ')"
[[ -n "${scenarios// /}" ]] || { [[ -n "${scenarios// /}" ]] || {
echo "integration: ci/display-scenarios lists no scenario." >&2 echo "integration: ci/display-scenarios lists no scenario." >&2
exit 1 exit 1
} }
echo "integration: external Signage package built; scenarios: ${scenarios}" echo "integration: consuming prebuilt $(basename "$archive"); scenarios: ${scenarios}"
cd "$platform" cd "$platform"
EKKLESIA_SMOKE_TOPOLOGY=split \ EKKLESIA_SMOKE_TOPOLOGY=split \
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Builds the one canonical linux-x64-glibc Module Package pair that later jobs must consume.
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
release="${1:-$root/build/module-package}"
source_revision="${GITHUB_SHA:-$(git -C "$root" rev-parse HEAD 2>/dev/null || true)}"
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || {
echo "package: no full source revision is available." >&2
exit 1
}
rm -rf -- "$release"
mkdir -p -- "$release"
pnpm --dir "$root" build
pnpm --dir "$root" exec ekklesia-pack-module --module "$root" --target linux-x64-glibc \
--out "$release" --license-file "$root/LICENSE" --source-revision "$source_revision"
mapfile -t archives < <(find "$release" -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz' -print)
[[ "${#archives[@]}" -eq 1 ]] || {
echo "package: expected exactly one Module Package archive." >&2
exit 1
}
archive="${archives[0]}"
checksum="$archive.sha256"
[[ -f "$checksum" ]] || {
echo "package: missing $(basename "$checksum")." >&2
exit 1
}
(cd "$release" && sha256sum --check --strict "$(basename "$checksum")")
digest="$(sha256sum "$archive" | cut -d' ' -f1)"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "sha256=$digest" >>"$GITHUB_OUTPUT"
fi
echo "package: canonical pair is $(basename "$archive") and $(basename "$checksum")."
+3 -3
View File
@@ -1,5 +1,5 @@
# The companion Core harness Signage uses to install and run its real external package. The pull ref # The companion Core harness Signage uses to install and run its real external package. The pull ref
# remains reachable after its branch is deleted; the full commit pin makes the checkout immutable. # remains reachable after its branch is deleted; the full commit pin makes the checkout immutable.
EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git EKKLESIA_PLATFORM_REPOSITORY=https://git.zayin.net/Zayin/Ekklesia.git
EKKLESIA_PLATFORM_REF=refs/pull/90/head EKKLESIA_PLATFORM_REF=refs/pull/92/head
EKKLESIA_PLATFORM_COMMIT=40ef0ff802c124ae5a3a0b9aa51a1502a7b72644 EKKLESIA_PLATFORM_COMMIT=9331a8f7dfbf8cbb6528a8bff5ba842297b1ee5a
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Validate and publish one tested Signage release bundle through the Gitea API."""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import tarfile
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
ARCHIVE = re.compile(r"^signage-(\d+\.\d+\.\d+)-linux-x64-glibc\.tar\.gz$")
class Refused(Exception):
"""The release does not meet a condition that must fail closed."""
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def package_metadata(archive: Path) -> dict:
try:
with tarfile.open(archive, "r:gz") as bundle:
member = next(
entry for entry in bundle.getmembers()
if entry.name == "signage/ekklesia.package.json" and entry.isfile()
)
extracted = bundle.extractfile(member)
if extracted is None:
raise KeyError(member.name)
return json.loads(extracted.read())
except (KeyError, StopIteration, tarfile.TarError, json.JSONDecodeError) as error:
raise Refused(f"cannot read signage/ekklesia.package.json from {archive.name}") from error
def package_pair(directory: Path, tag: str, revision: str) -> list[Path]:
archives = [path for path in directory.iterdir() if path.is_file() and ARCHIVE.fullmatch(path.name)]
if len(archives) != 1:
raise Refused(f"expected one Signage Module Package, found {len(archives)}")
package = archives[0]
match = ARCHIVE.fullmatch(package.name)
assert match is not None
version = match.group(1)
if tag != f"v{version}":
raise Refused(f"tag {tag} does not match package version {version}")
if not re.fullmatch(r"[0-9a-f]{40}", revision):
raise Refused("release revision is not a full Git object ID")
checksum = directory / f"{package.name}.sha256"
expected = [package, checksum]
missing = [path.name for path in expected if not path.is_file()]
if missing:
raise Refused(f"Module Package pair is incomplete; missing={missing}")
package_digest = sha256(package)
checksum_line = checksum.read_text(encoding="utf-8")
wanted_line = f"{package_digest} {package.name}\n"
if checksum_line != wanted_line:
raise Refused(f"{checksum.name} does not match {package.name}")
built_digest = os.environ.get("EXPECTED_PACKAGE_SHA256")
if built_digest is not None and built_digest != package_digest:
raise Refused("selected Module Package differs from the package job's bytes")
metadata = package_metadata(package)
if metadata.get("module") != {"id": "signage", "version": version}:
raise Refused("package metadata does not identify the expected Signage version")
if metadata.get("provenance", {}).get("revision") != revision:
raise Refused("package provenance revision does not match the tag commit")
return expected
def expected_paths(directory: Path, tag: str, revision: str) -> list[Path]:
package, checksum = package_pair(directory, tag, revision)
package_digest = sha256(package)
match = ARCHIVE.fullmatch(package.name)
assert match is not None
version = match.group(1)
source = directory / f"signage-{version}-linux-x64-glibc-corresponding-source.tar.gz"
source_checksum = directory / f"{source.name}.sha256"
redistribution = directory / f"signage-{version}-linux-x64-glibc.redistribution.json"
notice = directory / f"signage-{version}-linux-x64-glibc.source-notice.txt"
expected = [package, checksum, source, source_checksum, redistribution, notice]
missing = [path.name for path in expected if not path.is_file()]
extras = sorted(path.name for path in directory.iterdir() if path.is_file() and path not in expected)
if missing or extras:
raise Refused(f"release bundle differs from the six required files; missing={missing}, extra={extras}")
source_line = source_checksum.read_text(encoding="utf-8")
wanted_source_line = f"{sha256(source)} {source.name}\n"
if source_line != wanted_source_line:
raise Refused(f"{source_checksum.name} does not match {source.name}")
binding = json.loads(redistribution.read_text(encoding="utf-8"))
package_binding = binding.get("package", {})
if package_binding.get("file") != package.name or package_binding.get("sha256") != package_digest:
raise Refused("redistribution metadata does not bind the selected Module Package")
corresponding = binding.get("correspondingSource", {})
if corresponding.get("file") != source.name or corresponding.get("sha256") != sha256(source):
raise Refused("redistribution metadata does not bind the selected corresponding source")
return expected
class Gitea:
def __init__(self) -> None:
self.server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
self.repository = os.environ.get("GITHUB_REPOSITORY", "")
self.token = os.environ.get("GITHUB_TOKEN", "")
if not self.server or not self.repository or not self.token:
raise Refused("GITHUB_SERVER_URL, GITHUB_REPOSITORY, and GITHUB_TOKEN are required")
def request(self, method: str, path: str, body: bytes | None = None,
headers: dict[str, str] | None = None, *, authenticated: bool = True,
missing_ok: bool = False) -> bytes | None:
target = path if path.startswith("http") else f"{self.server}/api/v1/repos/{self.repository}{path}"
request_headers = dict(headers or {})
if authenticated:
request_headers["Authorization"] = f"token {self.token}"
request = urllib.request.Request(target, data=body, method=method, headers=request_headers)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
except urllib.error.HTTPError as error:
if missing_ok and error.code == 404:
return None
detail = error.read()[:300].decode("utf-8", "replace")
raise Refused(f"{method} {path} answered {error.code}: {detail}") from error
def json(self, method: str, path: str, body: object | None = None,
*, missing_ok: bool = False) -> object:
encoded = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"} if encoded is not None else None
data = self.request(method, path, encoded, headers, missing_ok=missing_ok)
return json.loads(data) if data is not None else None
def public_download(self, url: str) -> bytes:
return self.request("GET", url, authenticated=False)
def publish(directory: Path, tag: str, revision: str) -> None:
# This validates every local byte before the first API request, and therefore before creating or
# mutating a release. Corresponding-source policy verification happens in the workflow first.
files = expected_paths(directory, tag, revision)
gitea = Gitea()
quoted_tag = urllib.parse.quote(tag, safe="")
release = gitea.json("GET", f"/releases/tags/{quoted_tag}", missing_ok=True)
tagged = gitea.json("GET", f"/tags/{quoted_tag}", missing_ok=True)
if tagged is not None and (
not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision
):
raise Refused(f"existing tag {tag} does not name {revision}")
if release is not None and tagged is None:
raise Refused(f"release {tag} exists without a readable tag")
if release is None:
release = gitea.json("POST", "/releases", {
"tag_name": tag,
# A full immutable commit, never a moving branch, is the release's tag target.
"target_commitish": revision,
"name": f"Ekklesia Signage {tag}",
"body": (
"Published by the manual Publish Signage Release workflow after package, "
"external integration, and corresponding-source verification."
),
# Existing Signage 0.x releases use Gitea's prerelease convention.
"draft": False,
"prerelease": True,
})
if not isinstance(release, dict):
raise Refused(f"Gitea did not return the created release {tag}")
tagged = gitea.json("GET", f"/tags/{quoted_tag}")
if not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision:
raise Refused(f"created tag {tag} does not name {revision}")
elif not isinstance(release, dict):
raise Refused(f"cannot read release {tag}")
release_id = release.get("id")
if not isinstance(release_id, int):
raise Refused(f"release {tag} has no numeric id")
listed = gitea.json("GET", f"/releases/{release_id}/assets")
present = {asset["name"]: asset for asset in listed or []}
unexpected = sorted(set(present) - {path.name for path in files})
if unexpected:
raise Refused(f"release already has unexpected assets: {unexpected}")
# Preflight every published name before changing the release. This prevents a later conflict
# from leaving newly uploaded earlier files beside immutable, different bytes.
verified: dict[str, str] = {}
for path in files:
asset = present.get(path.name)
if asset is None:
continue
public_digest = hashlib.sha256(gitea.public_download(asset["browser_download_url"])).hexdigest()
expected_digest = sha256(path)
if public_digest != expected_digest:
raise Refused(f"public {path.name} has sha256 {public_digest}, expected {expected_digest}")
verified[path.name] = public_digest
for path in files:
digest = sha256(path)
asset = present.get(path.name)
if asset is None:
boundary = uuid.uuid4().hex
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode() + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
gitea.request(
"POST",
f"/releases/{release_id}/assets?name={urllib.parse.quote(path.name)}",
body,
{"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
listed = gitea.json("GET", f"/releases/{release_id}/assets")
asset = next((item for item in listed or [] if item.get("name") == path.name), None)
if asset is None:
raise Refused(f"Gitea did not list {path.name} after upload")
public_digest = verified.get(path.name)
if public_digest is None:
public_digest = hashlib.sha256(gitea.public_download(asset["browser_download_url"])).hexdigest()
if public_digest != digest:
raise Refused(f"public {path.name} has sha256 {public_digest}, expected {digest}")
print(f"release-assets: public {path.name}, sha256 {digest}", flush=True)
def main(argv: list[str]) -> int:
try:
if len(argv) != 4 or argv[0] not in {"verify-package", "verify", "publish"}:
raise Refused(
"usage: release-assets.py (verify-package|verify|publish) DIRECTORY TAG REVISION"
)
command, directory, tag, revision = argv
selected = (package_pair(Path(directory), tag, revision)
if command == "verify-package"
else expected_paths(Path(directory), tag, revision))
if command in {"verify-package", "verify"}:
for path in selected:
print(path)
else:
publish(Path(directory), tag, revision)
return 0
except (OSError, json.JSONDecodeError, Refused) as error:
print(f"release-assets: REFUSED: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Release selection and Gitea upload tests; no real server or credential is used."""
from __future__ import annotations
import contextlib
import hashlib
import http.server
import importlib.util
import io
import json
import os
import socketserver
import tarfile
import tempfile
import threading
import unittest
import urllib.parse
from pathlib import Path
from unittest import mock
HERE = Path(__file__).resolve().parent
SPEC = importlib.util.spec_from_file_location("release_assets", HERE / "release-assets.py")
assert SPEC is not None and SPEC.loader is not None
release_assets = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(release_assets)
VERSION = "1.2.3"
REVISION = "a" * 40
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def release_bundle(directory: Path) -> list[Path]:
package = directory / f"signage-{VERSION}-linux-x64-glibc.tar.gz"
metadata = json.dumps({
"module": {"id": "signage", "version": VERSION},
"provenance": {"revision": REVISION},
}).encode()
with tarfile.open(package, "w:gz") as archive:
member = tarfile.TarInfo("signage/ekklesia.package.json")
member.size = len(metadata)
archive.addfile(member, io.BytesIO(metadata))
checksum = directory / f"{package.name}.sha256"
checksum.write_text(f"{release_assets.sha256(package)} {package.name}\n", encoding="utf-8")
source = directory / f"signage-{VERSION}-linux-x64-glibc-corresponding-source.tar.gz"
source.write_bytes(b"corresponding source")
source_checksum = directory / f"{source.name}.sha256"
source_checksum.write_text(f"{release_assets.sha256(source)} {source.name}\n", encoding="utf-8")
redistribution = directory / f"signage-{VERSION}-linux-x64-glibc.redistribution.json"
redistribution.write_text(json.dumps({
"package": {"file": package.name, "sha256": release_assets.sha256(package)},
"correspondingSource": {"file": source.name, "sha256": release_assets.sha256(source)},
}), encoding="utf-8")
notice = directory / f"signage-{VERSION}-linux-x64-glibc.source-notice.txt"
notice.write_text("source notice\n", encoding="utf-8")
return [package, checksum, source, source_checksum, redistribution, notice]
class FakeGitea(http.server.BaseHTTPRequestHandler):
assets: dict[str, bytes] = {}
release_exists = True
tag_revision: str | None = REVISION
api_authorizations: list[str | None] = []
public_authorizations: list[str | None] = []
asset_posts = 0
release_posts = 0
release_request: dict[str, object] | None = None
def log_message(self, _format: str, *_args: object) -> None:
pass
def answer(self, status: int, body: object = None) -> None:
data = b"" if body is None else json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def asset_list(self) -> list[dict[str, object]]:
server = self.server
assert isinstance(server, socketserver.TCPServer)
host, port = server.server_address
return [
{"id": index, "name": name, "browser_download_url": f"http://{host}:{port}/public/{name}"}
for index, name in enumerate(self.assets, 1)
]
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's API
if self.path.startswith("/public/"):
self.public_authorizations.append(self.headers.get("Authorization"))
name = urllib.parse.unquote(self.path.removeprefix("/public/"))
data = self.assets[name]
self.send_response(200)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self.api_authorizations.append(self.headers.get("Authorization"))
if "/releases/tags/" in self.path:
if self.release_exists:
self.answer(200, {"id": 7, "tag_name": f"v{VERSION}"})
else:
self.answer(404, {"message": "not found"})
elif "/tags/" in self.path:
if self.tag_revision is not None:
self.answer(200, {"commit": {"sha": self.tag_revision}})
else:
self.answer(404, {"message": "not found"})
elif self.path.endswith("/releases/7/assets"):
self.answer(200, self.asset_list())
else:
self.answer(404, {"message": "not found"})
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's API
self.api_authorizations.append(self.headers.get("Authorization"))
length = int(self.headers["Content-Length"])
body = self.rfile.read(length)
if self.path.endswith("/releases"):
request = json.loads(body)
self.__class__.release_posts += 1
self.__class__.release_request = request
self.__class__.release_exists = True
self.__class__.tag_revision = request["target_commitish"]
self.answer(201, {"id": 7, "tag_name": request["tag_name"]})
return
self.__class__.asset_posts += 1
name = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)["name"][0]
boundary = self.headers["Content-Type"].split("boundary=", 1)[1].encode()
payload = body.split(b"\r\n\r\n", 1)[1].rsplit(b"\r\n--" + boundary + b"--", 1)[0]
self.assets[name] = payload
self.answer(201, {"name": name})
@contextlib.contextmanager
def fake_gitea(initial: dict[str, bytes] | None = None, *, release_exists: bool = True,
tag_revision: str | None = REVISION):
FakeGitea.assets = dict(initial or {})
FakeGitea.release_exists = release_exists
FakeGitea.tag_revision = tag_revision
FakeGitea.api_authorizations = []
FakeGitea.public_authorizations = []
FakeGitea.asset_posts = 0
FakeGitea.release_posts = 0
FakeGitea.release_request = None
with socketserver.TCPServer(("127.0.0.1", 0), FakeGitea) as server:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
host, port = server.server_address
old = {name: os.environ.get(name) for name in ("GITHUB_SERVER_URL", "GITHUB_REPOSITORY", "GITHUB_TOKEN")}
os.environ.update({
"GITHUB_SERVER_URL": f"http://{host}:{port}",
"GITHUB_REPOSITORY": "owner/repository",
"GITHUB_TOKEN": "test-token",
})
try:
yield
finally:
server.shutdown()
thread.join()
for name, value in old.items():
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
class ReleaseAssetsTest(unittest.TestCase):
def test_selects_only_the_complete_bound_bundle(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
expected = release_bundle(directory)
self.assertEqual(release_assets.expected_paths(directory, f"v{VERSION}", REVISION), expected)
(directory / "unexpected.txt").write_text("no", encoding="utf-8")
with self.assertRaisesRegex(release_assets.Refused, "six required files"):
release_assets.expected_paths(directory, f"v{VERSION}", REVISION)
def test_refuses_wrong_tag_revision_and_checksum(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
paths = release_bundle(directory)
with self.assertRaisesRegex(release_assets.Refused, "tag"):
release_assets.expected_paths(directory, "v9.9.9", REVISION)
with self.assertRaisesRegex(release_assets.Refused, "provenance"):
release_assets.expected_paths(directory, f"v{VERSION}", "b" * 40)
with mock.patch.dict(os.environ, {"EXPECTED_PACKAGE_SHA256": "0" * 64}):
with self.assertRaisesRegex(release_assets.Refused, "package job's bytes"):
release_assets.expected_paths(directory, f"v{VERSION}", REVISION)
paths[1].write_text(f"{'0' * 64} {paths[0].name}\n", encoding="utf-8")
with self.assertRaisesRegex(release_assets.Refused, "does not match"):
release_assets.expected_paths(directory, f"v{VERSION}", REVISION)
def test_creates_release_uploads_each_byte_and_reads_it_back_anonymously(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
expected = release_bundle(directory)
with fake_gitea(release_exists=False, tag_revision=None):
release_assets.publish(directory, f"v{VERSION}", REVISION)
self.assertEqual(FakeGitea.release_posts, 1)
self.assertEqual(FakeGitea.release_request, {
"tag_name": f"v{VERSION}",
"target_commitish": REVISION,
"name": f"Ekklesia Signage v{VERSION}",
"body": (
"Published by the manual Publish Signage Release workflow after package, "
"external integration, and corresponding-source verification."
),
"draft": False,
"prerelease": True,
})
self.assertEqual(FakeGitea.asset_posts, 6)
self.assertEqual(FakeGitea.assets, {path.name: path.read_bytes() for path in expected})
self.assertTrue(all(value == "token test-token" for value in FakeGitea.api_authorizations))
self.assertEqual(FakeGitea.public_authorizations, [None] * 6)
def test_existing_identical_assets_are_idempotent_but_conflicts_fail(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
expected = release_bundle(directory)
initial = {path.name: path.read_bytes() for path in expected}
with fake_gitea(initial):
release_assets.publish(directory, f"v{VERSION}", REVISION)
self.assertEqual(FakeGitea.release_posts, 0)
self.assertEqual(FakeGitea.asset_posts, 0)
conflicting = {expected[1].name: b"different published bytes"}
with fake_gitea(conflicting):
with self.assertRaisesRegex(release_assets.Refused, "public .* expected"):
release_assets.publish(directory, f"v{VERSION}", REVISION)
self.assertEqual(FakeGitea.release_posts, 0)
self.assertEqual(FakeGitea.asset_posts, 0)
def test_refuses_existing_tag_at_another_revision_before_mutation(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
release_bundle(directory)
with fake_gitea(release_exists=False, tag_revision="b" * 40):
with self.assertRaisesRegex(release_assets.Refused, "existing tag"):
release_assets.publish(directory, f"v{VERSION}", REVISION)
self.assertEqual(FakeGitea.release_posts, 0)
self.assertEqual(FakeGitea.asset_posts, 0)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -3,7 +3,7 @@
"id": "signage", "id": "signage",
"name": "Signage", "name": "Signage",
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.", "description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
"version": "0.1.2", "version": "0.1.4",
"compatibility": { "compatibility": {
"platform": "^0.2.0", "platform": "^0.2.0",
"moduleSdk": "^0.2.0" "moduleSdk": "^0.2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/signage", "name": "@ekklesia/signage",
"version": "0.1.2", "version": "0.1.4",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
+1 -1
View File
@@ -20,7 +20,7 @@ export function DesktopOnly({ children }: { readonly children: ReactNode }): Rea
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note"> <div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2> <h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
<p className="signage-narrow-notice__body"> <p className="signage-narrow-notice__body">
{`Open the Ekklesia Dashboard on a display at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, channels, and playback.`} {`Open this page on a screen at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, displays, and playback.`}
</p> </p>
</div> </div>
<div className="signage-desktop" data-testid="signage-desktop"> <div className="signage-desktop" data-testid="signage-desktop">
+153 -21
View File
@@ -20,6 +20,7 @@ import {
signageMediaKey, signageMediaKey,
} from "../browser/storage.js"; } from "../browser/storage.js";
import { import {
assignedChannelOf,
type DisplayChannelState, type DisplayChannelState,
type DisplayHeartbeat, type DisplayHeartbeat,
type DisplayRealtimeGroup, type DisplayRealtimeGroup,
@@ -110,6 +111,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
let stage: SceneStage | undefined; let stage: SceneStage | undefined;
let session: DisplaySessionView | undefined; let session: DisplaySessionView | undefined;
let subscription: { close(): void } | undefined; let subscription: { close(): void } | undefined;
/** Identifies the one subscription whose callbacks may still act. */
let activeSubscription: symbol | undefined;
let heartbeatHandle: unknown; let heartbeatHandle: unknown;
let refreshHandle: unknown; let refreshHandle: unknown;
let stopped = false; let stopped = false;
@@ -127,6 +130,13 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
* each resets once its request works again. */ * each resets once its request works again. */
let sessionHandle: unknown; let sessionHandle: unknown;
let sessionAttempt = 0; let sessionAttempt = 0;
/** One registration in flight at a time. A refresh and a heartbeat can both discover that this
* viewer was removed from the roster; registering twice would open two realtime subscriptions and
* two heartbeat timers for one screen. */
let registering = false;
/** Consecutive times this screen found itself removed from the roster without a working fetch in
* between. Resets once a state fetch succeeds. */
let rosterAttempt = 0;
let refreshAttempt = 0; let refreshAttempt = 0;
let realtimeHandle: unknown; let realtimeHandle: unknown;
let realtimeAttempt = 0; let realtimeAttempt = 0;
@@ -347,6 +357,9 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
// The crossfade is part of the persisted state too, so a screen that resumes offline fades the // The crossfade is part of the persisted state too, so a screen that resumes offline fades the
// way its operator configured rather than the way this file happened to default. // way its operator configured rather than the way this file happened to default.
stage.setCrossfade(snapshot.crossfadeMs); stage.setCrossfade(snapshot.crossfadeMs);
// A deliberate blackout is persisted state like any other. Restoring it first means a kiosk that
// reloads mid-blackout stays black while it reconnects, instead of flashing the hidden frame.
stage.setBlackout(snapshot.playback.blackout);
const atMs = serverNowFrom(snapshot, now()); const atMs = serverNowFrom(snapshot, now());
const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable }); const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable });
const resolution = evaluation.resolution; const resolution = evaluation.resolution;
@@ -500,22 +513,96 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
if (stopped || !session) { if (stopped || !session) {
return; return;
} }
const viewerId = session.viewerId;
try { try {
const response = await options.context.api.request( // Asked by viewer, not by channel: the server resolves where this screen is assigned *now*,
`channels/${session.channelId}/display-state`, // so a reassignment or a deleted channel reaches a display that is already running.
); const response = await options.context.api.request(`viewers/${viewerId}/display-state`);
if (await removedFromRoster(response)) {
// This screen was removed from the roster. It registers again with the identity it keeps,
// and keeps its frame meanwhile.
reregister(viewerId);
return;
}
if (!response.ok) { if (!response.ok) {
await refreshFailed(); await refreshFailed();
return; return;
} }
const state = (await response.json()) as DisplayChannelStateView; const state = (await response.json()) as DisplayChannelStateView;
refreshAttempt = 0; refreshAttempt = 0;
rosterAttempt = 0;
adoptAssignment(state.channel);
await apply(state); await apply(state);
} catch { } catch {
await refreshFailed(); await refreshFailed();
} }
} }
/** The server's answer about where this screen belongs wins, exactly as its playback state does. */
function adoptAssignment(channel: DisplayChannelStateView["channel"]): void {
if (!session || (session.channelId === channel.id && session.groupId === channel.groupId)) {
return;
}
session = {
...session,
channelId: channel.id,
channelKey: channel.key,
groupId: channel.groupId,
};
}
/**
* Only Signage's own "no such viewer" means the roster entry is gone. Any other 404 — a proxy, a
* route this server does not have — is an ordinary failure, retried with backoff like the rest;
* treating it as removal would re-register in a loop.
*/
async function removedFromRoster(response: Response): Promise<boolean> {
if (response.status !== 404) {
return false;
}
const body = (await response
.clone()
.json()
.catch(() => null)) as { code?: unknown } | null;
return body?.code === "SIGNAGE_NOT_FOUND";
}
/** Drops a session the server no longer recognizes and registers again, once, on backoff. */
function reregister(viewerId: string): void {
if (stopped || session?.viewerId !== viewerId) {
return;
}
endSession();
// Backed off as well: a server that keeps forgetting this screen the moment it registers must
// cost a request a minute, not a tight loop.
const delay = rosterAttempt === 0 ? 0 : displayRetryDelayMs(rosterAttempt);
rosterAttempt += 1;
if (delay === 0) {
void register();
return;
}
sessionHandle = setTimer(() => {
sessionHandle = undefined;
void register();
}, delay);
}
/** Releases everything a registration started: realtime, heartbeats, and pending retries. */
function endSession(): void {
session = undefined;
activeSubscription = undefined;
subscription?.close();
subscription = undefined;
if (heartbeatHandle !== undefined) {
clearInterval(heartbeatHandle as never);
heartbeatHandle = undefined;
}
if (realtimeHandle !== undefined) {
clearTimer(realtimeHandle);
realtimeHandle = undefined;
}
}
/** /**
* The server is unreachable, or answered with an error. The frame stays and local continuation * The server is unreachable, or answered with an error. The frame stays and local continuation
* takes over — nothing about a network failure belongs on a lobby wall — and the refresh is tried * takes over — nothing about a network failure belongs on a lobby wall — and the refresh is tried
@@ -545,26 +632,17 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
* state — exactly as if it had worked the first time. * state — exactly as if it had worked the first time.
*/ */
async function register(): Promise<void> { async function register(): Promise<void> {
if (stopped || session) { if (stopped || session || registering) {
return; return;
} }
registering = true;
let registered: DisplaySessionView | undefined; let registered: DisplaySessionView | undefined;
try { try {
const response = await options.context.api.request("viewers/session", { registered = await requestSession();
body: JSON.stringify({ viewerKey: await viewerKey() }), } finally {
headers: { "content-type": "application/json" }, registering = false;
method: "POST",
});
if (response.ok) {
// Read, not cast: a registration the display cannot understand is no registration, and is
// tried again like any other failure.
const parsed = parseViewerSessionResponse(await response.json());
registered = parsed.ok ? parsed.value : undefined;
}
} catch {
registered = undefined;
} }
if (stopped) { if (stopped || session) {
return; return;
} }
if (!registered) { if (!registered) {
@@ -590,6 +668,26 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
report(); report();
} }
async function requestSession(): Promise<DisplaySessionView | undefined> {
let registered: DisplaySessionView | undefined;
try {
const response = await options.context.api.request("viewers/session", {
body: JSON.stringify({ viewerKey: await viewerKey() }),
headers: { "content-type": "application/json" },
method: "POST",
});
if (response.ok) {
// Read, not cast: a registration the display cannot understand is no registration, and is
// tried again like any other failure.
const parsed = parseViewerSessionResponse(await response.json());
registered = parsed.ok ? parsed.value : undefined;
}
} catch {
registered = undefined;
}
return registered;
}
/** /**
* Subscribes to the display's realtime channel, and again whenever the host reports that a * Subscribes to the display's realtime channel, and again whenever the host reports that a
* subscription has ended for good. The host retries ordinary disconnects itself; a stream that * subscription has ended for good. The host retries ordinary disconnects itself; a stream that
@@ -600,10 +698,21 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
if (stopped || !session) { if (stopped || !session) {
return; return;
} }
// Only the newest subscription may act. Closing the old one can report "closed" synchronously,
// and that report must not schedule a second resubscription on top of this one.
const token = Symbol("subscription");
activeSubscription = token;
subscription?.close(); subscription?.close();
subscription = options.context.realtime.subscribe("display-state", { subscription = options.context.realtime.subscribe("display-state", {
onSnapshot: (state) => onRealtime(state), onSnapshot: (state) => {
if (activeSubscription === token) {
onRealtime(state);
}
},
onStatus: (status) => { onStatus: (status) => {
if (activeSubscription !== token) {
return;
}
online = status === "connected"; online = status === "connected";
if (status === "connected") { if (status === "connected") {
realtimeAttempt = 0; realtimeAttempt = 0;
@@ -616,7 +725,11 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
} }
report(); report();
}, },
onUpdate: (state) => onRealtime(state), onUpdate: (state) => {
if (activeSubscription === token) {
onRealtime(state);
}
},
}); });
} }
@@ -639,11 +752,29 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
reportedSelectionId: renderedSelectionId ?? null, reportedSelectionId: renderedSelectionId ?? null,
reportedTimelineId: lastState?.playback.activeTimelineId ?? null, reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
}; };
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, { const viewerId = session.viewerId;
const response = await options.context.api.request(`viewers/${viewerId}/heartbeat`, {
body: JSON.stringify(report), body: JSON.stringify(report),
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
method: "POST", method: "POST",
}); });
if (await removedFromRoster(response)) {
reregister(viewerId);
return;
}
if (!response.ok) {
return;
}
// The heartbeat is also how a screen learns it was reassigned while its slot is long, paused,
// or held — times when no state fetch is otherwise due.
const assigned = assignedChannelOf(await response.json().catch(() => undefined));
if (
assigned !== undefined &&
session?.viewerId === viewerId &&
assigned !== session.channelId
) {
void refresh();
}
} catch { } catch {
// Telemetry is best effort and never affects what is on screen. // Telemetry is best effort and never affects what is on screen.
} }
@@ -695,6 +826,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
async stop(): Promise<void> { async stop(): Promise<void> {
stopped = true; stopped = true;
activeSubscription = undefined;
subscription?.close(); subscription?.close();
subscription = undefined; subscription = undefined;
if (heartbeatHandle !== undefined) { if (heartbeatHandle !== undefined) {
+1 -1
View File
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "signage", id: "signage",
version: "0.1.2", version: "0.1.4",
styles: displayStyles, styles: displayStyles,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
+40 -1
View File
@@ -18,6 +18,7 @@ import {
describeViewerHealth, describeViewerHealth,
openViewerSession, openViewerSession,
recordViewerHeartbeat, recordViewerHeartbeat,
resolveViewerSession,
SIGNAGE_VIEWER_NAME_MAX, SIGNAGE_VIEWER_NAME_MAX,
updateViewerAssignment, updateViewerAssignment,
} from "../viewers/service.js"; } from "../viewers/service.js";
@@ -123,12 +124,50 @@ export function registerViewerRoutes(
}); });
// The response says what a display needs to keep itself honest, and nothing about other // The response says what a display needs to keep itself honest, and nothing about other
// displays or the installation. // displays or the installation.
const response: DisplayHeartbeatResponse = { acknowledged: true, recorded: result.written }; const assigned = await resolveViewerSession(store(context), result.viewer);
const response: DisplayHeartbeatResponse = {
acknowledged: true,
channelId: assigned.channel.id,
recorded: result.written,
};
return Response.json(response); return Response.json(response);
}); });
}, },
}); });
http.route({
method: "GET",
path: "/viewers/:viewerId/display-state",
permission: "signage.display.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const viewerId = requireUuidParam(context, "viewerId");
const signageStore = store(context);
const viewer = await signageStore.findViewer(viewerId);
if (!viewer) {
// Removed from the roster. The display registers again with the identity it keeps.
throw new SignageError("SIGNAGE_NOT_FOUND");
}
// Resolved on every fetch, never remembered from registration: an administrator who moves
// a screen, or deletes the channel it was on, changes what it shows without a reboot.
const { channel } = await resolveViewerSession(signageStore, viewer);
const snapshot = await playback().snapshot(channel.groupId);
return Response.json(
await buildDisplayChannelState({
atMs: Date.now(),
channel,
playback: snapshot,
registry: registry(),
settings: readSignageSettings(context.config),
storage: context.storage,
store: signageStore,
}),
);
});
},
});
http.route({ http.route({
method: "GET", method: "GET",
path: "/channels/:channelId/display-state", path: "/channels/:channelId/display-state",
+2 -2
View File
@@ -130,7 +130,7 @@ export function createSignageModuleRuntime(
// Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to. // Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to.
database: { abi: 1 }, database: { abi: 1 },
id: "signage", id: "signage",
version: "0.1.2", version: "0.1.4",
register({ capabilities, config, http, realtime }) { register({ capabilities, config, http, realtime }) {
const settings = readSignageSettings(config); const settings = readSignageSettings(config);
@@ -192,7 +192,7 @@ export function createSignageModuleRuntime(
// server enforces — a UI believing a stale limit would offer an upload the server refuses. // server enforces — a UI believing a stale limit would offer an upload the server refuses.
return Response.json({ return Response.json({
crossfadeMs: current.crossfadeMs, crossfadeMs: current.crossfadeMs,
moduleVersion: "0.1.2", moduleVersion: "0.1.4",
nativeImageDurationMs: current.nativeImageDurationMs, nativeImageDurationMs: current.nativeImageDurationMs,
nativePresentation: current.nativePresentation, nativePresentation: current.nativePresentation,
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION, rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
+11 -2
View File
@@ -87,7 +87,7 @@ export async function openViewerSession(input: ViewerSessionInput): Promise<View
if (!refreshed) { if (!refreshed) {
throw new SignageError("SIGNAGE_OPERATION_FAILED"); throw new SignageError("SIGNAGE_OPERATION_FAILED");
} }
return resolveSession(input.store, refreshed); return resolveViewerSession(input.store, refreshed);
} }
const channel = defaultChannelOf(group.channels); const channel = defaultChannelOf(group.channels);
@@ -121,7 +121,16 @@ function defaultChannelOf(channels: readonly StoredChannel[]): StoredChannel {
return fallback; return fallback;
} }
async function resolveSession(store: SignageStore, viewer: StoredViewer): Promise<ViewerSession> { /**
* Where a known viewer is assigned **now**: its own channel, or the default channel of the default
* group when that channel no longer exists. A display asks this on every state fetch, which is what
* makes an administrator's reassignment — or deleting the channel a screen was on — reach a display
* that is already running, rather than only the next time it boots.
*/
export async function resolveViewerSession(
store: SignageStore,
viewer: StoredViewer,
): Promise<ViewerSession> {
const groups = await store.listChannelGroups(); const groups = await store.listChannelGroups();
for (const group of groups) { for (const group of groups) {
const channel = group.channels.find((candidate) => candidate.id === viewer.channelId); const channel = group.channels.find((candidate) => candidate.id === viewer.channelId);
+20 -1
View File
@@ -206,9 +206,28 @@ export function parseDisplayHeartbeat(raw: unknown): ProtocolResult<DisplayHeart
export interface DisplayHeartbeatResponse { export interface DisplayHeartbeatResponse {
readonly acknowledged: true; readonly acknowledged: true;
readonly recorded: boolean; readonly recorded: boolean;
/** The channel an administrator has this viewer assigned to now. A display whose own idea of its
* channel differs refetches its state, so a reassignment reaches a running screen within one
* heartbeat. Optional: a display ignores a response without it. */
readonly channelId?: string;
} }
// --- Channel state: GET channels/:channelId/display-state ----------------------------------------- /** The assigned channel from a heartbeat response, if it carried a usable one. */
export function assignedChannelOf(raw: unknown): string | undefined {
if (typeof raw !== "object" || raw === null) {
return undefined;
}
const channelId = (raw as { readonly channelId?: unknown }).channelId;
return typeof channelId === "string" && channelId.length > 0 && channelId.length <= 64
? channelId
: undefined;
}
// --- Channel state: GET viewers/:viewerId/display-state (and channels/:channelId/display-state) -----
//
// A registered display asks by its own viewer id, so the server resolves which channel it is assigned
// to at that moment. The channel route remains for the management preview and for display code a
// kiosk cached before the viewer route existed.
/** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */ /** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */
export interface DisplayMedia { export interface DisplayMedia {
+165 -168
View File
@@ -1,9 +1,9 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui"; import { EkklesiaUiStyles, EmptyState, PageHeader, Tabs } from "@ekklesia/ui";
import { type ReactElement, useMemo, useState } from "react"; import { type ReactElement, useContext, useMemo, useState } from "react";
import { DesktopOnly } from "../browser/desktop-gate.js"; import { DesktopOnly } from "../browser/desktop-gate.js";
import { createSignageApi } from "./api.js"; import { createSignageApi } from "./api.js";
import { useResource } from "./hooks.js"; import { createResourceCache, ResourceCacheContext, useResource } from "./hooks.js";
import { DisplaysPanel } from "./panels/DisplaysPanel.js"; import { DisplaysPanel } from "./panels/DisplaysPanel.js";
import { ImagesPanel } from "./panels/ImagesPanel.js"; import { ImagesPanel } from "./panels/ImagesPanel.js";
import { PlaybackPanel } from "./panels/PlaybackPanel.js"; import { PlaybackPanel } from "./panels/PlaybackPanel.js";
@@ -22,175 +22,172 @@ import type { ChannelGroupView, SettingsResponse } from "./types.js";
* Permission checks here decide what an operator is *offered*. The server decides what happens: a * Permission checks here decide what an operator is *offered*. The server decides what happens: a
* hidden button is a courtesy, and every route is enforced regardless of what this believes. * hidden button is a courtesy, and every route is enforced regardless of what this believes.
*/ */
export function SignageDashboard({ export function SignageDashboard(props: {
context, readonly context: ModuleUiHostContext;
copyText, readonly copyText: (value: string) => Promise<void>;
}: {
readonly context: ModuleUiHostContext;
readonly copyText: (value: string) => Promise<void>;
}): ReactElement { }): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]); // One cache for the life of this screen: everything the panels have loaded, shared between them.
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing. const [cache] = useState(createResourceCache);
// A hidden screen is a usability choice; the server is what actually refuses, and making a return (
// request it will certainly refuse is noise in a log rather than security. <ResourceCacheContext.Provider value={cache}>
const mayView = context.permissions.has("signage.view"); <SignageWorkspace {...props} />
const settings = useResource( </ResourceCacheContext.Provider>
() => );
mayView }
? api.get<SettingsResponse>("settings")
: Promise.resolve(undefined),
[mayView],
);
const groups = useResource(
() =>
mayView
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
: Promise.resolve(undefined),
[mayView],
);
const [tab, setTab] = useState("playback");
const [groupId, setGroupId] = useState<string | undefined>(undefined);
const mayManageAssets = context.permissions.has("signage.assets.manage"); function SignageWorkspace({
const mayManageTimelines = context.permissions.has( context,
"signage.timelines.manage", copyText,
); }: {
const mayControl = context.permissions.has("signage.playback.control"); readonly context: ModuleUiHostContext;
const mayManageDisplays = context.permissions.has("signage.displays.manage"); readonly copyText: (value: string) => Promise<void>;
}): ReactElement {
const api = useMemo(() => createSignageApi(context.api), [context.api]);
const cache = useContext(ResourceCacheContext);
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
// A hidden screen is a usability choice; the server is what actually refuses, and making a
// request it will certainly refuse is noise in a log rather than security.
const mayView = context.permissions.has("signage.view");
const settings = useResource(
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
[mayView],
mayView ? "settings" : undefined,
);
const groups = useResource(
() =>
mayView
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
: Promise.resolve(undefined),
[mayView],
mayView ? "channel-groups" : undefined,
);
const [tab, setTab] = useState("playback");
const [groupId, setGroupId] = useState<string | undefined>(undefined);
if (!mayView) { const mayManageAssets = context.permissions.has("signage.assets.manage");
return ( const mayManageTimelines = context.permissions.has("signage.timelines.manage");
<EmptyState const mayControl = context.permissions.has("signage.playback.control");
description="Ask an administrator for the signage.view permission." const mayManageDisplays = context.permissions.has("signage.displays.manage");
title="No access to Signage"
/>
);
}
const selectedGroupId = if (!mayView) {
groupId ?? return (
(groups.state.status === "ready" <EmptyState
? groups.state.value?.groups[0]?.id description="Ask an administrator for the signage.view permission."
: undefined); title="No access to Signage"
const selectedGroup = />
groups.state.status === "ready" );
? groups.state.value?.groups.find( }
(candidate) => candidate.id === selectedGroupId,
)
: undefined;
return ( const selectedGroupId =
<> groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
<EkklesiaUiStyles /> const selectedGroup =
<DesktopOnly> groups.state.status === "ready"
<PageHeader ? groups.state.value?.groups.find((candidate) => candidate.id === selectedGroupId)
description="Plan visual content, operate playback, and keep every screen connected." : undefined;
eyebrow="Media"
title="Signage" return (
/> <>
{renderLoadable( <EkklesiaUiStyles />
settings.state, <DesktopOnly>
(loaded) => <PageHeader
loaded === undefined ? undefined : ( description="Plan visual content, operate playback, and keep every screen connected."
<div className="signage-workspace"> eyebrow="Media"
<Tabs title="Signage"
label="Signage sections" />
onSelect={setTab} {renderLoadable(
selectedId={tab} settings.state,
tabs={[ (loaded) =>
{ loaded === undefined ? undefined : (
content: ( <div className="signage-workspace">
<PlaybackPanel <Tabs
api={api} label="Signage sections"
canControl={mayControl} onSelect={setTab}
context={context} selectedId={tab}
groupId={selectedGroupId} tabs={[
onGroupChange={setGroupId} {
settings={loaded} content: (
/> <PlaybackPanel
), api={api}
id: "playback", canControl={mayControl}
label: "Playback", context={context}
}, groupId={selectedGroupId}
{ onGroupChange={setGroupId}
content: ( settings={loaded}
<TimelinesPanel />
{...(selectedGroup ),
? { id: "playback",
activeGroupName: selectedGroup.name, label: "Playback",
activeTimelineId: },
selectedGroup.playback.activeTimelineId, {
} content: (
: {})} <TimelinesPanel
api={api} {...(selectedGroup
canManage={mayManageTimelines} ? {
onActivate={ activeGroupName: selectedGroup.name,
mayControl && selectedGroupId !== undefined activeTimelineId: selectedGroup.playback.activeTimelineId,
? async (timelineId) => { }
await api.send( : {})}
"POST", api={api}
`playback/${selectedGroupId}/activate`, canManage={mayManageTimelines}
{ onActivate={
timelineId, mayControl && selectedGroupId !== undefined
when: "now", ? async (timelineId) => {
}, await api.send("POST", `playback/${selectedGroupId}/activate`, {
); timelineId,
groups.reload(); when: "now",
setTab("playback"); });
} // Playback opens on what is now true, not on the
: undefined // timeline it was showing before.
} cache?.write(
settings={loaded} `playback/${selectedGroupId}`,
/> await api.get(`playback/${selectedGroupId}`),
), );
id: "timelines", groups.reload();
label: "Timelines", setTab("playback");
}, }
{ : undefined
content: ( }
<ImagesPanel settings={loaded}
api={api} />
canManage={mayManageAssets} ),
settings={loaded} id: "timelines",
/> label: "Timelines",
), },
id: "images", {
label: "Images", content: (
}, <ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
{ ),
content: ( id: "images",
<DisplaysPanel label: "Images",
api={api} },
canManage={mayManageDisplays} {
copyText={copyText} content: (
displaySetupUrl={context.display?.url} <DisplaysPanel
onGroupsChanged={() => { api={api}
setGroupId(undefined); canManage={mayManageDisplays}
groups.reload(); copyText={copyText}
}} displaySetupUrl={context.display?.url}
/> onGroupsChanged={() => {
), setGroupId(undefined);
id: "displays", groups.reload();
label: "Displays", }}
}, />
{ ),
content: ( id: "displays",
<ProvidersPanel label: "Displays",
api={api} },
context={context} {
settings={loaded} content: <ProvidersPanel api={api} context={context} settings={loaded} />,
/> id: "providers",
), label: "Providers",
id: "providers", },
label: "Providers", ]}
}, />
]} </div>
/> ),
</div> { label: "Loading Signage…", onRetry: settings.reload },
), )}
{ label: "Loading Signage…", onRetry: settings.reload }, </DesktopOnly>
)} </>
</DesktopOnly> );
</>
);
} }
+71 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { type SignageApiFailure, SignageRequestError } from "./api.js"; import { type SignageApiFailure, SignageRequestError } from "./api.js";
/** /**
@@ -21,15 +21,76 @@ export interface Resource<Value> {
reload(): void; reload(): void;
} }
/**
* The last value each Signage resource loaded, shared by every panel on the screen.
*
* The tabs mount only the selected panel, so without this every tab switch threw its data away,
* flashed a spinner, and collapsed the page to its height before growing again — the "jumping" an
* operator saw. It also let two panels disagree: activating a timeline from Playback left Timelines
* showing the old one as inactive until a reload. Now a remounted panel starts from what was last
* known and refreshes quietly, and a reload anywhere updates every panel reading the same resource.
*/
export interface ResourceCache {
read(key: string): { readonly value: unknown } | undefined;
write(key: string, value: unknown): void;
subscribe(key: string, listener: (value: unknown) => void): () => void;
}
export function createResourceCache(): ResourceCache {
const values = new Map<string, { readonly value: unknown }>();
const listeners = new Map<string, Set<(value: unknown) => void>>();
return {
read: (key) => values.get(key),
subscribe(key, listener) {
const set = listeners.get(key) ?? new Set();
set.add(listener);
listeners.set(key, set);
return () => {
set.delete(listener);
if (set.size === 0) {
listeners.delete(key);
}
};
},
write(key, value) {
values.set(key, { value });
for (const listener of [...(listeners.get(key) ?? [])]) {
listener(value);
}
},
};
}
export const ResourceCacheContext = createContext<ResourceCache | undefined>(undefined);
export function useResource<Value>( export function useResource<Value>(
load: () => Promise<Value>, load: () => Promise<Value>,
dependencies: readonly unknown[], dependencies: readonly unknown[],
/** Shares this resource with every other panel that names the same key. */
key?: string,
): Resource<Value> { ): Resource<Value> {
const [state, setState] = useState<Loadable<Value>>({ status: "loading" }); const cache = useContext(ResourceCacheContext);
const cached = key === undefined ? undefined : cache?.read(key);
const [state, setState] = useState<Loadable<Value>>(
cached === undefined
? { status: "loading" }
: { status: "ready", value: cached.value as Value },
);
const generation = useRef(0); const generation = useRef(0);
const loadRef = useRef(load); const loadRef = useRef(load);
loadRef.current = load; loadRef.current = load;
useEffect(() => {
if (key === undefined || !cache) {
return;
}
const current = cache.read(key);
if (current !== undefined) {
setState({ status: "ready", value: current.value as Value });
}
return cache.subscribe(key, (value) => setState({ status: "ready", value: value as Value }));
}, [cache, key]);
const run = useCallback(() => { const run = useCallback(() => {
generation.current += 1; generation.current += 1;
const token = generation.current; const token = generation.current;
@@ -38,7 +99,13 @@ export function useResource<Value>(
.then((value) => { .then((value) => {
// A response from a request the panel has already moved on from must never overwrite a // A response from a request the panel has already moved on from must never overwrite a
// newer one. // newer one.
if (token === generation.current) { if (token !== generation.current) {
return;
}
if (key !== undefined && cache) {
// Every panel subscribed to this key, including this one, is updated by the write.
cache.write(key, value);
} else {
setState({ status: "ready", value }); setState({ status: "ready", value });
} }
}) })
@@ -58,7 +125,7 @@ export function useResource<Value>(
status: "failed", status: "failed",
}); });
}); });
}, []); }, [cache, key]);
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs // The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
// change, which is the whole reason this takes one. // change, which is the whole reason this takes one.
+18 -20
View File
@@ -14,24 +14,22 @@ import ownStyles from "./styles.css";
* rather than bundled (ADR 0039). * rather than bundled (ADR 0039).
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "signage", id: "signage",
version: "0.1.2", version: "0.1.4",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
root.render( root.render(
<SignageDashboard <SignageDashboard
context={context} context={context}
copyText={async (value) => { copyText={async (value) => {
if (globalThis.navigator.clipboard === undefined) { if (globalThis.navigator.clipboard === undefined) {
throw new Error( throw new Error("Clipboard access is not available in this browser.");
"Clipboard access is not available in this browser.", }
); await globalThis.navigator.clipboard.writeText(value);
} }}
await globalThis.navigator.clipboard.writeText(value); />,
}} );
/>, return () => root.unmount();
); },
return () => root.unmount();
},
}); });
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -9,7 +9,7 @@ import {
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
import { useAction, useResource } from "../hooks.js"; import { useAction, useResource } from "../hooks.js";
import type { ImageRecord, SettingsResponse } from "../types.js"; import type { ImageRecord, SettingsResponse } from "../types.js";
import { FailureNote, formatInstant, renderLoadable } from "./shared.js"; import { ConfirmButton, FailureNote, formatInstant, renderLoadable } from "./shared.js";
/** /**
* The native image library and its upload queue. * The native image library and its upload queue.
@@ -28,7 +28,11 @@ export function ImagesPanel({
readonly settings: SettingsResponse; readonly settings: SettingsResponse;
readonly canManage: boolean; readonly canManage: boolean;
}): ReactElement { }): ReactElement {
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []); const images = useResource(
() => api.get<{ images: readonly ImageRecord[] }>("images"),
[],
"images",
);
const [uploads, setUploads] = useState<readonly UploadItem[]>([]); const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
const queueRef = useRef<UploadQueue | undefined>(undefined); const queueRef = useRef<UploadQueue | undefined>(undefined);
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined); const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
@@ -204,13 +208,15 @@ export function ImagesPanel({
Rename Rename
</Button> </Button>
)} )}
<Button <ConfirmButton
confirmLabel="Delete this image"
disabled={remove.pending} disabled={remove.pending}
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))} label="Delete"
variant="danger" onConfirm={() =>
> remove.run(() => api.send("DELETE", `images/${image.id}`))
Delete }
</Button> testId={`signage-image-delete-${image.id}`}
/>
</div> </div>
) : undefined} ) : undefined}
</li> </li>
+326 -331
View File
@@ -1,29 +1,16 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { import { ActionBar, Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
ActionBar, import { type ReactElement, useEffect, useRef, useState } from "react";
Badge,
Button,
EmptyState,
Field,
Panel,
Select,
} from "@ekklesia/ui";
import { type ReactElement, useState } from "react";
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
import { useAction, useResource, useTick } from "../hooks.js"; import { useAction, useResource, useTick } from "../hooks.js";
import type { import type {
ChannelGroupView, ChannelGroupView,
PlaybackDetailView, PlaybackDetailView,
SettingsResponse, SettingsResponse,
TimelineSummary, TimelineSummary,
} from "../types.js"; } from "../types.js";
import { PreviewPanel } from "./PreviewPanel.js"; import { PreviewPanel } from "./PreviewPanel.js";
import { import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
FailureNote,
formatDuration,
formatInstant,
renderLoadable,
} from "./shared.js";
/** /**
* Live transport for one channel group. * Live transport for one channel group.
@@ -33,325 +20,333 @@ import {
* button, and neither can produce a state the other did not ask for. * button, and neither can produce a state the other did not ask for.
*/ */
export function PlaybackPanel({ export function PlaybackPanel({
api, api,
context, context,
settings, settings,
canControl, canControl,
groupId, groupId,
onGroupChange, onGroupChange,
}: { }: {
readonly api: SignageApi; readonly api: SignageApi;
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse; readonly settings: SettingsResponse;
readonly canControl: boolean; readonly canControl: boolean;
readonly groupId: string | undefined; readonly groupId: string | undefined;
readonly onGroupChange: (groupId: string) => void; readonly onGroupChange: (groupId: string) => void;
}): ReactElement { }): ReactElement {
const groups = useResource( const groups = useResource(
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"), () => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
[], [],
); "channel-groups",
const timelines = useResource( );
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"), const timelines = useResource(
[], () => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
); [],
const detail = useResource( "timelines",
() => );
groupId === undefined const detail = useResource(
? Promise.resolve(undefined) () =>
: api.get<PlaybackDetailView>(`playback/${groupId}`), groupId === undefined
[groupId], ? Promise.resolve(undefined)
); : api.get<PlaybackDetailView>(`playback/${groupId}`),
const command = useAction(() => { [groupId],
detail.reload(); groupId === undefined ? undefined : `playback/${groupId}`,
groups.reload(); );
}); const command = useAction(() => {
const [activateId, setActivateId] = useState(""); detail.reload();
const previewChannelId = groups.reload();
groups.state.status === "ready" });
? (groups.state.value.groups const [activateId, setActivateId] = useState("");
.find((group) => group.id === groupId) const previewChannelId =
?.channels.find((channel) => channel.isDefault)?.id ?? undefined) groups.state.status === "ready"
: undefined; ? (groups.state.value.groups
// A one-second tick so the remaining time counts down rather than sitting still between refreshes. .find((group) => group.id === groupId)
useTick(1000); ?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
: undefined;
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
useTick(1000);
useManagementUpdates(context, groupId, () => {
detail.reload();
groups.reload();
});
function run(path: string, body?: unknown): void { function run(path: string, body?: unknown): void {
if (groupId === undefined) { if (groupId === undefined) {
return; return;
} }
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body)); command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
} }
function timelineLabel(timelineId: string): string { function timelineLabel(timelineId: string): string {
if (timelines.state.status !== "ready") { if (timelines.state.status !== "ready") {
return timelineId; return timelineId;
} }
return ( return (
timelines.state.value.timelines.find( timelines.state.value.timelines.find((timeline) => timeline.id === timelineId)?.name ??
(timeline) => timeline.id === timelineId, timelineId
)?.name ?? timelineId );
); }
}
return ( return (
<div className="signage-columns signage-playback-layout"> <div className="signage-columns signage-playback-layout">
<Panel <Panel
actions={renderLoadable( actions={renderLoadable(
groups.state, groups.state,
(value) => ( (value) => (
<Field label="Channel group"> <Field label="Channel group">
{(control) => ( {(control) => (
<Select <Select
{...control} {...control}
onChange={(event) => onGroupChange(event.target.value)} onChange={(event) => onGroupChange(event.target.value)}
value={groupId ?? ""} value={groupId ?? ""}
> >
{value.groups.map((group) => ( {value.groups.map((group) => (
<option key={group.id} value={group.id}> <option key={group.id} value={group.id}>
{group.name} {group.name}
</option> </option>
))} ))}
</Select> </Select>
)} )}
</Field> </Field>
), ),
{ label: "Loading groups…" }, { label: "Loading groups…" },
)} )}
description="One clock for every channel in the group." description="One clock for every channel in the group."
title="Playback" title="Playback"
> >
{groupId === undefined ? ( {groupId === undefined ? (
<EmptyState title="No channel group selected" /> <EmptyState title="No channel group selected" />
) : ( ) : (
renderLoadable( renderLoadable(
detail.state, detail.state,
(value) => (value) =>
value === undefined ? ( value === undefined ? (
<EmptyState title="No channel group selected" /> <EmptyState title="No channel group selected" />
) : ( ) : (
<div <div className="signage-playback" data-testid="signage-playback">
className="signage-playback" <div className="signage-playback__status">
data-testid="signage-playback" {value.snapshot.activeTimelineId === null ? undefined : (
> <Badge tone="success">
<div className="signage-playback__status"> {`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`}
{value.snapshot.activeTimelineId === null ? undefined : ( </Badge>
<Badge tone="success"> )}
{`Active: ${timelineLabel(value.snapshot.activeTimelineId)}`} <Badge
</Badge> tone={
)} value.snapshot.transport === "playing"
<Badge ? "success"
tone={ : value.snapshot.transport === "idle"
value.snapshot.transport === "playing" ? "warning"
? "success" : "info"
: value.snapshot.transport === "idle" }
? "warning" >
: "info" {value.snapshot.transport}
} </Badge>
> {value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
{value.snapshot.transport} {value.current?.stale === true ? (
</Badge> <Badge tone="warning">Stale</Badge>
{value.snapshot.blackout ? ( ) : undefined}
<Badge tone="danger">Blackout</Badge> {value.current?.withdrawn === true ? (
) : undefined} <Badge tone="warning">Withdrawn</Badge>
{value.current?.stale === true ? ( ) : undefined}
<Badge tone="warning">Stale</Badge> {value.snapshot.pendingTimelineId === null ? undefined : (
) : undefined} <Badge tone="info">Activation queued</Badge>
{value.current?.withdrawn === true ? ( )}
<Badge tone="warning">Withdrawn</Badge> </div>
) : undefined}
{value.snapshot.pendingTimelineId === null ? undefined : (
<Badge tone="info">Activation queued</Badge>
)}
</div>
{value.current === null ? ( {value.current === null ? (
<EmptyState <EmptyState
description="Nothing is currently resolvable. A display shows its safe slate." description="Nothing is currently resolvable. A display shows its safe slate."
title="Nothing on screen" title="Nothing on screen"
/> />
) : ( ) : (
<section className="signage-playback__now"> <section className="signage-playback__now">
<h3>Now showing</h3> <h3>Now showing</h3>
<dl <dl className="signage-playback__current" data-testid="signage-current">
className="signage-playback__current" <dt>Provider</dt>
data-testid="signage-current" <dd>{value.current.providerId}</dd>
> <dt>Item</dt>
<dt>Provider</dt> <dd>{value.current.itemId}</dd>
<dd>{value.current.providerId}</dd> <dt>Renderer</dt>
<dt>Item</dt> <dd>{value.current.rendererContributionId ?? "—"}</dd>
<dd>{value.current.itemId}</dd> <dt>Duration</dt>
<dt>Renderer</dt> <dd>{formatDuration(value.current.durationMs)}</dd>
<dd>{value.current.rendererContributionId ?? "—"}</dd> <dt>Remaining</dt>
<dt>Duration</dt> <dd data-testid="signage-remaining">
<dd>{formatDuration(value.current.durationMs)}</dd> {value.snapshot.remainingMs === null
<dt>Remaining</dt> ? "—"
<dd data-testid="signage-remaining"> : formatDuration(value.snapshot.remainingMs)}
{value.snapshot.remainingMs === null </dd>
? "—" <dt>Started</dt>
: formatDuration(value.snapshot.remainingMs)} <dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
</dd> </dl>
<dt>Started</dt> </section>
<dd> )}
{formatInstant(
value.current.startedAtMs,
settings.timezone,
)}
</dd>
</dl>
</section>
)}
{canControl ? ( {canControl ? (
<ActionBar <ActionBar
className="signage-playback__controls" className="signage-playback__controls"
data-testid="signage-controls" data-testid="signage-controls"
label="Playback controls" label="Playback controls"
> >
<Button <Button disabled={command.pending} onClick={() => run("previous")}>
disabled={command.pending} Previous
onClick={() => run("previous")} </Button>
> {value.snapshot.transport === "paused" ? (
Previous <Button
</Button> disabled={command.pending}
{value.snapshot.transport === "paused" ? ( onClick={() => run("resume")}
<Button variant="primary"
disabled={command.pending} >
onClick={() => run("resume")} Resume
variant="primary" </Button>
> ) : (
Resume <Button disabled={command.pending} onClick={() => run("pause")}>
</Button> Pause
) : ( </Button>
<Button )}
disabled={command.pending} <Button disabled={command.pending} onClick={() => run("next")}>
onClick={() => run("pause")} Next
> </Button>
Pause <Button
</Button> disabled={command.pending}
)} onClick={() => run("blackout", { on: !value.snapshot.blackout })}
<Button variant={value.snapshot.blackout ? "primary" : "secondary"}
disabled={command.pending} >
onClick={() => run("next")} {value.snapshot.blackout ? "End blackout" : "Blackout"}
> </Button>
Next {value.snapshot.staticAssetId === null ? undefined : (
</Button> <Button
<Button disabled={command.pending}
disabled={command.pending} onClick={() => run("static", { assetId: null })}
onClick={() => >
run("blackout", { on: !value.snapshot.blackout }) Release hold
} </Button>
variant={ )}
value.snapshot.blackout ? "primary" : "secondary" <Button disabled={command.pending} onClick={() => run("reload")}>
} Reload content
> </Button>
{value.snapshot.blackout ? "End blackout" : "Blackout"} </ActionBar>
</Button> ) : undefined}
{value.snapshot.staticAssetId === null ? undefined : (
<Button
disabled={command.pending}
onClick={() => run("static", { assetId: null })}
>
Release hold
</Button>
)}
<Button
disabled={command.pending}
onClick={() => run("reload")}
>
Reload content
</Button>
</ActionBar>
) : undefined}
{canControl ? ( {canControl ? (
<div className="signage-playback__activate"> <div className="signage-playback__activate">
{renderLoadable( {renderLoadable(
timelines.state, timelines.state,
(loaded) => ( (loaded) => (
<> <>
<Field label="Activate timeline"> <Field label="Activate timeline">
{(control) => ( {(control) => (
<Select <Select
{...control} {...control}
onChange={(event) => onChange={(event) => setActivateId(event.target.value)}
setActivateId(event.target.value) value={activateId}
} >
value={activateId} <option value="">Choose a timeline…</option>
> {loaded.timelines.map((timeline) => (
<option value="">Choose a timeline…</option> <option key={timeline.id} value={timeline.id}>
{loaded.timelines.map((timeline) => ( {timeline.name}
<option </option>
key={timeline.id} ))}
value={timeline.id} </Select>
> )}
{timeline.name} </Field>
</option> <Button
))} disabled={command.pending || activateId === ""}
</Select> onClick={() =>
)} run("activate", {
</Field> timelineId: activateId,
<Button when: "now",
disabled={command.pending || activateId === ""} })
onClick={() => }
run("activate", { variant="primary"
timelineId: activateId, >
when: "now", Now
}) </Button>
} <Button
variant="primary" disabled={command.pending || activateId === ""}
> onClick={() =>
Now run("activate", {
</Button> timelineId: activateId,
<Button when: "next-boundary",
disabled={command.pending || activateId === ""} })
onClick={() => }
run("activate", { >
timelineId: activateId, At next slot
when: "next-boundary", </Button>
}) </>
} ),
> { label: "Loading timelines…" },
At next slot )}
</Button> </div>
</> ) : undefined}
),
{ label: "Loading timelines…" },
)}
</div>
) : undefined}
{value.history.length === 0 ? undefined : ( {value.history.length === 0 ? undefined : (
<div className="signage-playback__history"> <div className="signage-playback__history">
<h4 className="signage-editor__subtitle"> <h4 className="signage-editor__subtitle">Recently played</h4>
Recently played <ul className="ek-list">
</h4> {value.history.slice(0, 8).map((entry) => (
<ul className="ek-list"> <li className="ek-list__item signage-row" key={entry.id}>
{value.history.slice(0, 8).map((entry) => ( <span className="signage-row__name">{entry.itemId}</span>
<li <span className="signage-playback__history-meta">
className="ek-list__item signage-row" {`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
key={entry.id} </span>
> </li>
<span className="signage-row__name"> ))}
{entry.itemId} </ul>
</span> </div>
<span className="signage-playback__history-meta"> )}
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`} </div>
</span> ),
</li> { label: "Loading playback…", onRetry: detail.reload },
))} )
</ul> )}
</div> <FailureNote failure={command.failure} />
)} </Panel>
</div>
),
{ label: "Loading playback…", onRetry: detail.reload },
)
)}
<FailureNote failure={command.failure} />
</Panel>
<PreviewPanel api={api} channelId={previewChannelId} context={context} /> <PreviewPanel api={api} channelId={previewChannelId} context={context} />
</div> </div>
); );
}
/**
* Follows Signage's management realtime channel, which carries revisions only (ADR 0005), and asks
* for fresh detail when this group's playback actually changed — so "Now showing" follows the wall
* instead of freezing until an operator presses something. One subscription per mounted panel,
* closed when the panel goes away.
*/
function useManagementUpdates(
context: ModuleUiHostContext,
groupId: string | undefined,
onChange: () => void,
): void {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (groupId === undefined) {
return;
}
let seen: string | undefined;
const subscription = context.realtime.subscribe("management-state", {
onSnapshot: (payload) => observe(payload),
onUpdate: (payload) => observe(payload),
});
function observe(payload: unknown): void {
const groups = (payload as { groups?: unknown } | null)?.groups;
if (!Array.isArray(groups)) {
return;
}
const mine = groups.find((group: { groupId?: unknown }) => group?.groupId === groupId) as
| { revision?: unknown; selectionRevision?: unknown }
| undefined;
if (!mine) {
return;
}
const marker = `${String(mine.revision)}:${String(mine.selectionRevision)}`;
if (seen !== undefined && marker !== seen) {
onChangeRef.current();
}
seen = marker;
}
return () => subscription.close();
}, [context, groupId]);
} }
+11 -1
View File
@@ -2,6 +2,7 @@ import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EmptyState, Panel } from "@ekklesia/ui"; import { EmptyState, Panel } from "@ekklesia/ui";
import { type ReactElement, useEffect, useRef } from "react"; import { type ReactElement, useEffect, useRef } from "react";
import { SignageSlate } from "../../browser/slate.js"; import { SignageSlate } from "../../browser/slate.js";
import { createMemoryMediaCache, createMemoryStore } from "../../browser/storage.js";
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js"; import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
@@ -36,7 +37,16 @@ export function PreviewPanel({
return; return;
} }
let stopped = false; let stopped = false;
const controller = createDisplayController({ context, root, telemetry: false }); // The display's own rendering code, but nothing it keeps: a management browser is not a wall,
// so it persists no snapshot and caches no image bytes, and never restores one channel's last
// frame into another channel's preview.
const controller = createDisplayController({
context,
media: createMemoryMediaCache(),
root,
store: createMemoryStore(),
telemetry: false,
});
let handle: ReturnType<typeof setTimeout> | undefined; let handle: ReturnType<typeof setTimeout> | undefined;
async function pump(): Promise<void> { async function pump(): Promise<void> {
+89 -102
View File
@@ -1,13 +1,13 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { import {
Badge, Badge,
EmptyState, EmptyState,
Panel, Panel,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableRow, TableRow,
} from "@ekklesia/ui"; } from "@ekklesia/ui";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import type { SignageApi } from "../api.js"; import type { SignageApi } from "../api.js";
@@ -31,101 +31,88 @@ const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer";
* exactly that, which is the difference between "this is broken" and "this needs an update". * exactly that, which is the difference between "this is broken" and "this needs an update".
*/ */
export function ProvidersPanel({ export function ProvidersPanel({
api, api,
context, context,
settings, settings,
}: { }: {
readonly api: SignageApi; readonly api: SignageApi;
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
readonly settings: SettingsResponse; readonly settings: SettingsResponse;
}): ReactElement { }): ReactElement {
const providers = useResource( const providers = useResource(
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"), () => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
[], [],
); "providers",
const published = new Set( );
context.contributions const published = new Set(
.list(SLIDE_RENDERER_CAPABILITY) context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
.map((contribution) => contribution.id), );
);
return ( return (
<Panel <Panel
actions={ actions={<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge>}
<Badge tone="neutral">{`Contract ${settings.rendererInputVersion}`}</Badge> description="Running content sources available to timelines."
} title="Content providers"
description="Running content sources available to timelines." >
title="Content providers" {renderLoadable(
> providers.state,
{renderLoadable( (value) =>
providers.state, value.providers.length === 0 ? (
(value) => <EmptyState
value.providers.length === 0 ? ( description="Signage works on its own with native images. Install a content module to add more."
<EmptyState title="No content providers registered"
description="Signage works on its own with native images. Install a content module to add more." />
title="No content providers registered" ) : (
/> <Table label="Provider health">
) : ( <TableHead>
<Table label="Provider health"> <TableRow>
<TableHead> <TableCell header>Provider</TableCell>
<TableRow> <TableCell header>Contract</TableCell>
<TableCell header>Provider</TableCell> <TableCell header>Renderer</TableCell>
<TableCell header>Contract</TableCell> <TableCell header>Health</TableCell>
<TableCell header>Renderer</TableCell> <TableCell header>Last success</TableCell>
<TableCell header>Health</TableCell> </TableRow>
<TableCell header>Last success</TableCell> </TableHead>
</TableRow> <TableBody>
</TableHead> {value.providers.map((provider) => {
<TableBody> const rendererPublished = published.has(provider.renderer.contributionId);
{value.providers.map((provider) => { const isNative = provider.renderer.contributionId === "signage.native-image";
const rendererPublished = published.has( return (
provider.renderer.contributionId, <TableRow key={provider.providerId}>
); <TableCell>
const isNative = <span className="signage-provider__id">{provider.providerId}</span>
provider.renderer.contributionId === "signage.native-image"; <span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
return ( </TableCell>
<TableRow key={provider.providerId}> <TableCell>{provider.contractVersion}</TableCell>
<TableCell> <TableCell>
<span className="signage-provider__id"> {isNative ? (
{provider.providerId} <Badge tone="info">Built in</Badge>
</span> ) : rendererPublished ? (
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span> <Badge tone="success">{provider.renderer.contributionId}</Badge>
</TableCell> ) : (
<TableCell>{provider.contractVersion}</TableCell> <Badge tone="warning">Renderer not published</Badge>
<TableCell> )}
{isNative ? ( </TableCell>
<Badge tone="info">Built in</Badge> <TableCell>
) : rendererPublished ? ( {provider.consecutiveFailures === 0 ? (
<Badge tone="success"> <Badge tone="success">Healthy</Badge>
{provider.renderer.contributionId} ) : (
</Badge> <Badge tone="danger">
) : ( {`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
<Badge tone="warning">Renderer not published</Badge> </Badge>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{provider.consecutiveFailures === 0 ? ( {formatInstant(provider.lastSuccessAt, settings.timezone)}
<Badge tone="success">Healthy</Badge> </TableCell>
) : ( </TableRow>
<Badge tone="danger"> );
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`} })}
</Badge> </TableBody>
)} </Table>
</TableCell> ),
<TableCell> { label: "Loading providers…", onRetry: providers.reload },
{formatInstant( )}
provider.lastSuccessAt, </Panel>
settings.timezone, );
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
),
{ label: "Loading providers…", onRetry: providers.reload },
)}
</Panel>
);
} }
File diff suppressed because it is too large Load Diff
+79 -2
View File
@@ -1,5 +1,5 @@
import { Badge, ErrorState, LoadingState } from "@ekklesia/ui"; import { Badge, Button, ErrorState, LoadingState } from "@ekklesia/ui";
import type { ReactElement, ReactNode } from "react"; import { type ReactElement, type ReactNode, useEffect, useState } from "react";
import type { SignageApiFailure } from "../api.js"; import type { SignageApiFailure } from "../api.js";
import type { Loadable } from "../hooks.js"; import type { Loadable } from "../hooks.js";
@@ -89,3 +89,80 @@ export function formatSince(atMs: number | null, nowMs: number): string {
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement { export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>; return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
} }
/** How long an armed destructive button waits for its second press before standing down. */
export const CONFIRM_RESET_MS = 6000;
/**
* A moderate destructive action, confirmed in place (DESIGN §21.3) — never with a browser dialog.
*
* The first press arms it: the button says plainly what will happen and a Cancel appears beside it.
* A second, deliberate press performs the action. Walking away, pressing Cancel, or Escape disarms
* it, so an armed button can never be left waiting for an accidental click.
*/
export function ConfirmButton({
label,
confirmLabel,
disabled,
onConfirm,
testId,
}: {
readonly label: string;
/** What the second press does, in words: "Delete this image", not "Are you sure?". */
readonly confirmLabel: string;
readonly disabled?: boolean;
readonly onConfirm: () => void;
readonly testId?: string;
}): ReactElement {
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!armed) {
return;
}
const timer = setTimeout(() => setArmed(false), CONFIRM_RESET_MS);
return () => clearTimeout(timer);
}, [armed]);
if (!armed) {
return (
<Button
data-testid={testId}
disabled={disabled}
onClick={() => setArmed(true)}
variant="danger"
>
{label}
</Button>
);
}
return (
<fieldset
className="signage-confirm-inline"
onKeyDown={(event) => {
if (event.key === "Escape") {
setArmed(false);
}
}}
>
<Button
autoFocus
data-testid={testId === undefined ? undefined : `${testId}-confirm`}
disabled={disabled}
onClick={() => {
setArmed(false);
onConfirm();
}}
variant="danger"
>
{confirmLabel}
</Button>
<Button
data-testid={testId === undefined ? undefined : `${testId}-cancel`}
onClick={() => setArmed(false)}
variant="quiet"
>
Cancel
</Button>
</fieldset>
);
}
+422 -414
View File
File diff suppressed because it is too large Load Diff
+46 -1
View File
@@ -200,7 +200,8 @@ describe("display ↔ server contract", () => {
const sent = calls.map((call) => `${call.method} ${call.path}`); const sent = calls.map((call) => `${call.method} ${call.path}`);
expect(sent).toContain("POST /viewers/session"); expect(sent).toContain("POST /viewers/session");
expect(sent).toContain("GET /channels/:channelId/display-state"); // By viewer, so the server — not the display's memory of its registration — decides the channel.
expect(sent).toContain("GET /viewers/:viewerId/display-state");
expect(sent).toContain("POST /viewers/:viewerId/heartbeat"); expect(sent).toContain("POST /viewers/:viewerId/heartbeat");
// Not one refusal, of any kind, for anything the display built itself. // Not one refusal, of any kind, for anything the display built itself.
expect(calls.filter((call) => call.status >= 400)).toEqual([]); expect(calls.filter((call) => call.status >= 400)).toEqual([]);
@@ -220,4 +221,48 @@ describe("display ↔ server contract", () => {
expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID); expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID);
expect(viewer?.reportedContributionId).toBeNull(); expect(viewer?.reportedContributionId).toBeNull();
}); });
it("a display moved to another channel group is served that group's state on its next fetch", async () => {
const display = controller();
await display.start();
await settle();
const viewerId = display.diagnostics().viewerId;
const firstChannel = display.diagnostics().channelId;
expect(viewerId).toBeDefined();
// An administrator creates a second group and moves this screen to it while it is running.
const groupId = newId();
const channelId = newId();
await store.createChannelGroup({ channelId, channelName: "Default", groupId, name: "Nursery" });
await store.updateViewer(viewerId as string, { channelId });
const response = await host.context.api.request(`viewers/${viewerId}/display-state`);
const state = (await response.json()) as { channel: { id: string; groupId: string } };
expect(state.channel).toMatchObject({ groupId, id: channelId });
expect(channelId).not.toBe(firstChannel);
// And the heartbeat names the new assignment, which is how a display with a long slot hears.
const beat = await host.context.api.request(`viewers/${viewerId}/heartbeat`, {
body: JSON.stringify({}),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(((await beat.json()) as { channelId?: string }).channelId).toBe(channelId);
await display.stop();
});
it("a display whose channel was deleted falls back to the default channel, not to nothing", async () => {
const display = controller();
await display.start();
await settle();
const viewerId = display.diagnostics().viewerId as string;
await store.updateViewer(viewerId, { channelId: null });
const response = await host.context.api.request(`viewers/${viewerId}/display-state`);
expect(response.status).toBe(200);
const [group] = await store.listChannelGroups();
const fallback = group?.channels.find((channel) => channel.isDefault);
expect(((await response.json()) as { channel: { id: string } }).channel.id).toBe(fallback?.id);
await display.stop();
});
}); });
+1 -1
View File
@@ -145,7 +145,7 @@ describe("a display recovers from every failed request without a reload", () =>
}, },
{ {
method: "GET", method: "GET",
path: "channels/chan-1/display-state", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
respond: () => { respond: () => {
server.stateRequests += 1; server.stateRequests += 1;
if (server.stateFailing) { if (server.stateFailing) {
+168 -4
View File
@@ -174,7 +174,7 @@ describe("display controller", () => {
}, },
{ {
method: "GET", method: "GET",
path: "channels/chan-1/display-state", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
respond: () => jsonResponse(state()), respond: () => jsonResponse(state()),
}, },
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
@@ -205,7 +205,7 @@ describe("display controller", () => {
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{ {
method: "GET", method: "GET",
path: "channels/chan-1/display-state", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
respond: () => jsonResponse(state()), respond: () => jsonResponse(state()),
}, },
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
@@ -376,6 +376,28 @@ describe("display controller", () => {
await controller.stop(); await controller.stop();
}); });
it("restores a blackout before the server answers, so a reload never flashes content", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
const online = createFakeHost({ permissions: [], routes: [] });
const before = build({ host: online, media, store, telemetry: false });
await before.start();
await before.apply(state({ playback: { blackout: true } as never }));
await settle();
await before.stop();
// A kiosk reloads mid-blackout and its first request has not been answered yet.
root.dataset.blackout = "false";
const pending = createFakeHost({ permissions: ["signage.display.view"], routes: [] });
Object.assign(pending.context.api, { request: () => new Promise<Response>(() => undefined) });
const after = build({ host: pending, media, store });
void after.start();
await settle();
expect(root.dataset.blackout).toBe("true");
await after.stop();
});
it("reports a bounded, sanitized heartbeat and nothing else", async () => { it("reports a bounded, sanitized heartbeat and nothing else", async () => {
const beats: unknown[] = []; const beats: unknown[] = [];
const host = createFakeHost({ const host = createFakeHost({
@@ -384,7 +406,7 @@ describe("display controller", () => {
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{ {
method: "GET", method: "GET",
path: "channels/chan-1/display-state", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
respond: () => jsonResponse(state()), respond: () => jsonResponse(state()),
}, },
{ {
@@ -428,7 +450,7 @@ describe("display controller", () => {
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) }, { method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{ {
method: "GET", method: "GET",
path: "channels/chan-1/display-state", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
respond: () => jsonResponse(state()), respond: () => jsonResponse(state()),
}, },
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) }, { method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
@@ -471,4 +493,146 @@ describe("display controller", () => {
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1"); expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
await controller.stop(); await controller.stop();
}); });
it("follows the server when an administrator moves it to another channel", async () => {
const moved = { groupId: "group-2", id: "chan-2", key: "online" };
let stateFetches = 0;
let realtimeHandlers: { onUpdate?: (state: unknown) => void } | undefined;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{
method: "GET",
path: /^viewers\/[^/]+\/display-state$/u,
respond: () => {
stateFetches += 1;
// Registered on chan-1; moved by an administrator right after its first fetch.
return jsonResponse(state(stateFetches === 1 ? {} : { channel: moved }));
},
},
{
method: "POST",
path: /^viewers\/.*\/heartbeat$/u,
respond: () => jsonResponse({ acknowledged: true, channelId: moved.id, recorded: true }),
},
],
});
Object.assign(host.context.realtime, {
subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => {
realtimeHandlers = handlers;
return { close: () => undefined };
},
});
const controller = build({ host });
await controller.start();
await settle();
// The heartbeat named a different channel, so the display refetched and adopted it.
expect(stateFetches).toBe(2);
expect(controller.diagnostics().channelId).toBe("chan-2");
// A change in the old group no longer concerns it; a change in the new one does.
realtimeHandlers?.onUpdate?.({
groups: [{ groupId: "group-1", revision: 99, selectionRevision: 1 }],
});
await settle();
expect(stateFetches).toBe(2);
realtimeHandlers?.onUpdate?.({
groups: [{ groupId: "group-2", revision: 99, selectionRevision: 1 }],
});
await settle();
expect(stateFetches).toBe(3);
await controller.stop();
});
it("registers again, exactly once, when it was removed from the roster", async () => {
let sessions = 0;
let subscriptions = 0;
let closes = 0;
let removed = false;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () => {
sessions += 1;
removed = false;
return jsonResponse(SESSION);
},
},
{
method: "GET",
path: /^viewers\/[^/]+\/display-state$/u,
respond: () =>
removed ? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404) : jsonResponse(state()),
},
{
method: "POST",
path: /^viewers\/.*\/heartbeat$/u,
respond: () =>
removed
? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404)
: jsonResponse({ acknowledged: true }),
},
],
});
let onUpdate: ((state: unknown) => void) | undefined;
Object.assign(host.context.realtime, {
subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => {
subscriptions += 1;
onUpdate = handlers.onUpdate;
return { close: () => (closes += 1) };
},
});
const controller = build({ host });
await controller.start();
await settle();
expect(sessions).toBe(1);
// Removed from the roster; a realtime nudge and a heartbeat both discover it at once.
removed = true;
onUpdate?.({ groups: [{ groupId: "group-1", revision: 50, selectionRevision: 1 }] });
onUpdate?.({ groups: [{ groupId: "group-1", revision: 51, selectionRevision: 1 }] });
await settle();
expect(sessions).toBe(2);
// One live subscription: the old one closed, exactly one new one opened.
expect(subscriptions - closes).toBe(1);
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
await controller.stop();
});
it("treats a 404 that is not Signage's own as an ordinary failure, never a re-registration loop", async () => {
let sessions = 0;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () => {
sessions += 1;
return jsonResponse(SESSION);
},
},
{
method: "GET",
path: /^viewers\/[^/]+\/display-state$/u,
// A proxy, or a server without this route — not "this viewer is gone".
respond: () => jsonResponse({ code: "NOT_FOUND" }, 404),
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
],
});
const controller = build({ host });
await controller.start();
await settle();
await settle();
expect(sessions).toBe(1);
await controller.stop();
});
}); });
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -391,7 +391,7 @@ describe("a display that loses its network", () => {
}, },
{ {
method: "GET", method: "GET",
path: "channels/chan-1/display-state", path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
respond: () => { respond: () => {
if (!reachable) { if (!reachable) {
throw new Error("network is gone"); throw new Error("network is gone");
+414
View File
@@ -0,0 +1,414 @@
// @vitest-environment happy-dom
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { audienceKeyFor } from "../../src/ui/panels/DisplaysPanel.js";
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
import {
createFakeHost,
FAKE_SETTINGS,
type FakeHostRoute,
jsonResponse,
} from "./support/fake-host.js";
/**
* Stage 0 production-readiness regressions for the management screen.
*
* Each case is a defect an operator met: an activation that looked lost after changing tabs, a
* screen that collapsed to a spinner on every tab switch, deletions with no confirmation, an
* internal key presented as something to fill in, and test displays nobody could remove.
*/
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
async function settle(): Promise<void> {
for (let index = 0; index < 8; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
async function click(element: Element | null | undefined): Promise<void> {
if (!element) {
throw new Error("Expected an element to click.");
}
await act(async () => (element as HTMLElement).click());
await settle();
}
function button(label: string): HTMLButtonElement | undefined {
return [...container.querySelectorAll("button")].find(
(candidate) => candidate.textContent?.trim() === label,
) as HTMLButtonElement | undefined;
}
function tab(label: string): Element | undefined {
return [...container.querySelectorAll("[role='tab']")].find(
(candidate) => candidate.textContent === label,
);
}
async function choose(select: HTMLSelectElement, value: string): Promise<void> {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
await act(async () => {
setter?.call(select, value);
select.dispatchEvent(new Event("change", { bubbles: true }));
});
}
async function type(input: HTMLInputElement, value: string): Promise<void> {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
await act(async () => {
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function timeline(id: string, name: string) {
return {
description: null,
eligibleFrom: null,
eligibleUntil: null,
id,
name,
revision: 1,
updatedAt: "2026-10-01T15:00:00.000Z",
};
}
function group(activeTimelineId: string | null) {
return {
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
id: "group-1",
name: "Main",
playback: {
activeTimelineId,
blackout: false,
currentSelectionId: null,
currentSlotId: null,
deadlineAtMs: null,
groupId: "group-1",
pendingTimelineId: null,
remainingMs: null,
revision: activeTimelineId === null ? 1 : 2,
scheduledActivationAtMs: null,
scheduledTimelineId: null,
selectionRevision: null,
serverNowMs: 1_700_000_000_000,
startedAtMs: null,
staticAssetId: null,
transport: activeTimelineId === null ? ("idle" as const) : ("playing" as const),
},
revision: 1,
};
}
/** A small, stateful Signage backend: activation really changes what later reads return. */
function backend(extra: readonly FakeHostRoute[] = []) {
let active: string | null = null;
const sent: string[] = [];
const routes: FakeHostRoute[] = [
...extra,
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
{
method: "GET",
path: "channel-groups",
respond: () => jsonResponse({ groups: [group(active)] }),
},
{
method: "GET",
path: "timelines",
respond: () =>
jsonResponse({
timelines: [timeline("timeline-1", "Sunday"), timeline("timeline-2", "Easter")],
}),
},
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [] }) },
{
method: "GET",
path: "playback/group-1",
respond: () => jsonResponse({ current: null, history: [], snapshot: group(active).playback }),
},
{ method: "GET", path: /^channels\/.*\/display-state$/u, respond: () => jsonResponse({}) },
{
method: "POST",
path: "playback/group-1/activate",
respond: ({ init }) => {
active = (JSON.parse(String(init?.body)) as { timelineId: string }).timelineId;
sent.push(`activate ${active}`);
return jsonResponse(group(active).playback);
},
},
];
return { routes, sent };
}
function mount(host: ReturnType<typeof createFakeHost>) {
return act(async () => {
root.render(<SignageDashboard context={host.context} copyText={() => Promise.resolve()} />);
});
}
describe("Signage dashboard — Stage 0 regressions", () => {
it("shows a timeline activated on Playback as active on Timelines too", async () => {
const server = backend();
const host = createFakeHost({
permissions: ["signage.view", "signage.playback.control", "signage.timelines.manage"],
routes: server.routes,
});
await mount(host);
await settle();
const activate = [...container.querySelectorAll("select")].find((select) =>
[...select.options].some((option) => option.textContent === "Choose a timeline…"),
) as HTMLSelectElement;
await choose(activate, "timeline-2");
await click(button("Now"));
expect(server.sent).toEqual(["activate timeline-2"]);
await click(tab("Timelines"));
const row = [...container.querySelectorAll("tr")].find((candidate) =>
candidate.textContent?.includes("Easter"),
);
expect(row?.textContent).toContain("Active in Main");
expect(container.querySelector("tr")?.parentElement).not.toBeNull();
});
it("keeps what it loaded when switching tabs, instead of flashing back to a loading state", async () => {
let slow = false;
const server = backend([
{
method: "GET",
path: "timelines",
respond: () =>
slow
? new Promise<Response>(() => undefined)
: jsonResponse({ timelines: [timeline("timeline-1", "Sunday")] }),
},
]);
const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes });
await mount(host);
await settle();
await click(tab("Timelines"));
expect(container.textContent).toContain("Sunday");
// The next load never finishes. Coming back must still show the last known list.
slow = true;
await click(tab("Images"));
await click(tab("Timelines"));
expect(container.textContent).toContain("Sunday");
expect(container.textContent).not.toContain("Loading timelines…");
});
it("deletes an image only after a second, deliberate press, and Cancel stands it down", async () => {
const deleted: string[] = [];
const image = {
byteSize: 1000,
contentType: "image/png",
createdAt: "2026-10-01T15:00:00.000Z",
displayName: "Welcome",
expiresAt: null,
height: 1080,
id: "image-1",
originalFilename: "welcome.png",
renditions: [],
status: "ready",
width: 1920,
};
const server = backend([
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [image] }) },
{
method: "DELETE",
path: "images/image-1",
respond: ({ path }) => {
deleted.push(path);
return new Response(null, { status: 204 });
},
},
]);
const host = createFakeHost({
permissions: ["signage.view", "signage.assets.manage"],
routes: server.routes,
});
await mount(host);
await settle();
await click(tab("Images"));
await click(container.querySelector("[data-testid='signage-image-delete-image-1']"));
expect(deleted).toEqual([]);
expect(button("Delete this image")).toBeDefined();
await click(container.querySelector("[data-testid='signage-image-delete-image-1-cancel']"));
expect(button("Delete this image")).toBeUndefined();
expect(deleted).toEqual([]);
await click(container.querySelector("[data-testid='signage-image-delete-image-1']"));
await click(container.querySelector("[data-testid='signage-image-delete-image-1-confirm']"));
expect(deleted).toEqual(["images/image-1"]);
});
it("adds an audience from its name alone, deriving the content key", async () => {
const posted: unknown[] = [];
const server = backend([
{
method: "POST",
path: "channel-groups/group-1/channels",
respond: ({ init }) => {
posted.push(JSON.parse(String(init?.body)));
return jsonResponse({}, 201);
},
},
]);
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: server.routes,
});
await mount(host);
await settle();
await click(tab("Displays"));
const name = container.querySelector("input[placeholder='Online']") as HTMLInputElement;
await type(name, "Watching Online");
await click(button("Add audience"));
expect(posted).toEqual([{ key: "watching-online", name: "Watching Online" }]);
});
it("labels a signed-in browser and removes an offline entry only after confirmation", async () => {
const removed: string[] = [];
const viewer = {
cachedAssetCount: 0,
channelId: "chan-1",
deviceId: null,
id: "viewer-1",
lastError: null,
lastSeenAtMs: Date.now() - 600_000,
name: "New display",
offlineReady: false,
online: false,
reportedContributionId: null,
reportedProviderId: null,
reportedSelectionId: null,
reportedTimelineId: null,
};
const server = backend([
{ method: "GET", path: "viewers", respond: () => jsonResponse({ viewers: [viewer] }) },
{
method: "DELETE",
path: "viewers/viewer-1",
respond: ({ path }) => {
removed.push(path);
return new Response(null, { status: 204 });
},
},
]);
const host = createFakeHost({
permissions: ["signage.view", "signage.displays.manage"],
routes: server.routes,
});
await mount(host);
await settle();
await click(tab("Displays"));
expect(container.textContent).toContain("Signed-in browser");
await click(container.querySelector("[data-testid='signage-display-remove-viewer-1']"));
expect(removed).toEqual([]);
await click(container.querySelector("[data-testid='signage-display-remove-viewer-1-confirm']"));
expect(removed).toEqual(["viewers/viewer-1"]);
});
it("follows playback changes on the management channel, and stops when Playback is left", async () => {
const subscriptions: { closed: boolean; onUpdate: ((payload: unknown) => void) | undefined }[] =
[];
const server = backend();
const host = createFakeHost({ permissions: ["signage.view"], routes: server.routes });
Object.assign(host.context.realtime, {
subscribe(channel: string, handlers: { onUpdate?: (payload: unknown) => void }) {
const entry = { closed: false, onUpdate: handlers.onUpdate };
if (channel === "management-state") {
subscriptions.push(entry);
}
return { close: () => (entry.closed = true) };
},
});
await mount(host);
await settle();
const detailReads = () =>
host.requests.filter((request) => request.path === "playback/group-1").length;
const before = detailReads();
const live = subscriptions.find((entry) => !entry.closed);
await act(async () => {
live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 1, selectionRevision: null }] });
live?.onUpdate?.({ groups: [{ groupId: "group-1", revision: 2, selectionRevision: 4 }] });
});
await settle();
expect(detailReads()).toBeGreaterThan(before);
await click(tab("Images"));
expect(subscriptions.every((entry) => entry.closed)).toBe(true);
});
});
describe("audience content keys", () => {
it("derives a valid, unique key that is never the reserved default", () => {
expect(audienceKeyFor("Online", [])).toBe("online");
expect(audienceKeyFor("Online", ["online"])).toBe("online-2");
expect(audienceKeyFor("Default", [])).toBe("default-2");
expect(audienceKeyFor("2nd service", [])).toBe("audience-2nd-service");
expect(audienceKeyFor(" ", [])).toBe("audience");
expect(audienceKeyFor("Café — Room!", [])).toBe("cafe-room");
});
});
/** Source-level guards for two product-wide rules (DESIGN §2.1 and §21.1). */
describe("product rules in the browser source", () => {
function sources(directory: string): string[] {
return readdirSync(directory).flatMap((entry) => {
const path = join(directory, entry);
if (statSync(path).isDirectory()) {
return sources(path);
}
return /\.(ts|tsx)$/u.test(entry) ? [path] : [];
});
}
const files = ["src/ui", "src/browser", "src/display"].flatMap(sources);
it("never uses a browser-native alert, confirm, or prompt", () => {
for (const file of files) {
expect(readFileSync(file, "utf8"), file).not.toMatch(
/\b(?:window\.|globalThis\.)?(?:alert|confirm|prompt)\s*\(/u,
);
}
});
it("never puts the platform's name in user-facing text", () => {
for (const file of files) {
const code = readFileSync(file, "utf8")
.replace(/\/\*[\s\S]*?\*\//gu, "")
.replace(/^\s*\/\/.*$/gmu, "")
.replace(/\{\/\*[\s\S]*?\*\/\}/gu, "");
// Quoted strings and JSX text; identifiers such as `EkklesiaUiStyles` are not text.
expect(code, file).not.toMatch(
/["'`][^"'`\n]*\bEkklesia\b[^"'`\n]*["'`]|>[^<{]*\bEkklesia\b/u,
);
}
});
});
+103 -113
View File
@@ -1,10 +1,10 @@
import type { import type {
ModuleContributionDescriptor, ModuleContributionDescriptor,
ModuleUiApiClient, ModuleUiApiClient,
ModuleUiContributions, ModuleUiContributions,
ModuleUiHostContext, ModuleUiHostContext,
MountedModuleContribution, MountedModuleContribution,
MountModuleContributionOptions, MountModuleContributionOptions,
} from "@ekklesia/module-ui-sdk"; } from "@ekklesia/module-ui-sdk";
/** /**
@@ -15,132 +15,122 @@ import type {
* simply never match. * simply never match.
*/ */
export interface FakeHostRoute { export interface FakeHostRoute {
readonly method: string; readonly method: string;
readonly path: string | RegExp; readonly path: string | RegExp;
readonly respond: (request: { readonly respond: (request: {
readonly path: string; readonly path: string;
readonly init: RequestInit | undefined; readonly init: RequestInit | undefined;
}) => Response | Promise<Response>; }) => Response | Promise<Response>;
} }
export interface FakeHost { export interface FakeHost {
readonly context: ModuleUiHostContext; readonly context: ModuleUiHostContext;
readonly requests: { method: string; path: string }[]; readonly requests: { method: string; path: string }[];
/** Contributions this host published against Signage's renderer capability. */ /** Contributions this host published against Signage's renderer capability. */
readonly mounted: { contributionId: string; input: unknown }[]; readonly mounted: { contributionId: string; input: unknown }[];
} }
export function jsonResponse(body: unknown, status = 200): Response { export function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
status, status,
}); });
} }
export function errorResponse( export function errorResponse(code: string, message: string, status = 400): Response {
code: string, return jsonResponse({ code, message }, status);
message: string,
status = 400,
): Response {
return jsonResponse({ code, message }, status);
} }
export interface FakeHostOptions { export interface FakeHostOptions {
readonly permissions: readonly string[]; readonly permissions: readonly string[];
readonly routes: readonly FakeHostRoute[]; readonly routes: readonly FakeHostRoute[];
readonly contributions?: readonly ModuleContributionDescriptor[]; readonly contributions?: readonly ModuleContributionDescriptor[];
readonly displayUrl?: string; readonly displayUrl?: string;
readonly principal?: ModuleUiHostContext["principal"]; readonly principal?: ModuleUiHostContext["principal"];
readonly mount?: ( readonly mount?: (
options: MountModuleContributionOptions, options: MountModuleContributionOptions,
) => Promise<MountedModuleContribution> | MountedModuleContribution; ) => Promise<MountedModuleContribution> | MountedModuleContribution;
} }
export function createFakeHost(options: FakeHostOptions): FakeHost { export function createFakeHost(options: FakeHostOptions): FakeHost {
const requests: { method: string; path: string }[] = []; const requests: { method: string; path: string }[] = [];
const mounted: { contributionId: string; input: unknown }[] = []; const mounted: { contributionId: string; input: unknown }[] = [];
const api: ModuleUiApiClient = { const api: ModuleUiApiClient = {
async request(path, init) { async request(path, init) {
const method = (init?.method ?? "GET").toUpperCase(); const method = (init?.method ?? "GET").toUpperCase();
const normalized = path.replace(/^\//u, ""); const normalized = path.replace(/^\//u, "");
requests.push({ method, path: normalized }); requests.push({ method, path: normalized });
for (const route of options.routes) { for (const route of options.routes) {
const matches = const matches =
typeof route.path === "string" typeof route.path === "string"
? route.path === normalized.split("?")[0] ? route.path === normalized.split("?")[0]
: route.path.test(normalized); : route.path.test(normalized);
if (route.method === method && matches) { if (route.method === method && matches) {
return route.respond({ init, path: normalized }); return route.respond({ init, path: normalized });
} }
} }
return errorResponse( return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
"SIGNAGE_NOT_FOUND", },
"The requested resource was not found.", };
404,
);
},
};
const contributions: ModuleUiContributions = { const contributions: ModuleUiContributions = {
list: (capabilityId) => list: (capabilityId) =>
capabilityId === "signage.slide-renderer" capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
? (options.contributions ?? []) async mount(mountOptions) {
: [], mounted.push({
async mount(mountOptions) { contributionId: mountOptions.contributionId,
mounted.push({ input: mountOptions.input,
contributionId: mountOptions.contributionId, });
input: mountOptions.input, if (options.mount) {
}); return options.mount(mountOptions);
if (options.mount) { }
return options.mount(mountOptions); return {
} contributionId: mountOptions.contributionId,
return { mounted: true,
contributionId: mountOptions.contributionId, unmount: () => Promise.resolve(),
mounted: true, update: () => Promise.resolve(),
unmount: () => Promise.resolve(), version: "1.0.0",
update: () => Promise.resolve(), };
version: "1.0.0", },
}; };
},
};
return { return {
context: { context: {
api, api,
contributions, contributions,
display: { display: {
url: options.displayUrl ?? "https://api.example.test/display/signage", url: options.displayUrl ?? "https://api.example.test/display/signage",
}, },
moduleId: "signage", moduleId: "signage",
permissions: { permissions: {
has: (permission) => options.permissions.includes(permission), has: (permission) => options.permissions.includes(permission),
}, },
principal: options.principal ?? { kind: "anonymous" }, principal: options.principal ?? { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) }, realtime: { subscribe: () => ({ close: () => undefined }) },
}, },
mounted, mounted,
requests, requests,
}; };
} }
/** The settings payload every screen loads first. */ /** The settings payload every screen loads first. */
export const FAKE_SETTINGS = Object.freeze({ export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000, crossfadeMs: 1000,
moduleVersion: "0.1.2", moduleVersion: "0.1.4",
nativeImageDurationMs: 7000, nativeImageDurationMs: 7000,
nativePresentation: { nativePresentation: {
backgroundColor: "#000000", backgroundColor: "#000000",
backgroundDarkened: false, backgroundDarkened: false,
backgroundMode: "blurred", backgroundMode: "blurred",
blurPixels: 20, blurPixels: 20,
}, },
rendererInputVersion: "1.0.0", rendererInputVersion: "1.0.0",
timezone: "America/Chicago", timezone: "America/Chicago",
uploads: { uploads: {
batchMaxFiles: 20, batchMaxFiles: 20,
concurrency: 3, concurrency: 3,
maxBytes: 26_214_400, maxBytes: 26_214_400,
maxPixels: 40_000_000, maxPixels: 40_000_000,
}, },
}); });
+2 -1
View File
@@ -65,7 +65,8 @@ function constraintDatabase(existing: readonly { id: string; position: number }[
}; };
return { return {
database: { database: {
transaction: async (operation: (value: typeof transaction) => Promise<unknown>) => operation(transaction), transaction: async (operation: (value: typeof transaction) => Promise<unknown>) =>
operation(transaction),
} as unknown as ModuleDrizzleDatabase, } as unknown as ModuleDrizzleDatabase,
positions, positions,
}; };
+4 -3
View File
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
expect(manifest.manifestVersion).toBe(8); expect(manifest.manifestVersion).toBe(8);
expect(manifest.id).toBe("signage"); expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage"); expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.2"); expect(manifest.version).toBe("0.1.4");
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" }); expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
}); });
@@ -151,7 +151,7 @@ describe("Signage module manifest", () => {
describe("Signage server runtime", () => { describe("Signage server runtime", () => {
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => { it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
// The runtime's database declaration must match the manifest's, or Core refuses to start it. // The runtime's database declaration must match the manifest's, or Core refuses to start it.
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.2" }); expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.4" });
}); });
it("registers its routes declaratively, with no database, storage, or realtime work", async () => { it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
@@ -177,6 +177,7 @@ describe("Signage server runtime", () => {
"GET /timelines", "GET /timelines",
"GET /timelines/:timelineId", "GET /timelines/:timelineId",
"GET /viewers", "GET /viewers",
"GET /viewers/:viewerId/display-state",
"PATCH /images/:imageId", "PATCH /images/:imageId",
"PATCH /timelines/:timelineId", "PATCH /timelines/:timelineId",
"PATCH /timelines/:timelineId/slots/:slotId", "PATCH /timelines/:timelineId/slots/:slotId",
@@ -250,7 +251,7 @@ describe("Signage server runtime", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.json()).toEqual({ expect(await response.json()).toEqual({
crossfadeMs: 400, crossfadeMs: 400,
moduleVersion: "0.1.2", moduleVersion: "0.1.4",
nativeImageDurationMs: 12_000, nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation, nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0", rendererInputVersion: "1.0.0",