From 5c6e0325b1712a7337868aa3f260cff8cc0f634a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 20:09:06 -0500 Subject: [PATCH] Widget edits reach the web and Tizen players too, and a pinned render can be cached offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- server/player/index.html | 11 +++++++---- server/player/sw.js | 27 ++++++++++++++++++++++++++- server/routes/widgets.js | 17 ++++++++++++++--- tizen/js/player.js | 9 ++++++--- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/server/player/index.html b/server/player/index.html index fae26bf..a107dbc 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -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'); diff --git a/server/player/sw.js b/server/player/sw.js index b3375c3..72f829a 100644 --- a/server/player/sw.js +++ b/server/player/sw.js @@ -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( + '', + { 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( diff --git a/server/routes/widgets.js b/server/routes/widgets.js index 0337a69..08e1107 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -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= 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)); }); diff --git a/tizen/js/player.js b/tizen/js/player.js index fdf7b81..a3fa7f4 100644 --- a/tizen/js/player.js +++ b/tizen/js/player.js @@ -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');