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:
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user