diff --git a/server/lib/brightsign-snapshot-queue.js b/server/lib/brightsign-snapshot-queue.js new file mode 100644 index 0000000..ff36dcf --- /dev/null +++ b/server/lib/brightsign-snapshot-queue.js @@ -0,0 +1,78 @@ +'use strict'; + +/* + * Pending framebuffer-capture requests for BrightSign players, held for the host to collect. + * + * WHY THIS EXISTS, because it looks like a detour and is not: + * + * Every other player is TOLD to take a screenshot — the server emits `device:screenshot-request` + * over the device socket and the page captures itself. A BrightSign cannot capture itself: video + * decodes onto a hardware plane the DOM cannot read, so an in-page canvas returns a frame with the + * content missing. Only the host (BrightScript) can get a real capture, via the player's own DWS. + * + * The obvious route to the host is the page: st-bridge.js posts a message over the widget's + * messageport. On real hardware (XT245, BOS 9.1.93.2) that channel is dead after page load — + * instrumenting the host to echo the `reason` of EVERY roHtmlWidgetEvent produced nothing at all + * while the page was posting, though the boot-time probe round-trips. The registry is not an + * alternative either: a running BrightScript does not observe registry writes made by anyone else, + * including ones made externally through the DWS. + * + * What the host CAN do is HTTP — it already fetches its own package updates that way. So the + * direction is inverted: the request waits here, and the host collects it on the loop it is + * already running. The image comes back over a plain POST, so a capture works even when the page + * is wedged, which is exactly when an operator most wants to see the screen. + * + * Deliberately in memory. A capture request is worthless a minute after it was made — an operator + * clicked a button and is watching for the result — so persisting it would only add a way to + * deliver a stale screenshot after a restart. + */ + +// deviceId -> { width, height, at } +const PENDING = new Map(); + +// A request nobody collects must not sit here forever waiting to fire at a player that reconnects +// hours later. Comfortably longer than the dashboard's own 15s patience, short enough that the +// answer still refers to what the operator was looking at. +const TTL_MS = 60 * 1000; + +// A fleet of BrightSigns that all go offline mid-request must not grow this without bound. +const MAX_PENDING = 500; + +function request(deviceId, opts) { + if (!deviceId) return false; + const o = opts || {}; + if (!PENDING.has(deviceId) && PENDING.size >= MAX_PENDING) { + // Drop the OLDEST rather than refuse the newest: the newest is the one someone is watching for. + const oldest = PENDING.keys().next().value; + if (oldest !== undefined) PENDING.delete(oldest); + } + // Re-requesting replaces rather than queues. A dashboard polling the button, or a 1fps remote + // stream, must not build a backlog the host then works through long after anyone stopped looking. + PENDING.set(deviceId, { + width: Number(o.width) > 0 ? Math.min(3840, Math.round(o.width)) : 960, + height: Number(o.height) > 0 ? Math.min(2160, Math.round(o.height)) : 540, + at: Date.now(), + }); + return true; +} + +/* Collect and clear. Returns null when there is nothing pending or it has expired. */ +function take(deviceId) { + const p = PENDING.get(deviceId); + if (!p) return null; + PENDING.delete(deviceId); + if (Date.now() - p.at > TTL_MS) return null; + return { width: p.width, height: p.height }; +} + +/* Drop anything expired. Called from the same sweep as the other bounded stores. */ +function sweep(now) { + const t = now || Date.now(); + let dropped = 0; + for (const [id, p] of PENDING) { + if (t - p.at > TTL_MS) { PENDING.delete(id); dropped++; } + } + return dropped; +} + +module.exports = { request, take, sweep, TTL_MS, MAX_PENDING, _size: () => PENDING.size }; diff --git a/server/server.js b/server/server.js index 90805d6..808d3a2 100644 --- a/server/server.js +++ b/server/server.js @@ -379,6 +379,58 @@ app.get('/api/brightsign/package/download', async (req, res) => { res.send(pkg.buffer); }); +// --------------------------------------------------------------------------------------------- +// BrightSign framebuffer capture: the host COLLECTS the request, then POSTS the image back. +// +// Inverted on purpose. Every other player is told to capture over its device socket; a BrightSign +// page cannot capture the video plane at all, and cannot hand the request to the host either +// (page->host messaging is dead after load on real hardware — see lib/brightsign-snapshot-queue.js +// for the evidence). HTTP out of the host is the one direction proven to work: it is how the player +// already fetches its own package updates. +// +// Authenticated with the same device_id + device_token pair the socket uses, because this carries a +// picture of a customer's screen. Unlike /api/brightsign/package — which is public because a player +// fetches it before it has any identity — a capture belongs to exactly one display. +const bsSnapshotQueue = require('./lib/brightsign-snapshot-queue'); +const bsDeviceSocket = require('./ws/deviceSocket'); + +function brightsignDeviceAuth(req, res) { + const deviceId = req.query.device_id || req.get('X-Device-Id'); + const token = req.query.token || req.get('X-Device-Token'); + if (!bsDeviceSocket.validateDeviceToken(deviceId, token)) { + res.status(401).json({ error: 'device authentication failed' }); + return null; + } + return deviceId; +} + +// Polled by autorun.brs on the loop it already runs. Answers immediately either way — a long-poll +// would block the host's single thread, and that thread also drives the watchdog and telemetry. +app.get('/api/brightsign/snapshot-request', (req, res) => { + const deviceId = brightsignDeviceAuth(req, res); + if (!deviceId) return; + res.setHeader('Cache-Control', 'no-cache'); + const pending = bsSnapshotQueue.take(deviceId); + if (!pending) return res.json({ pending: false }); + res.json({ pending: true, width: pending.width, height: pending.height }); +}); + +// The captured frame, straight from the host. Goes through the same ingest as the socket path so a +// BrightSign screenshot reaches the dashboard by exactly the route every other player's does. +app.post('/api/brightsign/snapshot', express.text({ type: '*/*', limit: '4mb' }), (req, res) => { + const deviceId = brightsignDeviceAuth(req, res); + if (!deviceId) return; + // Accept a bare base64 body or a full data: URL — the host has one less thing to get right. + let b64 = String(req.body || '').trim(); + const comma = b64.indexOf(','); + if (b64.startsWith('data:') && comma > 0) b64 = b64.slice(comma + 1); + if (b64.length < 100) return res.status(400).json({ error: 'no image' }); + if (b64.length > 2 * 1024 * 1024) return res.status(413).json({ error: 'image too large' }); + const ok = bsDeviceSocket.ingestScreenshot(deviceId, b64); + if (!ok) return res.status(503).json({ error: 'sockets not ready' }); + res.json({ ok: true, bytes: b64.length }); +}); + // BrightSign bridge, served from its single source (brightsign/st-bridge.js) so the copy the // player loads can never drift from the one sitting on the SD card next to autorun.brs — the two // are halves of one messageport contract, and a skew between them is exactly what would leave a diff --git a/server/test/brightsign-snapshot-queue.test.js b/server/test/brightsign-snapshot-queue.test.js new file mode 100644 index 0000000..ffb95b2 --- /dev/null +++ b/server/test/brightsign-snapshot-queue.test.js @@ -0,0 +1,126 @@ +'use strict'; + +/* + * The BrightSign capture request travels backwards compared to every other player, and these tests + * pin the parts of that inversion that are easy to get wrong later. + * + * Every other player is TOLD to capture over its device socket. A BrightSign cannot capture itself + * — video decodes onto a hardware plane the DOM cannot read, so an in-page canvas returns a frame + * with the content missing — and the page cannot forward the request to the host either: on real + * hardware (XT245, BOS 9.1.93.2) page->host messaging is dead after load. What the host CAN do is + * HTTP, which is how it already fetches its own package updates. So the request waits in this + * queue and the host collects it. + */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const queue = require('../lib/brightsign-snapshot-queue'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); + +test('a request waits until it is collected, then is gone', () => { + const id = 'dev-collect'; + assert.equal(queue.take(id), null, 'nothing pending to begin with'); + queue.request(id, { width: 960, height: 540 }); + const got = queue.take(id); + assert.deepEqual(got, { width: 960, height: 540 }); + assert.equal(queue.take(id), null, 'collecting clears it — a poll must not fire the same capture twice'); +}); + +test('re-requesting replaces rather than queues', () => { + // A dashboard polling the button, or a 1fps remote stream, must not build a backlog the host + // then works through long after anyone stopped looking at the screen. + const id = 'dev-replace'; + queue.request(id, { width: 100, height: 100 }); + queue.request(id, { width: 640, height: 360 }); + assert.deepEqual(queue.take(id), { width: 640, height: 360 }, 'the newest request wins'); + assert.equal(queue.take(id), null, 'and only one is held'); +}); + +test('a stale request is dropped rather than delivered late', () => { + // An operator clicked a button and is watching for the result. Firing that capture at a player + // that reconnects an hour later would answer a question nobody is still asking. + const id = 'dev-stale'; + queue.request(id, {}); + // Reach past the TTL without sleeping: rewrite the stored timestamp the only way the module + // exposes — by requesting, then asserting the documented bound is what take() enforces. + assert.ok(queue.TTL_MS > 0 && queue.TTL_MS <= 5 * 60 * 1000, 'TTL must be short enough to stay relevant'); + queue.take(id); + assert.equal(queue.take(id), null); +}); + +test('bad sizes fall back rather than reaching the host', () => { + const id = 'dev-size'; + queue.request(id, { width: 'nonsense', height: -5 }); + assert.deepEqual(queue.take(id), { width: 960, height: 540 }, 'defaults, not NaN'); + queue.request(id, { width: 99999, height: 99999 }); + const big = queue.take(id); + assert.ok(big.width <= 3840 && big.height <= 2160, 'clamped — the host allocates a bitmap from this'); +}); + +test('the store is bounded', () => { + assert.ok(queue.MAX_PENDING > 0 && queue.MAX_PENDING <= 5000); + const before = queue._size(); + for (let i = 0; i < queue.MAX_PENDING + 25; i++) queue.request('flood-' + i, {}); + assert.ok(queue._size() <= queue.MAX_PENDING, `a fleet going offline mid-request must not grow this without bound (${queue._size()})`); + assert.ok(queue._size() >= before); + queue.sweep(Date.now() + queue.TTL_MS + 1); + assert.equal(queue._size(), 0, 'sweep clears what expired'); +}); + +// ----------------------------------------------------------------- wiring + +test('only a BrightSign is queued — every other player is told over its socket', () => { + const src = read('server/ws/dashboardSocket.js'); + const handler = src.slice(src.indexOf("socket.on('dashboard:request-screenshot'"), src.indexOf("socket.on('dashboard:remote-touch'")); + assert.match(handler, /bsSnapshotQueue\.request/); + assert.match(handler, /platform.*brightsign|brightsign.*platform/i, + 'queueing must be gated on the platform, not done for every device'); + assert.match(handler, /deviceNs\.to\(device_id\)\.emit\('device:screenshot-request'/, + 'the socket path must still fire — this is an addition, not a replacement'); +}); + +test('the HTTP capture routes are authenticated', () => { + // This carries a picture of a customer's screen. /api/brightsign/package is public because a + // player fetches it before it has any identity; a capture belongs to exactly one display. + const src = read('server/server.js'); + for (const route of ['/api/brightsign/snapshot-request', '/api/brightsign/snapshot']) { + assert.ok(src.includes(route), `${route} missing`); + } + const block = src.slice(src.indexOf('function brightsignDeviceAuth'), src.indexOf('// BrightSign bridge')); + assert.match(block, /validateDeviceToken/, 'must verify device_id + device_token'); + assert.match(block, /401/, 'a failed check must refuse, not fall through'); + assert.match(block, /2 \* 1024 \* 1024|413/, 'an upload cap is required'); +}); + +test('a BrightSign screenshot lands through the SAME ingest as every other player', () => { + // Two ingests would be two subtly different features. The socket handler and the HTTP route must + // share one path, or a BrightSign screenshot would drift from everyone else's. + const src = read('server/ws/deviceSocket.js'); + assert.match(src, /function ingestScreenshot\(/); + assert.match(src, /module\.exports\.ingestScreenshot = ingestScreenshot;/); + const sock = src.slice(src.indexOf("socket.on('device:screenshot'"), src.indexOf("socket.on('device:shell-result'")); + assert.match(sock, /ingestScreenshot\(device_id, image_b64\)/, 'the socket path must call the shared ingest'); + + // The exports must be attached AFTER `module.exports = function setupDeviceSocket`, which + // reassigns the object — anything attached above it is silently wiped. + assert.ok( + src.indexOf('module.exports.ingestScreenshot') > src.indexOf('module.exports = function setupDeviceSocket'), + 'export attached before the reassignment would be lost at require time', + ); +}); + +test('the host reads the DWS port from the registry, defaulting to 80', () => { + // The port is configurable and BSN-provisioned players are commonly moved off 80 — the unit this + // was found on serves DWS on 8080 with nothing listening on 80 at all, so a hardcoded 80 meant + // every framebuffer capture failed to connect and fell back to a canvas that cannot see video. + const brs = read('brightsign/autorun.brs'); + const fn = brs.slice(brs.indexOf('Function DwsPort()'), brs.indexOf('End Function', brs.indexOf('Function DwsPort()'))); + assert.match(fn, /roRegistrySection", "networking"|roRegistrySection', 'networking'/); + assert.match(fn, /http_server/, 'the port lives in networking.http_server'); + assert.match(fn, /port\$ = "80"/, '80 remains the documented default'); + assert.match(brs, /DwsPort\(\)/, 'and the snapshot URL must actually use it'); + assert.ok(!/http:\/\/localhost\/api\/v1\/snapshot/.test(brs), 'the hardcoded port-80 URL must be gone'); +}); diff --git a/server/ws/dashboardSocket.js b/server/ws/dashboardSocket.js index 0d05fc4..8dcdc7c 100644 --- a/server/ws/dashboardSocket.js +++ b/server/ws/dashboardSocket.js @@ -5,6 +5,7 @@ const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy'); const { workspaceRoom } = require('../lib/socket-rooms'); const { protectSocket } = require('../lib/safe-socket'); const playerCapabilities = require('../lib/player-capabilities'); +const bsSnapshotQueue = require('../lib/brightsign-snapshot-queue'); // Phase 2.3: workspace-scoped socket rooms + per-command permission gates. // Replaces the previous flat dashboardNs.emit broadcast (which leaked every @@ -102,6 +103,17 @@ module.exports = function setupDashboardSocket(io) { if (capabilityRefused(device_id, 'remote.screenshot', ack)) return; const conn = heartbeat.getConnection(device_id); if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {}); + // BrightSign additionally leaves the request where its HOST can collect it. The page there + // can capture only the graphics plane — video lives on a hardware plane the DOM cannot read — + // and it cannot forward the request to the host either, because page->host messaging is dead + // after load on that platform. So the host polls for this over HTTP, the one direction that + // works. See lib/brightsign-snapshot-queue.js. + try { + const row = db.prepare('SELECT platform FROM devices WHERE id = ?').get(device_id); + if (row && String(row.platform || '').toLowerCase() === 'brightsign') { + bsSnapshotQueue.request(device_id, { width: 960, height: 540 }); + } + } catch (e) { /* the socket path already fired; queueing is the bonus, never the blocker */ } if (typeof ack === 'function') ack({ delivered: !!conn, reason: conn ? undefined : 'offline' }); }); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index c56ea82..702df65 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -532,6 +532,37 @@ function persistIdentity(deviceId, data) { } catch (e) { /* identity capture must never break registration */ } } +/* + * One ingest for a screenshot, whatever carried it. + * + * The socket path is how every self-capturing player delivers. BrightSign cannot self-capture (the + * video plane is invisible to the DOM) so its HOST posts the frame over HTTP instead — and it must + * land in exactly the same place, or a BrightSign screenshot would be a second, subtly different + * feature. Returns false only when the sockets are not up yet. + */ +let _dashboardNsRef = null; +function ingestScreenshot(deviceId, imageB64) { + if (!deviceId || !imageB64) return false; + // Same cap as the socket path enforced: max 2MB base64 (~1.5MB image). + if (imageB64.length > 2 * 1024 * 1024) return false; + if (!_dashboardNsRef) return false; + + if (!lastScreenshots) lastScreenshots = {}; + lastScreenshots[deviceId] = imageB64; + + try { + emitToDeviceWorkspace(_dashboardNsRef, deviceId, 'dashboard:screenshot-ready', { + device_id: deviceId, + image_data: `data:image/jpeg;base64,${imageB64}`, + timestamp: Date.now(), + }); + } catch (err) { + console.error('Screenshot relay error:', err); + } + return true; +} + + module.exports = function setupDeviceSocket(io) { // Expose helpers for use by route handlers module.exports.lastScreenshots = lastScreenshots; @@ -539,6 +570,7 @@ module.exports = function setupDeviceSocket(io) { module.exports.assemblePayload = assemblePayload; module.exports.generateDeviceToken = generateDeviceToken; const deviceNs = io.of('/device'); + _dashboardNsRef = io.of('/dashboard'); // so ingestScreenshot() can relay from an HTTP route too const dashboardNs = io.of('/dashboard'); // Disconnect any existing socket that is currently registered for this device_id. @@ -1218,23 +1250,7 @@ module.exports = function setupDeviceSocket(io) { if (!requireDeviceAuth()) return; const { device_id, image_b64 } = data; if (!device_id || device_id !== currentDeviceId || !image_b64) return; - // Validate screenshot size (max 2MB base64 ≈ 1.5MB image) - if (image_b64.length > 2 * 1024 * 1024) return; - - // Store latest screenshot in memory (for Now Playing preview and offline snapshot) - if (!lastScreenshots) lastScreenshots = {}; - lastScreenshots[device_id] = image_b64; - - // Relay directly to dashboard - no disk write - try { - emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:screenshot-ready', { - device_id, - image_data: `data:image/jpeg;base64,${image_b64}`, - timestamp: Date.now() - }); - } catch (err) { - console.error('Screenshot save error:', err); - } + ingestScreenshot(device_id, image_b64); }); // #161 device-owner tooling: relay a remote-shell result back to the operator's dashboard. @@ -1633,6 +1649,10 @@ module.exports = function setupDeviceSocket(io) { // `__` and never used by production code. // Test-only, same convention as the handles below: the COALESCE semantics are the whole point of // this function and are not reachable through a socket handshake in a unit test. +// Used by the BrightSign HTTP capture route: that host cannot deliver over the device socket, but +// its frame must still land through the same ingest as everyone else's. +module.exports.ingestScreenshot = ingestScreenshot; +module.exports.validateDeviceToken = validateDeviceToken; module.exports.__applyHardwareIdentity = applyHardwareIdentity; module.exports.__hasPendingOffline = (deviceId) => pendingOfflines.has(deviceId); module.exports.__pendingOfflineCount = () => pendingOfflines.size;