Widget edits reach the web and Tizen players too, and a pinned render can be cached offline

Same fault as Android, in both other players, and my earlier read of them was wrong: I assumed they
rebuilt the iframe each cycle so could not go stale. They do rebuild — but only after the update
survives a change check, and both change checks key on IDENTITY:

  web    content_id|widget_id|remote_url|filepath|filename|schedules|transition
  tizen  [content_id, widget_id, remote_url, mime_type, schedules, transition]

A widget's identity does not change when it is edited, so an edit produced an identical signature,
the update was discarded as "unchanged", and the old render stayed up. widget_rev now sits in both,
alongside schedules and transition, which are there for exactly this reason.

The render URL carries the rev on both players as well. In the zone path the web player was picking
up `item.widget_rev` inside a loop whose variable is `a` — that would have been undefined on every
zone; it now reads the zone assignment's own rev.

Caching, which is the reason this is worth doing properly rather than just busting the URL: a URL
carrying ?rev=<updated_at> is content-addressed, so those bytes cannot change without the URL
changing. The render endpoint now returns immutable caching for a pinned URL and keeps no-store for
a bare one, and the service worker serves pinned renders cache-first (CACHE_NAME v18).

That closes a real gap. no-store meant widgets were the ONE thing the player's offline cache could
never hold, so a display that lost its uplink lost its widgets — while its images and video kept
playing. Offline resilience is the point of that cache. Old players sending no rev are unaffected:
they still get no-store, because without a rev nothing distinguishes one render from the next.

Verified live: bare URL -> no-store; ?rev=123 -> public, max-age=31536000, immutable. 859 server
tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
Claude 2026-07-30 20:09:06 -05:00
parent 6e3be7a95a
commit 5c6e0325b1
4 changed files with 53 additions and 11 deletions

View file

@ -1848,7 +1848,10 @@
// stale cached playlist and never applies the new transitions. This bug hid every transition edit.
// STRUCTURAL fingerprint only (identity + order + schedules + transition). duration_sec is
// deliberately EXCLUDED so a duration-only edit is applied IN PLACE (not a full change/restart).
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}|${JSON.stringify(a.transition || null)}`).join(',');
// widget_rev is in here for the same reason as schedules and transition: a widget's IDENTITY
// does not change when it is EDITED, so a content edit produced an identical fingerprint, the
// update was treated as "unchanged", and the screen kept the old render until a reload.
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.widget_rev || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}|${JSON.stringify(a.transition || null)}`).join(',');
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
@ -2381,7 +2384,7 @@
discardPendingSwap();
const iframe = document.createElement('iframe');
iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${item.widget_rev||0}`;
// Positioned + sized by the `#playerContainer > iframe` CSS rule. Hidden while it
// loads so its black background never shows over the outgoing content.
iframe.style.background = '#000';
@ -2932,7 +2935,7 @@
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
} else if (item.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${item.widget_rev||0}`;
iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000';
iframe.allow = 'autoplay; fullscreen';
// Sandbox into a unique origin so widget scripts can't read window.parent
@ -3046,7 +3049,7 @@
// Android player, which keys off the assignment's widget_type.
if (a.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${a.widget_rev||0}`;
// Sandbox into a unique origin so widget scripts can't read window.parent
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts');

View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'rd-player-v17';
const CACHE_NAME = 'rd-player-v18';
// Install: skip waiting to activate immediately
self.addEventListener('install', (event) => {
@ -25,6 +25,31 @@ self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Widget renders pinned to a revision: cache-FIRST, because those exact bytes cannot change
// without the rev changing. This is what lets a widget keep rendering when the network is gone —
// previously the server sent no-store for every render, so widgets were the one thing the
// player's offline cache could never hold, and a display that lost its uplink lost them.
// ignoreSearch is deliberately NOT used here: the query string carries the rev, and ignoring it
// would match a different revision's entry, which is the staleness we are trying to remove.
if (url.pathname.startsWith('/api/widgets/') && url.pathname.endsWith('/render') && url.searchParams.has('rev')) {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok && response.type !== 'opaque') {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
}).catch(() => new Response(
'<!DOCTYPE html><body style="margin:0;background:#000"></body>',
{ status: 200, headers: { 'Content-Type': 'text/html' } }
));
})
);
return;
}
// Player page and static assets: network-first, fall back to cache
if (url.pathname.startsWith('/player') || url.pathname === '/socket.io/socket.io.js') {
event.respondWith(

View file

@ -220,9 +220,20 @@ router.get('/:id/render', (req, res) => {
// widgets render blank in the web player. Drop it here; the sandbox - not
// X-Frame-Options - is what isolates the widget (it can't read the dashboard JWT).
res.removeHeader('X-Frame-Options');
// Never cache the render: widget data (clock/weather/rss/directory) changes, and
// a cached copy from before the X-Frame-Options change would keep showing blank.
res.setHeader('Cache-Control', 'no-store');
// Caching is keyed on whether the caller pinned a revision.
//
// A URL carrying ?rev=<widget.updated_at> is content-addressed: those exact bytes cannot change
// without the rev changing, so it is safe to cache hard — and it NEEDS to be, because a player
// that loses its network must still be able to render its widgets. Offline resilience is the
// point of the player's cache, and no-store made widgets the one thing it could never keep.
//
// A URL with no rev is the old shape and stays uncacheable: nothing distinguishes one render
// from the next, so a cached copy could serve content the operator has already changed.
if (req.query.rev) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else {
res.setHeader('Cache-Control', 'no-store');
}
res.setHeader('Content-Type', 'text/html');
res.send(renderWidgetHtml(widget.widget_type, config));
});

View file

@ -148,7 +148,10 @@ PlaylistPlayer.prototype.load = function (assignments) {
// transition-engine: include the per-item transition, or a transition change keeps the same
// signature -> "unchanged" -> the player never applies the new transitions.
// duration_sec is EXCLUDED so a duration edit applies in place (below), not as a restart.
return [a.content_id, a.widget_id, a.remote_url, a.mime_type, a.schedules || [], a.transition || null];
// widget_rev for the same reason as schedules and transition above: a widget's IDENTITY
// is unchanged when it is EDITED, so a content edit produced an identical signature, the
// update was treated as unchanged, and the screen kept the old render until a restart.
return [a.content_id, a.widget_id, a.widget_rev || 0, a.remote_url, a.mime_type, a.schedules || [], a.transition || null];
}));
if (sig === this.sig && this.items.length) {
// In-place duration refresh: patch duration_sec on the live items so a duration edit takes effect
@ -821,7 +824,7 @@ PlaylistPlayer.prototype.renderYouTube = function (item, single) {
PlaylistPlayer.prototype.renderWidget = function (item, single) {
var self = this;
var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '');
var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '?d=') + '&rev=' + (item.widget_rev || 0);
// Anti-flash (#directory-board, parity with the web player): build the new iframe hidden ON TOP of the
// current content and reveal it on load, THEN drop everything else — so a widget/directory-board
// reload never black-flashes the stage (playCurrent skipped the pre-clear for widgets).
@ -1037,7 +1040,7 @@ ZoneRenderer.prototype.showItem = function (zone, list, index) {
zone.el.appendChild(zrFrame(ysrc, 'autoplay; encrypted-media', yvert));
if (multi) this.scheduleAdvance(zone, dur, advance);
} else if (a.widget_type || (a.widget_id && !a.content_id)) {
zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '')));
zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '?d=') + '&rev=' + (a.widget_rev || 0)));
if (multi) this.scheduleAdvance(zone, dur, advance);
} else if (mime.indexOf('video/') === 0) {
var v = document.createElement('video');