mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Merge pull request #250 from screentinker/fix/brightsign-capture-port-and-queue
BrightSign capture: reach the right DWS port, and let the host collect its request
This commit is contained in:
commit
77e3081675
|
|
@ -100,6 +100,41 @@ Function LoadConfig() As Object
|
||||||
return cfg
|
return cfg
|
||||||
End Function
|
End Function
|
||||||
|
|
||||||
|
Function SnapshotDir() As String
|
||||||
|
' DWS writes to ITS primary storage, which is not necessarily the volume the presentation
|
||||||
|
' booted from — so probe rather than assume, in the same order StorageRoot() does.
|
||||||
|
for each v in ["USB1:", "SSD:", "SD:", "SD2:", "FLASH:"]
|
||||||
|
d$ = v + "/remote_snapshots"
|
||||||
|
files = MatchFiles(d$, "*.jpg")
|
||||||
|
if files <> invalid and files.Count() > 0 then return d$
|
||||||
|
end for
|
||||||
|
return ""
|
||||||
|
End Function
|
||||||
|
|
||||||
|
Function NewestFile(dir As String, pattern As String) As String
|
||||||
|
' DWS names captures img-YYYY-MM-DD-HH-MM-SS.jpg, so the lexicographic maximum IS the newest.
|
||||||
|
best$ = ""
|
||||||
|
files = MatchFiles(dir, pattern)
|
||||||
|
if files = invalid then return ""
|
||||||
|
for each f in files
|
||||||
|
if f > best$ then best$ = f
|
||||||
|
end for
|
||||||
|
return best$
|
||||||
|
End Function
|
||||||
|
|
||||||
|
Function DwsPort() As String
|
||||||
|
' Which port the local Diagnostic Web Server answers on. Read from the same registry the
|
||||||
|
' DWS itself is configured from, so a player moved off port 80 still gets framebuffer
|
||||||
|
' captures instead of silently degrading to the canvas path.
|
||||||
|
port$ = "80"
|
||||||
|
reg = CreateObject("roRegistrySection", "networking")
|
||||||
|
if reg <> invalid and reg.Exists("http_server") then
|
||||||
|
v$ = reg.Read("http_server").Trim()
|
||||||
|
if v$ <> "" then port$ = v$
|
||||||
|
end if
|
||||||
|
return port$
|
||||||
|
End Function
|
||||||
|
|
||||||
Sub SaveRegistry(key As String, value As String)
|
Sub SaveRegistry(key As String, value As String)
|
||||||
reg = CreateObject("roRegistrySection", "screentinker")
|
reg = CreateObject("roRegistrySection", "screentinker")
|
||||||
reg.Write(key, value)
|
reg.Write(key, value)
|
||||||
|
|
@ -218,56 +253,67 @@ Sub TakeSnapshot(widget As Object, req As Object)
|
||||||
return
|
return
|
||||||
end if
|
end if
|
||||||
|
|
||||||
ut.SetUrl("http://localhost/api/v1/snapshot/")
|
' The DWS port is NOT always 80. It is configurable and BSN/Supervisor-provisioned players
|
||||||
|
' are commonly moved off it — the unit this was found on serves DWS on 8080 with nothing
|
||||||
|
' listening on 80 at all. Hardcoding 80 meant every host snapshot failed to connect, fell
|
||||||
|
' through to the in-page canvas, and the canvas cannot read the hardware video plane, so the
|
||||||
|
' operator got a card reading "Video is playing on the hardware plane and cannot be captured"
|
||||||
|
' while the very same capture worked perfectly from the DWS Snapshots tab.
|
||||||
|
'
|
||||||
|
' The port lives in the networking registry section as http_server; absent means the default.
|
||||||
|
' 127.0.0.1, NOT "localhost". A name has to be resolved, and on this platform that resolution
|
||||||
|
' is not ours to rely on: if it answers ::1 first the connection goes to an address the DWS is
|
||||||
|
' not listening on and the transfer sits there until something times out — which is exactly the
|
||||||
|
' shape of the failure this chased (the page gave up at 15s having heard nothing at all, not
|
||||||
|
' even this Sub's own timeout). A literal address cannot be resolved wrongly.
|
||||||
|
ut.SetUrl("http://127.0.0.1:" + DwsPort() + "/api/v1/snapshot/")
|
||||||
ut.SetUserAndPassword("admin", serial$)
|
ut.SetUserAndPassword("admin", serial$)
|
||||||
ut.AddHeader("Content-Type", "application/json")
|
ut.AddHeader("Content-Type", "application/json")
|
||||||
|
|
||||||
' PostFromStringWithRetry does not exist — calling it raised "Member function not found" from
|
' SYNCHRONOUS, deliberately — and this is the whole fix.
|
||||||
' inside the event loop, i.e. a snapshot request took the whole player down. And the synchronous
|
'
|
||||||
' PostFromString() is no use either: it returns only a response CODE and discards the body, which
|
' PostFromStringWithRetry does not exist (calling it raised "Member function not found" from
|
||||||
' is where the thumbnail is. The documented way to read a POST response is asynchronous, on a
|
' inside the event loop, i.e. a snapshot request took the whole player down). The obvious
|
||||||
' message port.
|
' alternative, AsyncPostFromString + Wait on a private port, is the documented way to read a
|
||||||
port = CreateObject("roMessagePort")
|
' POST body — and on this hardware its roUrlEvent NEVER ARRIVES. The Sub simply sat in Wait
|
||||||
ut.SetPort(port)
|
' while st-bridge.js gave up at 15s, so the page reported "host did not answer in time" and
|
||||||
if not ut.AsyncPostFromString(body$) then
|
' fell back to the canvas, which cannot read the hardware video plane. Every other transfer in
|
||||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "could not reach the local DWS" })
|
' this file is synchronous (GetToString for the package check, GetToFile for the download) and
|
||||||
|
' every one of them works, including the self-update that replaced this very script.
|
||||||
|
'
|
||||||
|
' PostFromString returns only the response CODE and discards the body — which would normally
|
||||||
|
' lose the thumbnail. It does not matter here: DWS WRITES THE CAPTURE TO PRIMARY STORAGE before
|
||||||
|
' it answers (the body carries a `filename` pointing at it), so the file is on disk by the time
|
||||||
|
' the call returns and can simply be read back.
|
||||||
|
code% = ut.PostFromString(body$)
|
||||||
|
if code% <> 200 then
|
||||||
|
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS refused the capture (HTTP " + Stri(code%).Trim() + " on port " + DwsPort() + ")" })
|
||||||
return
|
return
|
||||||
end if
|
end if
|
||||||
|
|
||||||
' Bounded: a capture that never answers must not wedge the event loop that drives playback.
|
dir$ = SnapshotDir()
|
||||||
ev = Wait(20000, port)
|
if dir$ = "" then
|
||||||
if type(ev) <> "roUrlEvent" then
|
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "DWS wrote no capture to any volume" })
|
||||||
ut.AsyncCancel()
|
|
||||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "the local DWS did not answer" })
|
|
||||||
return
|
return
|
||||||
end if
|
end if
|
||||||
|
|
||||||
resp$ = ev.GetString()
|
newest$ = NewestFile(dir$, "*.jpg")
|
||||||
if resp$ = "" then
|
if newest$ = "" then
|
||||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no response from the local DWS" })
|
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "no capture found in " + dir$ })
|
||||||
return
|
return
|
||||||
end if
|
end if
|
||||||
|
|
||||||
json = ParseJson(resp$)
|
ba2 = CreateObject("roByteArray")
|
||||||
if json = invalid or json.data = invalid then
|
if not ba2.ReadFile(dir$ + "/" + newest$) then
|
||||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "unparseable DWS response" })
|
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: "could not read " + newest$ })
|
||||||
return
|
return
|
||||||
end if
|
end if
|
||||||
|
|
||||||
if json.data.error <> invalid then
|
' Read, hand over, then remove: DWS appends a new file per capture and nothing else prunes
|
||||||
' e.g. "No primary storage found." — pass the player's own words through; inventing a
|
' them, so a 1fps remote-control stream would otherwise fill the volume.
|
||||||
' friendlier message here would hide the one fact that explains the failure.
|
img$ = "data:image/jpeg;base64," + ba2.ToBase64String()
|
||||||
widget.PostJSMessage({ type: "snapshot-result", ok: false, error: json.data.error.message })
|
DeleteFile(dir$ + "/" + newest$)
|
||||||
return
|
widget.PostJSMessage({ type: "snapshot-result", ok: true, image: img$ })
|
||||||
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
|
End Sub
|
||||||
|
|
||||||
' Rotate the OUTPUT, not the DOM.
|
' Rotate the OUTPUT, not the DOM.
|
||||||
|
|
|
||||||
78
server/lib/brightsign-snapshot-queue.js
Normal file
78
server/lib/brightsign-snapshot-queue.js
Normal file
|
|
@ -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 };
|
||||||
|
|
@ -379,6 +379,58 @@ app.get('/api/brightsign/package/download', async (req, res) => {
|
||||||
res.send(pkg.buffer);
|
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
|
// 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
|
// 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
|
// are halves of one messageport contract, and a skew between them is exactly what would leave a
|
||||||
|
|
|
||||||
126
server/test/brightsign-snapshot-queue.test.js
Normal file
126
server/test/brightsign-snapshot-queue.test.js
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
@ -5,6 +5,7 @@ const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy');
|
||||||
const { workspaceRoom } = require('../lib/socket-rooms');
|
const { workspaceRoom } = require('../lib/socket-rooms');
|
||||||
const { protectSocket } = require('../lib/safe-socket');
|
const { protectSocket } = require('../lib/safe-socket');
|
||||||
const playerCapabilities = require('../lib/player-capabilities');
|
const playerCapabilities = require('../lib/player-capabilities');
|
||||||
|
const bsSnapshotQueue = require('../lib/brightsign-snapshot-queue');
|
||||||
|
|
||||||
// Phase 2.3: workspace-scoped socket rooms + per-command permission gates.
|
// Phase 2.3: workspace-scoped socket rooms + per-command permission gates.
|
||||||
// Replaces the previous flat dashboardNs.emit broadcast (which leaked every
|
// 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;
|
if (capabilityRefused(device_id, 'remote.screenshot', ack)) return;
|
||||||
const conn = heartbeat.getConnection(device_id);
|
const conn = heartbeat.getConnection(device_id);
|
||||||
if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {});
|
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' });
|
if (typeof ack === 'function') ack({ delivered: !!conn, reason: conn ? undefined : 'offline' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -532,6 +532,37 @@ function persistIdentity(deviceId, data) {
|
||||||
} catch (e) { /* identity capture must never break registration */ }
|
} 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) {
|
module.exports = function setupDeviceSocket(io) {
|
||||||
// Expose helpers for use by route handlers
|
// Expose helpers for use by route handlers
|
||||||
module.exports.lastScreenshots = lastScreenshots;
|
module.exports.lastScreenshots = lastScreenshots;
|
||||||
|
|
@ -539,6 +570,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
module.exports.assemblePayload = assemblePayload;
|
module.exports.assemblePayload = assemblePayload;
|
||||||
module.exports.generateDeviceToken = generateDeviceToken;
|
module.exports.generateDeviceToken = generateDeviceToken;
|
||||||
const deviceNs = io.of('/device');
|
const deviceNs = io.of('/device');
|
||||||
|
_dashboardNsRef = io.of('/dashboard'); // so ingestScreenshot() can relay from an HTTP route too
|
||||||
const dashboardNs = io.of('/dashboard');
|
const dashboardNs = io.of('/dashboard');
|
||||||
|
|
||||||
// Disconnect any existing socket that is currently registered for this device_id.
|
// Disconnect any existing socket that is currently registered for this device_id.
|
||||||
|
|
@ -1218,23 +1250,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
if (!requireDeviceAuth()) return;
|
if (!requireDeviceAuth()) return;
|
||||||
const { device_id, image_b64 } = data;
|
const { device_id, image_b64 } = data;
|
||||||
if (!device_id || device_id !== currentDeviceId || !image_b64) return;
|
if (!device_id || device_id !== currentDeviceId || !image_b64) return;
|
||||||
// Validate screenshot size (max 2MB base64 ≈ 1.5MB image)
|
ingestScreenshot(device_id, image_b64);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// #161 device-owner tooling: relay a remote-shell result back to the operator's dashboard.
|
// #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.
|
// `__` and never used by production code.
|
||||||
// Test-only, same convention as the handles below: the COALESCE semantics are the whole point of
|
// 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.
|
// 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.__applyHardwareIdentity = applyHardwareIdentity;
|
||||||
module.exports.__hasPendingOffline = (deviceId) => pendingOfflines.has(deviceId);
|
module.exports.__hasPendingOffline = (deviceId) => pendingOfflines.has(deviceId);
|
||||||
module.exports.__pendingOfflineCount = () => pendingOfflines.size;
|
module.exports.__pendingOfflineCount = () => pendingOfflines.size;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue