From df3a2879fa16917b46d198b2e6de71b9c0fbdf17 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 5 Aug 2026 12:33:34 -0500 Subject: [PATCH] Remote screenshots use the framebuffer, and an opted-in tester can move forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things reviewed against the hardware. REMOTE CAPTURE. An in-page canvas cannot read the hardware plane, so a screenshot from a BrightSign is a composite with the video missing. The player now asks the HOST, which uses the unit's own Diagnostic Web Server to capture the real framebuffer, video included. It has to run in BrightScript rather than the page for two reasons: the DWS is http on localhost while the player is served over https, so the page would be blocked as mixed content; and BrightScript is subject to neither CORS nor mixed-content rules. Credentials are the documented default — user "admin", password = the unit serial — which the host reads directly. It requires PRIMARY STORAGE: the endpoint writes the full-size capture to disk before returning a thumbnail, so a unit with no card or SSD answers "No primary storage found." That message is passed through verbatim rather than swallowed, and the canvas path still runs as a fallback, so a player with no disk keeps producing the partial screenshot it can rather than nothing at all. Verified against the real unit: the endpoint is reachable and blocked solely on storage. THE STUCK TESTER. An opted-in player on 1.9.29-rc1 was told "holding prerelease of the same core" when offered rc3 — so it would never move forward through rc1 -> rc2 -> rc3, which is the opposite of what opting in is for, and would have stopped our own XT245 ever receiving the next candidate. The hold rule exists to stop a test build being dragged BACK to its release. It now applies only when the advertised version IS that release: a newer prerelease of the same core is offered normally, the release still cannot claw a tester back, a newer core still lands, and a player that never opted in is still refused a prerelease. Also verified end to end on alpha: the advertised sha256 matches the served bytes exactly, size matches, and every member of the package is stored. 1063 pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- brightsign/autorun.brs | 67 +++++++++++++++++++++++++++ brightsign/st-bridge.js | 36 ++++++++++++++ server/lib/brightsign-update.js | 9 +++- server/player/index.html | 29 ++++++++++++ server/test/brightsign-bridge.test.js | 38 ++++++++++++++- server/test/brightsign-update.test.js | 46 ++++++++++++++++++ 6 files changed, 223 insertions(+), 2 deletions(-) diff --git a/brightsign/autorun.brs b/brightsign/autorun.brs index 1b55448..daed748 100644 --- a/brightsign/autorun.brs +++ b/brightsign/autorun.brs @@ -146,6 +146,70 @@ Sub EnsurePtpDomain(cfg As Object) end if End Sub +' Capture what is ACTUALLY on screen, using the player's own Diagnostic Web Server. +' +' The page cannot do this itself. With hwz enabled, video decodes onto a hardware plane the DOM +' cannot see: drawImage(video) on a canvas returns a fully transparent image and throws nothing, +' so an in-page screenshot silently produces a blank frame. The DWS captures the real framebuffer, +' video included. +' +' It has to happen HERE rather than in the page for two reasons: the DWS is http on localhost and +' the player is served over https, so the page would be blocked as mixed content; and BrightScript +' is subject to neither CORS nor mixed-content rules. The credentials are the documented default — +' user "admin", password = the unit serial — which this script can read directly. +' +' ⚠️ Requires PRIMARY STORAGE. With no card or SSD fitted the endpoint answers +' "No primary storage found", because it writes the full-size capture to disk before returning the +' thumbnail. Reported back as-is rather than swallowed, so the dashboard can say why. +Sub TakeSnapshot(widget As Object, req As Object) + di = CreateObject("roDeviceInfo") + serial$ = di.GetDeviceUniqueId() + + w% = 640 + h% = 360 + if req <> invalid and req.width <> invalid then w% = req.width + if req <> invalid and req.height <> invalid then h% = req.height + + body$ = "{""width"":" + Stri(w%).Trim() + ",""height"":" + Stri(h%).Trim() + "}" + + ut = CreateObject("roUrlTransfer") + if ut = invalid then + widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no roUrlTransfer" }) + return + end if + + ut.SetUrl("http://localhost/api/v1/snapshot/") + ut.SetUserAndPassword("admin", serial$) + ut.AddHeader("Content-Type", "application/json") + + resp$ = ut.PostFromStringWithRetry(body$, 1) + if resp$ = invalid or resp$ = "" then + widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no response from the local DWS" }) + return + end if + + json = ParseJson(resp$) + if json = invalid or json.data = invalid then + widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "unparseable DWS response" }) + return + end if + + if json.data.error <> invalid then + ' e.g. "No primary storage found." — pass the player's own words through; inventing a + ' friendlier message here would hide the one fact that explains the failure. + widget.PostJSMessage({ type: "snapshot-result", ok: false, error: json.data.error.message }) + return + end if + + r = json.data.result + if r = invalid or r.remotesnapshotthumbnail = invalid then + widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS returned no thumbnail" }) + return + end if + + widget.PostJSMessage({ type: "snapshot-result", ok: true, image: r.remotesnapshotthumbnail }) +End Sub + Function FullScreenRect() As Object vm = CreateObject("roVideoMode") return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY()) @@ -466,6 +530,9 @@ Sub Main() cfg.server_url = m.server_url end if + else if m.type = "snapshot" then + TakeSnapshot(widget, m) + else if m.type = "set-video-mode" then vm = CreateObject("roVideoMode") if m.mode <> invalid then vm.SetMode(m.mode) diff --git a/brightsign/st-bridge.js b/brightsign/st-bridge.js index 06d6529..ef7b7e7 100644 --- a/brightsign/st-bridge.js +++ b/brightsign/st-bridge.js @@ -365,6 +365,42 @@ return post({ type: 'set-video-mode', mode: mode }); }, + /* + * Ask the HOST to capture what is actually on screen, and resolve with a data URL. + * + * This exists because an in-page capture cannot work here: with hwz enabled the video decodes + * onto a hardware plane the DOM cannot read, so drawImage() returns a transparent frame and + * throws nothing — a screenshot that reports success and shows a dead screen. The host uses + * the player's own DWS, which captures the real framebuffer including video. + * + * Rejects rather than hanging: without a host, or if the player has no primary storage (the + * DWS writes the full capture to disk before returning a thumbnail), the caller gets a reason + * it can show instead of a spinner that never resolves. + */ + requestSnapshot: function (opts) { + var o = opts || {}; + return new Promise(function (resolve, reject) { + if (!port) { reject(new Error('no host bridge')); return; } + + var settled = false; + var timer = global.setTimeout(function () { + if (settled) return; + settled = true; + reject(new Error('host did not answer in time')); + }, o.timeoutMs || 15000); + + listeners.push(function handler(msg) { + if (settled || !msg || msg.type !== 'snapshot-result') return; + settled = true; + try { global.clearTimeout(timer); } catch (e) { /* ignore */ } + if (msg.ok && msg.image) resolve(msg.image); + else reject(new Error(msg.error || 'snapshot failed')); + }); + + post({ type: 'snapshot', width: o.width || 640, height: o.height || 360 }); + }); + }, + onHostMessage: function (fn) { if (typeof fn === 'function') listeners.push(fn); }, /* diff --git a/server/lib/brightsign-update.js b/server/lib/brightsign-update.js index 773f5bc..466549b 100644 --- a/server/lib/brightsign-update.js +++ b/server/lib/brightsign-update.js @@ -63,7 +63,14 @@ function compareVersions(a, b) { */ function isPrereleaseOf(version, release) { const core = (v) => String(v || '').split('-')[0]; - return String(version || '').includes('-') && core(version) === core(release); + // `release` must be an actual RELEASE, not another prerelease of the same core. Without that + // last clause a player on rc1 also "holds" against rc3, so an opted-in tester could never move + // forward through rc1 -> rc2 -> rc3 — the opposite of what opting in is for. The rule exists to + // stop a test build being dragged BACK to its release, not to freeze a tester on the first one + // they were handed. + return String(version || '').includes('-') + && !String(release || '').includes('-') + && core(version) === core(release); } /** diff --git a/server/player/index.html b/server/player/index.html index 601bcae..b700b39 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -3632,6 +3632,35 @@ function captureAndSend() { if (!socket?.connected) return; + + // On BrightSign, prefer the HOST's framebuffer capture. An in-page canvas cannot read the + // hardware plane, so a composite here is a screenshot with the video missing — the exact + // failure the alpha probe detects. The host's DWS capture includes video. + // + // Falls through to the canvas path on ANY failure (no host, no primary storage, timeout): + // a partial screenshot showing images and widgets beats none, and the probe makes the + // video's absence explicit rather than silent. + if (BS && typeof BS.requestSnapshot === 'function' && BS.hasHost()) { + BS.requestSnapshot({ width: 960, height: 540 }) + .then((dataUrl) => { + const base64 = String(dataUrl).split(',')[1]; + if (base64 && base64.length > 100) { + socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 }); + console.log('[bs] framebuffer screenshot sent:', base64.length, 'chars'); + } else { captureAndSendCanvas(); } + }) + .catch((err) => { + console.warn('[bs] framebuffer capture unavailable (' + err.message + ') — using canvas'); + captureAndSendCanvas(); + }); + return; + } + + captureAndSendCanvas(); + } + + function captureAndSendCanvas() { + if (!socket?.connected) return; // Also drives the 1fps remote stream (startStreaming). The composite is just a handful // of drawImage calls over already-decoded media, so one full-quality path serves both // the on-demand screenshot and the 1fps stream — no separate low-quality stream path. diff --git a/server/test/brightsign-bridge.test.js b/server/test/brightsign-bridge.test.js index a501ffd..92ff143 100644 --- a/server/test/brightsign-bridge.test.js +++ b/server/test/brightsign-bridge.test.js @@ -40,6 +40,8 @@ function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed = location: { search, reload() { sandbox.__reloaded = true; } }, setInterval: () => 1, setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (t) => clearTimeout(t), + Error, Promise, Object, Array, @@ -58,6 +60,8 @@ function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed = __registry: registryStore, localStorage: { getItem: () => null, setItem() {} }, }; + sandbox.__inbound = []; + sandbox.__deliver = (msg) => sandbox.__inbound.forEach((fn) => fn(msg)); sandbox.window = sandbox; if (mods) { @@ -66,7 +70,9 @@ function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150', seed = return function () { return { PostBSMessage: (o) => posted.push(o), - addEventListener: () => {}, + // Keep the handler so a test can deliver an inbound message, which is how the host + // answers a snapshot request. + addEventListener: (evt, fn) => { if (evt === 'bsmessage') sandbox.__inbound.push(fn); }, }; }; } @@ -358,3 +364,33 @@ test('refreshTelemetry never throws when the platform offers neither source', as await ready; assert.doesNotThrow(() => api.refreshTelemetry()); }); + +test('requestSnapshot asks the host and resolves with the captured image', async () => { + // An in-page canvas cannot read the hardware plane, so the only capture that includes video is + // the host's — via the player's own DWS against the real framebuffer. + const { api, ready, posted, sandbox } = load({ mods: true }); + await ready; + const p = api.requestSnapshot({ width: 320, height: 180 }); + const req = posted.find((m) => m.type === 'snapshot'); + assert.ok(req, 'the host must actually be asked'); + assert.equal(req.width, 320); + sandbox.__deliver({ type: 'snapshot-result', ok: true, image: 'data:image/jpeg;base64,AAAA' }); + assert.equal(await p, 'data:image/jpeg;base64,AAAA'); +}); + +test("THE STORAGE CASE: a player with no disk rejects with the player's own words", async () => { + // The DWS writes the full capture to disk before returning a thumbnail, so a unit with no card + // or SSD answers "No primary storage found." Passing that through verbatim is what lets the + // dashboard explain the failure instead of showing an empty frame. + const { api, ready, sandbox } = load({ mods: true }); + await ready; + const p = api.requestSnapshot(); + sandbox.__deliver({ type: 'snapshot-result', ok: false, error: 'No primary storage found.' }); + await assert.rejects(p, /No primary storage found/); +}); + +test('with no host it rejects immediately rather than hanging the caller', async () => { + const { api, ready } = load(); // plain browser + await ready; + await assert.rejects(api.requestSnapshot(), /no host bridge/); +}); diff --git a/server/test/brightsign-update.test.js b/server/test/brightsign-update.test.js index 57af998..cf18a7f 100644 --- a/server/test/brightsign-update.test.js +++ b/server/test/brightsign-update.test.js @@ -146,3 +146,49 @@ test('THE CAPTIVE PORTAL: a tiny file is refused even if the hash is somehow sat assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 800, 1024), false); assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 2048, 1024), true); }); + +test('THE STUCK TESTER: an opted-in player moves forward rc1 -> rc3', () => { + // The hold rule exists so a test build is not dragged BACK to its release. Applied to another + // PRERELEASE of the same core it froze testers on whichever build they were first handed, which + // is the opposite of what opting in is for — and would have stopped our own XT245 ever receiving + // the next candidate. + const d = U.decidePackageUpdate({ + currentVersion: '1.9.29-rc1', + manifestVersion: '1.9.29-rc3', + manifestSha256: 'abc', + allowPrerelease: true, + }); + assert.equal(d.action, 'download', d.reason); +}); + +test('but it still holds against the RELEASE of its own core — the original scar', () => { + const d = U.decidePackageUpdate({ + currentVersion: '1.9.29-rc1', + manifestVersion: '1.9.29', + manifestSha256: 'abc', + allowPrerelease: true, + }); + assert.equal(d.action, 'skip'); + assert.match(d.reason, /holding prerelease/); +}); + +test('and a newer CORE still lands, so opting in never means never updating', () => { + const d = U.decidePackageUpdate({ + currentVersion: '1.9.29-rc1', + manifestVersion: '1.9.30', + manifestSha256: 'abc', + allowPrerelease: true, + }); + assert.equal(d.action, 'download'); +}); + +test('a player NOT opted in is still refused a prerelease', () => { + const d = U.decidePackageUpdate({ + currentVersion: '1.9.28', + manifestVersion: '1.9.29-rc3', + manifestSha256: 'abc', + allowPrerelease: false, + }); + assert.equal(d.action, 'skip'); + assert.match(d.reason, /requires opt-in/); +});