Complete Phase 5B offline display resilience

Make the /display kiosk survive losing its infrastructure, then surrender
authority cleanly when it returns. The Phase 5A renderer is unchanged: this
builds on its two permanent scene layers, explicit Cache Storage, IndexedDB,
Service Worker, timing anchors, and structured errors.

While connected the server is authoritative. While disconnected the display
becomes a deterministic local continuation of the last authoritative state
it persisted, and the moment the server speaks again the local cursor is
abandoned rather than reconciled or reported back.

A small client evaluator resolves offline playback from the persisted
manifest and clock anchor. Catch-up is arithmetic rather than iterative:
whole rotations are skipped between eligibility boundaries, so a month
offline resolves in milliseconds. Mixed durations, wraparound, [start, end)
schedules, and expirations are evaluated locally, and a null expiration
stays permanent. Mode off keeps the hidden cursor advancing, pause holds
its slide and remainder, static holds until the cached metadata says the
asset genuinely expired, and an activation queued for an unknown playlist
holds the last good frame instead of continuing into a playlist that is
being replaced. Contract tests run this evaluator and the real Phase 3B
engine over identical scenarios and assert the same slide, start, and
deadline, so the client tracks the server without refactoring it.

Cold start restores signage from the Service Worker shell, IndexedDB, and
Cache Storage with the entire stack down, keeping the same persistent
display identity.

Validated display error codes now become Home Assistant incidents through
the Phase 4 machinery, keyed per display and condition so a persisting
error reports once and recovers once. Arbitrary browser text stays
telemetry.

Corrects the deployment assumption: the kiosk runs on a separate machine,
so production requires a trusted HTTPS origin and localhost is development
only. Verifying on a real trusted certificate exposed two production bugs
that localhost had hidden — behind a reverse proxy an outage arrives as a
502 rather than a refused connection, and an unbounded shell fetch left a
restarting kiosk blank for the proxy's whole connect timeout. The worker
now treats a gateway error as an outage and bounds that wait.

Verified with 200/200 backend, 20 client unit, and 30 Playwright tests on
a trusted HTTPS origin with no browser security flags weakened.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Media
2026-08-17 20:36:13 -05:00
co-authored by Claude Opus 5
parent 147e375702
commit ba678d08d5
17 changed files with 2300 additions and 21 deletions
+151 -6
View File
@@ -369,18 +369,74 @@ direct navigation and refresh through the existing Nginx SPA fallback. The
display stays a Chromium kiosk pointed at a web application: v2 makes that
application reliable rather than replacing it with a native client.
Chromium should open `/display` in kiosk mode against an origin the browser
treats as secure — `http://localhost`, a private hostname over HTTPS, or the
equivalent. Cache Storage, Service Workers, and therefore offline behaviour are
unavailable on an insecure origin, and the display degrades to a network-only
renderer without them.
### Deployment topology
The kiosk runs on a different computer from the Announcements host, so the
localhost secure-context exception is not available in production. A remote
display **must** use HTTPS:
```
Remote Chromium kiosk
|
HTTPS
|
v
Announcements web / reverse proxy
|
+-- React /display
+-- /api
+-- /socket.io
+-- /media
```
The reverse proxy terminates TLS; the API and web containers may keep speaking
plain HTTP behind it. The kiosk browser must trust the certificate — a public
certificate or an internal CA installed on the kiosk machine both work.
Secure-context rules decide what the display can do:
| Origin | Status |
| --- | --- |
| `https://display.example.org/display` | Production. Full offline capability. |
| `http://localhost:5175/display` | Development and automated testing only. Secure context, so offline works. |
| `http://10.0.0.5:5175/display` | Degraded, online only. **Not a valid kiosk configuration.** |
Plain LAN HTTP has no Service Worker, no Cache Storage, and no offline startup.
The display still shows slides while the server is reachable, but it cannot
survive an outage. It detects this at startup and reports
`offline_capability_unavailable` through its heartbeat so the limitation is
visible in `/api/displays` and Home Assistant rather than on screen.
Do not work around this with `--ignore-certificate-errors`,
`--disable-web-security`, or an insecure-origin-as-secure flag. They disable
the protections the offline design relies on and do not represent a supportable
deployment.
### Kiosk browser requirements
The kiosk must use a **persistent** Chromium profile. The Service Worker, Cache
Storage, and IndexedDB that make offline operation possible all live in that
profile, and an incognito window, a guest profile, or a throwaway
`--user-data-dir` discards them on every launch.
```sh
chromium \
--kiosk \
--user-data-dir=/var/lib/announcements-kiosk \
https://display.example.org/display
```
The old v1 kiosk flags that disabled the browser cache are no longer required
and should be removed at cutover. They existed because v1 reused mutable slide
and must be removed at cutover. They existed because v1 reused mutable slide
URLs; v2 media URLs are content-addressed, so a changed image always arrives
under a new URL and stale bytes are impossible. Those flags now actively
interfere with the Service Worker and Cache Storage the display depends on.
Clearing the browser's site data deletes the cached slides, the last-known-good
state, and the display's identity. Nothing is lost permanently: on its next
connection the display registers as a new client, re-caches the active playlist,
and resumes. No routine cache management is expected of an operator.
### Transition guarantee
The renderer owns two permanent full-screen scene layers. Each layer is a
@@ -483,6 +539,95 @@ was required; `/api/displays` parses them back into an object. An error clears
when the failing asset renders successfully or leaves the manifest — never
merely because time passed.
### Offline resilience
While connected the server is authoritative. While disconnected the display
becomes a deterministic local continuation of the last authoritative state it
persisted. The moment the server speaks again it is authoritative once more:
the local cursor is abandoned, never reconciled and never reported back.
Offline playback is resilience, not distributed consensus.
Local continuation uses the persisted manifest and timing anchor. Elapsed time
is derived from the anchor — the authoritative `serverTime` and the local clock
reading when it was accepted — so a restart estimates where playback should be
rather than restarting a slide. Catch-up is arithmetic: whole rotations are
skipped in one step between eligibility boundaries, so a display offline for a
month resolves its position in milliseconds instead of stepping through every
missed slide. Mixed durations, wraparound, `[start, end)` schedule boundaries,
and asset expirations are all evaluated locally from the cached manifest. A null
expiration stays permanent no matter how long the display has been away.
Transport state is preserved exactly. Mode off stays black while the hidden
cursor keeps advancing; a paused display holds its slide and remainder and never
advances merely because time passed or the browser restarted; static holds
indefinitely and releases only when the cached metadata says the asset genuinely
expired. If the server had queued an activation for a playlist the display does
not hold, the display holds its last good frame at that boundary rather than
continuing deeper into a playlist that is being replaced.
The distinction between "the playlist is genuinely empty" and "the playlist
should play but the local bytes are unusable" is preserved: the first is
correctly black, the second keeps the last good frame and reports an error.
This evaluator is a small client-side implementation, deliberately separate from
the verified Phase 3B engine. Contract tests run both over identical scenarios —
mixed durations, wraparound, schedule starts and ends, expirations, long
outages, and empty playlists — and assert they resolve the same slide, start,
and deadline.
### Cold start
After one successful online visit the display can start with the entire stack
unavailable. The Service Worker serves the application shell, IndexedDB
provides the client ID and last-known-good state, Cache Storage provides the
slide bytes, and local playback resumes from the persisted anchor. A reconnect
attempt continues in the background.
A display that has never successfully cached anything has nothing trustworthy
to show, so it stays black. It never invents placeholder content.
Because a remote kiosk reaches the origin through a reverse proxy, an outage
behind that proxy arrives as a 502 rather than a refused connection. The worker
treats a gateway error as an outage, and bounds how long it waits for the
network when a usable shell is already cached, so a kiosk restarting during an
outage reaches its slides quickly instead of waiting out a proxy timeout.
### Reconnect
On reconnect the authoritative snapshot wins. Required assets are staged and
decoded first and the current frame is kept until the new one is ready, so
resynchronization never exposes black and never replays the slides that were
missed. If the local calculation already agrees with the server, the timing
anchor is simply re-anchored with no visual churn. A playlist changed during the
outage is staged and adopted without mixing old and new entries, and the
superseded playlist's bytes are pruned once the new manifest is promoted.
The server's own 45-second heartbeat threshold is unchanged. A display can be
showing announcements perfectly while disconnected and still be reported as
`display.offline`, which is the useful distinction: the audience sees slides,
and the operator still learns that the display lost contact.
### Display errors as incidents
Validated display error codes become Home Assistant incidents through the
existing Phase 4 machinery, keyed per display and condition so a persisting
error is reported once and recovers once:
| Code | Severity |
| --- | --- |
| `slide_decode_failed` | warning |
| `asset_fetch_failed` | warning |
| `cache_write_failed` | warning |
| `manifest_invalid` | critical |
| `offline_capability_unavailable` | warning |
Arbitrary browser text remains telemetry and never becomes a notification. If
an error occurs while the display is completely disconnected the server cannot
learn of it at that instant; the display persists its current error and reports
it on reconnect. A transient offline error that fully resolves before
reconnection therefore produces no historical notification. This is a documented
limitation rather than a browser-side durable event queue.
## Storage and health
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,