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/); +});