mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Wire the BrightSign bridge into the web player
The bridge and the host existed but nothing loaded them. Now the player does. restartPlayer() replaces every location.reload() call site. On BrightSign a page-initiated reload does not reliably bring the roHtmlWidget back, so the page asks the host to rebuild it and only falls back to reload() when no host is there to take the request. That covers the deploy path, the operator refresh, the service-worker activation and the manual reset. Identity now round-trips through the registry, which outlives localStorage on this platform: getConfig() adopts a registry identity when local storage comes back empty, instead of re-pairing and spawning a second row for a panel that is already provisioned. The operator reset clears the registry too — otherwise it would clear localStorage, get the same identity straight back on the next boot, and reset nothing. Registration reports platform 'brightsign' rather than "Chrome 120", which is what sync-backend.js resolves native-vs-ours from, plus model, OS, serial and which output this widget paints. Dual output needed a collision fix: autorun.brs gives the second HDMI output its own widget, and both widgets share an origin, a registry and one SD storage_path. Un-namespaced, output 2 would read output 1's config, install salt and device id and the two would collapse into a single device row. Storage keys and registry keys are now suffixed per output; screen 1 keeps the bare names so nothing existing moves. The bridge is served from its single source so the copy the player loads can never skew from the one on the SD card next to autorun.brs, and it is served to every player rather than gated on a user agent — a panel reporting an unexpected UA would otherwise silently lose restart-instead-of-reload. Two test harnesses extract player functions and run them in an isolated scope, so they now supply SCREEN_SUFFIX; one gained a case proving two outputs of one player get distinct identities. 927 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
6f5907a1d4
commit
ce854ff2d8
|
|
@ -174,6 +174,12 @@ Sub Main()
|
|||
|
||||
else if m.type = "identity" then
|
||||
' Pairing completed in the page — persist it where a reboot can find it.
|
||||
' clear:true is the operator reset; the registry must forget the display or
|
||||
' the next boot re-adopts it and the reset silently does nothing.
|
||||
if m.clear = true then
|
||||
SaveRegistry("device_id", "")
|
||||
cfg.device_id = ""
|
||||
end if
|
||||
if m.device_id <> invalid then
|
||||
SaveRegistry("device_id", m.device_id)
|
||||
cfg.device_id = m.device_id
|
||||
|
|
|
|||
|
|
@ -69,17 +69,37 @@
|
|||
try { registry = new RegistryClass(); } catch (e) { registry = null; }
|
||||
}
|
||||
|
||||
function regRead(key, fallback) {
|
||||
function screenNumber() {
|
||||
try {
|
||||
var m = new RegExp('[?&]screen=([^&]*)').exec(global.location.search || '');
|
||||
var n = m ? parseInt(decodeURIComponent(m[1]), 10) : 1;
|
||||
return (isNaN(n) || n < 1) ? 1 : n;
|
||||
} catch (e) { return 1; }
|
||||
}
|
||||
|
||||
/*
|
||||
* Registry keys are namespaced per output. On a dual-output player autorun.brs runs TWO
|
||||
* widgets against the same registry, the same SD storage_path and the same origin — so an
|
||||
* un-namespaced "device_id" would have both outputs adopt one identity and collapse into a
|
||||
* single device row. Screen 1 keeps the bare key so existing single-output panels are
|
||||
* unaffected.
|
||||
*/
|
||||
function key(name) {
|
||||
var s = screenNumber();
|
||||
return s > 1 ? name + '_s' + s : name;
|
||||
}
|
||||
|
||||
function regRead(name, fallback) {
|
||||
if (!registry) return fallback;
|
||||
try {
|
||||
var v = registry.read('screentinker', key);
|
||||
var v = registry.read('screentinker', key(name));
|
||||
return (v === undefined || v === null || v === '') ? fallback : v;
|
||||
} catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
function regWrite(key, value) {
|
||||
function regWrite(name, value) {
|
||||
if (!registry) return false;
|
||||
try { registry.write('screentinker', key, String(value)); return true; } catch (e) { return false; }
|
||||
try { registry.write('screentinker', key(name), String(value)); return true; } catch (e) { return false; }
|
||||
}
|
||||
|
||||
var deviceInfo = null;
|
||||
|
|
@ -132,9 +152,16 @@
|
|||
},
|
||||
|
||||
/* Which physical output this widget is painting. 1 unless autorun.brs made a second one. */
|
||||
screen: function () {
|
||||
var n = parseInt(qs('screen') || '1', 10);
|
||||
return (isNaN(n) || n < 1) ? 1 : n;
|
||||
screen: screenNumber,
|
||||
|
||||
/*
|
||||
* Suffix callers should append to any per-display storage key. Two widgets on one player
|
||||
* share an origin and therefore share localStorage, so the config, playlist cache and
|
||||
* install salt all need separating or the second output silently becomes the first.
|
||||
*/
|
||||
storageSuffix: function () {
|
||||
var s = screenNumber();
|
||||
return s > 1 ? '_s' + s : '';
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
@ -154,6 +181,16 @@
|
|||
post({ type: 'identity', device_id: deviceId || null, server_url: serverUrl || null });
|
||||
},
|
||||
|
||||
/*
|
||||
* Forget this display. Required for the operator reset to mean anything: the registry
|
||||
* outlives localStorage, so clearing local storage alone would leave the panel re-adopting
|
||||
* the same identity on its next boot — a reset that resets nothing.
|
||||
*/
|
||||
clearIdentity: function () {
|
||||
regWrite('device_id', '');
|
||||
return post({ type: 'identity', clear: true });
|
||||
},
|
||||
|
||||
/*
|
||||
* THE reload replacement. Never call location.reload() on this platform.
|
||||
* Returns false if there is no host, so the caller can decide whether reloading in place
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@
|
|||
few-ms delay vs the inline trap is fine since errors before this
|
||||
loads are already captured in __debugLog by the inline trap above. -->
|
||||
<script src="/player/debug-overlay.js" defer></script>
|
||||
<!-- BrightSign bridge. NOT deferred: the player asks it who this device is and whether a
|
||||
restart can be delegated to the host, and both questions are asked during boot. Off
|
||||
BrightSign every method is a no-op, so this is inert in a browser. -->
|
||||
<script src="/player/st-bridge.js"></script>
|
||||
<title>ScreenTinker Player</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
|
@ -314,18 +318,70 @@
|
|||
});
|
||||
|
||||
// ==================== Config ====================
|
||||
const STORAGE_KEY = 'rd_web_player';
|
||||
// The BrightSign bridge, or null. Resolved before the storage keys because on a dual-output
|
||||
// player it decides what those keys are called.
|
||||
const BS = (typeof window !== 'undefined' && window.ScreenTinkerBS) || null;
|
||||
const ON_BRIGHTSIGN = !!(BS && BS.isBrightSign());
|
||||
|
||||
// Per-output storage namespace. A dual-output BrightSign runs TWO widgets against one origin
|
||||
// and one SD storage_path, so they share localStorage: without this the second output reads
|
||||
// the first one's config and device id and the two collapse into a single device row. Empty
|
||||
// for screen 1 and for every other platform, so nothing existing moves.
|
||||
const SCREEN_SUFFIX = (() => {
|
||||
try { return BS ? (BS.storageSuffix() || '') : ''; } catch (e) { return ''; }
|
||||
})();
|
||||
|
||||
const STORAGE_KEY = 'rd_web_player' + SCREEN_SUFFIX;
|
||||
const HEARTBEAT_INTERVAL = 15000;
|
||||
const PLAYLIST_REFRESH_INTERVAL = 60000;
|
||||
// #104: device-free dashboard preview mode (set by the ?preview=1 boot branch).
|
||||
// Gates the webpage-widget honest note and keeps the pairing/socket path off.
|
||||
let PREVIEW_MODE = false;
|
||||
|
||||
function getConfig() {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; }
|
||||
// Restarting the player — the only way any call site should do it.
|
||||
//
|
||||
// On BrightSign a page-initiated location.reload() does not reliably bring the roHtmlWidget
|
||||
// back: a deploy on 2026-07-28 reloaded every connected player, and the BrightSign was the
|
||||
// only one that never returned, while a browser on the same deploy was heartbeating minutes
|
||||
// later. autorun.brs rebuilds the widget instead, which is a restart the OS actually
|
||||
// performs.
|
||||
//
|
||||
// Falls through to reload() whenever the host isn't there to take the request — a browser, or
|
||||
// a widget built without nodejs_enabled — so a caller is never left with nothing done.
|
||||
function restartPlayer(reason) {
|
||||
try {
|
||||
if (BS && BS.hasHost() && BS.restart(reason)) {
|
||||
console.log('[bs] restart delegated to host:', reason);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* a broken bridge must not strand the player */ }
|
||||
location.reload();
|
||||
}
|
||||
function saveConfig(cfg) { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); }
|
||||
const PLAYLIST_CACHE_KEY = 'rd_playlist_cache';
|
||||
|
||||
function getConfig() {
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { cfg = {}; }
|
||||
// On BrightSign localStorage is bound to the page origin and its quota; the registry is
|
||||
// not. When local storage comes back empty but the registry still knows who we are, adopt
|
||||
// that identity instead of re-pairing — otherwise a provisioned panel spawns a second
|
||||
// device row, which is the same duplicate-row failure the hardware-only fingerprint caused.
|
||||
if (BS && !cfg.deviceId) {
|
||||
try {
|
||||
const known = BS.deviceId();
|
||||
if (known) { cfg.deviceId = known; cfg.paired = true; }
|
||||
} catch (e) { /* registry unavailable — carry on unpaired */ }
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
function saveConfig(cfg) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
|
||||
// Mirror identity into the registry so it survives a reboot, a content update or a storage
|
||||
// wipe. Best-effort by design: failing to persist here must never break pairing itself.
|
||||
if (BS && cfg && cfg.deviceId) {
|
||||
try { BS.setIdentity(cfg.deviceId, cfg.serverUrl || null); } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
const PLAYLIST_CACHE_KEY = 'rd_playlist_cache' + SCREEN_SUFFIX;
|
||||
function savePlaylistCache(items) {
|
||||
try { localStorage.setItem(PLAYLIST_CACHE_KEY, JSON.stringify(items)); } catch {}
|
||||
}
|
||||
|
|
@ -335,7 +391,7 @@
|
|||
// Cache the layout alongside the playlist so a cold start renders the correct
|
||||
// zone layout on the FIRST pass, instead of rendering fullscreen and only
|
||||
// switching to zones once the server payload arrives.
|
||||
const LAYOUT_CACHE_KEY = 'rd_layout_cache';
|
||||
const LAYOUT_CACHE_KEY = 'rd_layout_cache' + SCREEN_SUFFIX;
|
||||
function saveLayoutCache(l) {
|
||||
try { localStorage.setItem(LAYOUT_CACHE_KEY, JSON.stringify(l || null)); } catch {}
|
||||
}
|
||||
|
|
@ -369,7 +425,7 @@
|
|||
delete cfg.deviceId; delete cfg.deviceToken; delete cfg.pairingCode;
|
||||
cfg.paired = false;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
|
||||
localStorage.removeItem('st_install_id'); // mint a NEW identity, not the old one
|
||||
localStorage.removeItem('st_install_id' + SCREEN_SUFFIX); // mint a NEW identity, not the old one
|
||||
localStorage.removeItem(PLAYLIST_CACHE_KEY);
|
||||
localStorage.removeItem(LAYOUT_CACHE_KEY);
|
||||
localStorage.removeItem('st_group_sync');
|
||||
|
|
@ -732,12 +788,12 @@
|
|||
const hw = generateHardwareFingerprint();
|
||||
let salt = null;
|
||||
try {
|
||||
salt = localStorage.getItem('st_install_id');
|
||||
salt = localStorage.getItem('st_install_id' + SCREEN_SUFFIX);
|
||||
if (!salt) {
|
||||
const buf = new Uint8Array(16);
|
||||
(window.crypto || window.msCrypto).getRandomValues(buf);
|
||||
salt = Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
localStorage.setItem('st_install_id', salt);
|
||||
localStorage.setItem('st_install_id' + SCREEN_SUFFIX, salt);
|
||||
}
|
||||
} catch (e) { salt = null; }
|
||||
return salt ? hw + '-' + salt.slice(0, 16) : hw;
|
||||
|
|
@ -1373,7 +1429,7 @@
|
|||
|
||||
socket.on('device:command', (data) => {
|
||||
console.log('Command:', data.type);
|
||||
if (data.type === 'refresh') location.reload();
|
||||
if (data.type === 'refresh') restartPlayer('operator refresh');
|
||||
if (data.type === 'launch') { document.getElementById('screenOffOverlay')?.remove(); }
|
||||
if (data.type === 'screen_off') toggleScreenOff();
|
||||
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); }
|
||||
|
|
@ -1510,7 +1566,21 @@
|
|||
// server consumes one thing). Backward-compatible: an old server ignores unknown fields.
|
||||
data.client_type = 'player';
|
||||
data.client_version = PLAYER_VERSION;
|
||||
data.platform = browserPlatform();
|
||||
// A BrightSign must say so rather than registering as "Chrome 120", because the sync
|
||||
// resolver (server/lib/sync-backend.js) decides native-vs-ours from exactly this field —
|
||||
// and a wall that reports itself as a browser can never be offered BrightWall.
|
||||
data.platform = ON_BRIGHTSIGN ? 'brightsign' : browserPlatform();
|
||||
if (ON_BRIGHTSIGN) {
|
||||
try {
|
||||
data.bs_model = BS.model() || null;
|
||||
data.bs_os_version = BS.osVersion() || null;
|
||||
data.bs_serial = BS.serial() || null;
|
||||
// Which physical output this widget paints. autorun.brs gives the second output its
|
||||
// own widget with &screen=2, so two rows from one player stay distinguishable.
|
||||
data.bs_screen = BS.screen();
|
||||
data.sync_backend = BS.syncBackend();
|
||||
} catch (e) { /* identity extras are additive — never block registration */ }
|
||||
}
|
||||
data.contract_version = 'v4';
|
||||
// Device identity. `fingerprint` is per-INSTALL and is what the server matches on.
|
||||
// `hw_fingerprint` is the old hardware-only value, sent alongside so a panel whose storage
|
||||
|
|
@ -1579,7 +1649,9 @@
|
|||
fetch(config.serverUrl + '/api/version').then(r => r.json()).then(data => {
|
||||
if (knownServerHash && data.hash !== knownServerHash) {
|
||||
console.log('Server code updated, reloading...', knownServerHash, '->', data.hash);
|
||||
location.reload();
|
||||
// THE deploy path — this is the exact line that darkened a BrightSign panel on
|
||||
// 2026-07-28, when every connected player self-reloaded and that one never returned.
|
||||
restartPlayer('server code updated');
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 30000);
|
||||
|
|
@ -3472,7 +3544,7 @@
|
|||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'activated' && navigator.serviceWorker.controller) {
|
||||
console.log('New Service Worker activated — reloading for fresh code');
|
||||
location.reload();
|
||||
restartPlayer('service worker activated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -3488,7 +3560,11 @@
|
|||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(PLAYLIST_CACHE_KEY);
|
||||
localStorage.removeItem(LAYOUT_CACHE_KEY);
|
||||
location.reload();
|
||||
// The registry outlives localStorage, and getConfig() re-adopts the identity it finds
|
||||
// there. Without this the reset would appear to work and the panel would come straight
|
||||
// back paired as the same display — a reset that resets nothing.
|
||||
if (BS) { try { BS.clearIdentity(); } catch (e) { /* best effort */ } }
|
||||
restartPlayer('operator reset');
|
||||
}
|
||||
}
|
||||
if (e.key === 'f' || e.key === 'F11') {
|
||||
|
|
|
|||
|
|
@ -294,6 +294,19 @@ app.get('/player/schedule-eval.js', (req, res) => {
|
|||
res.sendFile(path.join(__dirname, 'lib', 'schedule-eval.js'));
|
||||
});
|
||||
|
||||
// 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
|
||||
// panel unable to restart itself.
|
||||
//
|
||||
// Served to every player rather than gated on a user agent: it costs one small request, every
|
||||
// method degrades to a no-op off-platform, and a panel reporting an unexpected UA would otherwise
|
||||
// silently lose restart-instead-of-reload — the one thing it most needs.
|
||||
app.get('/player/st-bridge.js', (req, res) => {
|
||||
res.type('application/javascript').setHeader('Cache-Control', 'no-cache');
|
||||
res.sendFile(path.join(__dirname, '..', 'brightsign', 'st-bridge.js'));
|
||||
});
|
||||
|
||||
// #146 web-player fix: serve the media-surface health decision from its single source
|
||||
// (server/lib/player-media-health.js) so the player and the Node test can't drift.
|
||||
app.get('/player/player-media-health.js', (req, res) => {
|
||||
|
|
|
|||
174
server/test/brightsign-bridge.test.js
Normal file
174
server/test/brightsign-bridge.test.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
'use strict';
|
||||
|
||||
// st-bridge.js is loaded by EVERY player, not just BrightSigns, because gating it on a user agent
|
||||
// would mean a panel reporting an unexpected UA silently loses restart-instead-of-reload — the one
|
||||
// thing it most needs. That makes its behaviour in a plain browser a correctness requirement, not a
|
||||
// nicety: it must not throw, must report isBrightSign() false, and must tell the caller it could NOT
|
||||
// take a restart so the player falls back to location.reload() instead of doing nothing.
|
||||
//
|
||||
// The other half is the dual-output collision. autorun.brs gives the second HDMI output its own
|
||||
// widget, and both widgets share an origin, a registry and one SD storage_path. Un-namespaced keys
|
||||
// would have output 2 read output 1's identity, and the two would collapse into a single device row
|
||||
// — the same duplicate-row failure the hardware-only fingerprint once caused, in reverse.
|
||||
//
|
||||
// Run in a vm with a fake global rather than a browser, so the contract is checked without hardware.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'brightsign', 'st-bridge.js'), 'utf8');
|
||||
|
||||
/** Load the bridge into a fake window. `mods` present => pretend we are on a BrightSign. */
|
||||
function load({ search = '', mods = null, ua = 'Mozilla/5.0 Chrome/150' } = {}) {
|
||||
const posted = [];
|
||||
const registryStore = new Map();
|
||||
|
||||
const sandbox = {
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
navigator: { userAgent: ua },
|
||||
location: { search, reload() { sandbox.__reloaded = true; } },
|
||||
setInterval: () => 1,
|
||||
Date,
|
||||
RegExp,
|
||||
parseInt,
|
||||
isNaN,
|
||||
String,
|
||||
decodeURIComponent,
|
||||
__reloaded: false,
|
||||
__posted: posted,
|
||||
__registry: registryStore,
|
||||
localStorage: { getItem: () => null, setItem() {} },
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
|
||||
if (mods) {
|
||||
sandbox.require = (name) => {
|
||||
if (name === '@brightsign/messageport') {
|
||||
return function () {
|
||||
return {
|
||||
PostBSMessage: (o) => posted.push(o),
|
||||
addEventListener: () => {},
|
||||
};
|
||||
};
|
||||
}
|
||||
if (name === '@brightsign/registry') {
|
||||
return function () {
|
||||
return {
|
||||
read: (section, key) => registryStore.get(section + ':' + key),
|
||||
write: (section, key, value) => registryStore.set(section + ':' + key, value),
|
||||
};
|
||||
};
|
||||
}
|
||||
if (name === '@brightsign/deviceinfo') {
|
||||
return function () {
|
||||
return { model: 'XT1145', osVersion: '9.1.92.2', serialNumber: 'SN-TEST-1' };
|
||||
};
|
||||
}
|
||||
throw new Error('no such module ' + name);
|
||||
};
|
||||
}
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(SRC, sandbox);
|
||||
return { api: sandbox.ScreenTinkerBS, sandbox, posted, registryStore };
|
||||
}
|
||||
|
||||
test('in a plain browser it loads without throwing and reports not-BrightSign', () => {
|
||||
const { api } = load();
|
||||
assert.equal(api.isBrightSign(), false);
|
||||
assert.equal(api.hasHost(), false);
|
||||
});
|
||||
|
||||
test('THE FALLBACK: with no host, restart() returns false so the player can reload instead', () => {
|
||||
const { api } = load();
|
||||
// Returning false is the whole contract — the player checks it and calls location.reload().
|
||||
assert.equal(api.restart('deploy'), false);
|
||||
});
|
||||
|
||||
test('off-platform accessors return null/defaults rather than throwing', () => {
|
||||
const { api } = load();
|
||||
assert.equal(api.serial(), null);
|
||||
assert.equal(api.model(), null);
|
||||
assert.equal(api.osVersion(), null);
|
||||
assert.equal(api.screen(), 1);
|
||||
assert.equal(api.storageSuffix(), '');
|
||||
assert.equal(api.setVideoMode({ width: 1920 }), false);
|
||||
assert.doesNotThrow(() => api.onHostMessage(null));
|
||||
});
|
||||
|
||||
test('a BrightSign UA alone is enough to identify the platform', () => {
|
||||
// A widget built without nodejs_enabled resolves no modules, but the player still needs to know.
|
||||
const { api } = load({ ua: 'BrightSign/9.1.92.2 (HD1026) Chrome/120.0.6099.225' });
|
||||
assert.equal(api.isBrightSign(), true);
|
||||
assert.equal(api.hasHost(), false, 'no modules means no host to take a restart');
|
||||
});
|
||||
|
||||
test('with the host present, restart() posts to BrightScript and reports success', () => {
|
||||
const { api, posted } = load({ mods: true });
|
||||
assert.equal(api.hasHost(), true);
|
||||
assert.equal(api.restart('server code updated'), true);
|
||||
const msg = posted.find((m) => m.type === 'restart');
|
||||
assert.ok(msg, 'the host must actually receive it');
|
||||
assert.equal(msg.reason, 'server code updated');
|
||||
});
|
||||
|
||||
test('identity round-trips through the registry', () => {
|
||||
const { api } = load({ mods: true });
|
||||
api.setIdentity('dev-123', 'https://screentinker.com');
|
||||
assert.equal(api.deviceId(), 'dev-123');
|
||||
});
|
||||
|
||||
test('THE RESET: clearIdentity makes the registry forget, so a reset really resets', () => {
|
||||
const { api, posted } = load({ mods: true });
|
||||
api.setIdentity('dev-123', null);
|
||||
api.clearIdentity();
|
||||
assert.equal(api.deviceId(), null, 'otherwise the next boot re-adopts the same display');
|
||||
assert.ok(posted.some((m) => m.type === 'identity' && m.clear === true));
|
||||
});
|
||||
|
||||
test('THE COLLISION: output 2 namespaces its registry key and storage away from output 1', () => {
|
||||
const one = load({ mods: true, search: '?screen=1' });
|
||||
const two = load({ mods: true, search: '?screen=2' });
|
||||
|
||||
assert.equal(one.api.screen(), 1);
|
||||
assert.equal(two.api.screen(), 2);
|
||||
assert.equal(one.api.storageSuffix(), '', 'screen 1 must keep the bare keys — existing panels');
|
||||
assert.equal(two.api.storageSuffix(), '_s2');
|
||||
|
||||
one.api.setIdentity('display-A', null);
|
||||
two.api.setIdentity('display-B', null);
|
||||
assert.equal(one.api.deviceId(), 'display-A');
|
||||
assert.equal(two.api.deviceId(), 'display-B', 'two outputs must not collapse into one device row');
|
||||
|
||||
// and the underlying keys really are distinct
|
||||
assert.deepEqual(
|
||||
[...one.registryStore.keys()].sort(),
|
||||
['screentinker:device_id']
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...two.registryStore.keys()].sort(),
|
||||
['screentinker:device_id_s2']
|
||||
);
|
||||
});
|
||||
|
||||
test('deviceinfo supplies identity, with the URL as the fallback before modules resolve', () => {
|
||||
const withMods = load({ mods: true });
|
||||
assert.equal(withMods.api.serial(), 'SN-TEST-1');
|
||||
assert.equal(withMods.api.model(), 'XT1145');
|
||||
|
||||
const urlOnly = load({ search: '?serial=SN-URL&model=XC2055', ua: 'BrightSign/9 Chrome/120' });
|
||||
assert.equal(urlOnly.api.serial(), 'SN-URL');
|
||||
assert.equal(urlOnly.api.model(), 'XC2055');
|
||||
});
|
||||
|
||||
test('sync backend comes from the URL, else the registry, else auto', () => {
|
||||
assert.equal(load({ mods: true }).api.syncBackend(), 'auto');
|
||||
assert.equal(load({ mods: true, search: '?sync_backend=brightsign' }).api.syncBackend(), 'brightsign');
|
||||
|
||||
const persisted = load({ mods: true });
|
||||
persisted.api.setSyncBackend('screentinker');
|
||||
assert.equal(persisted.api.syncBackend(), 'screentinker', 'a cold boot with no network still starts right');
|
||||
});
|
||||
|
|
@ -44,7 +44,7 @@ after(() => { try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } cat
|
|||
// ---------------------------------------------------------------- client: distinct identities
|
||||
|
||||
// Run the real client function against a fake localStorage, one per simulated panel.
|
||||
function makePanel(hw, store = {}) {
|
||||
function makePanel(hw, store = {}, screenSuffix = '') {
|
||||
const start = HTML.indexOf('function generateBrowserFingerprint()');
|
||||
assert.notEqual(start, -1);
|
||||
let depth = 0, end = -1;
|
||||
|
|
@ -54,6 +54,9 @@ function makePanel(hw, store = {}) {
|
|||
}
|
||||
const src = HTML.slice(start, end);
|
||||
const scope = {
|
||||
// A dual-output BrightSign runs two widgets against one localStorage, so the install
|
||||
// salt is namespaced per output. Empty for every single-output player.
|
||||
SCREEN_SUFFIX: screenSuffix,
|
||||
generateHardwareFingerprint: () => hw,
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
|
|
@ -72,6 +75,17 @@ test('THE BUG: two identical panels no longer share an identity', () => {
|
|||
assert.ok(a.fp().startsWith(HW), 'the hardware value is still recognisable inside it');
|
||||
});
|
||||
|
||||
test('two OUTPUTS of one dual-HDMI player also get distinct identities', () => {
|
||||
// autorun.brs gives the second HDMI output its own widget; both share an origin and one
|
||||
// localStorage. Without the per-output salt they would fingerprint identically and the server
|
||||
// would merge them into a single device row.
|
||||
const HW = 'web-m73u8w-5f';
|
||||
const shared = {};
|
||||
const out1 = makePanel(HW, shared, '');
|
||||
const out2 = makePanel(HW, shared, '_s2');
|
||||
assert.notEqual(out1.fp(), out2.fp(), 'one player, two screens, two displays');
|
||||
});
|
||||
|
||||
test('an identity is stable across reloads of the same install', () => {
|
||||
const p = makePanel('web-m73u8w-5f');
|
||||
assert.equal(p.fp(), p.fp(), 'same call twice');
|
||||
|
|
@ -94,6 +108,7 @@ test('storage being unavailable degrades to hardware rather than to nothing', ()
|
|||
else if (HTML[j] === '}' && --depth === 0) { end = j + 1; break; }
|
||||
}
|
||||
const scope = {
|
||||
SCREEN_SUFFIX: '',
|
||||
generateHardwareFingerprint: () => 'web-hw',
|
||||
localStorage: { getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); } },
|
||||
window: { crypto: { getRandomValues: (b) => crypto.randomFillSync(b) } },
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ function runReset(search, store) {
|
|||
else if (HTML[j] === '}' && --depth === 0) { end = j + 1; break; }
|
||||
}
|
||||
const scope = {
|
||||
SCREEN_SUFFIX: '',
|
||||
STORAGE_KEY: 'rd_config',
|
||||
PLAYLIST_CACHE_KEY: 'rd_playlist_cache',
|
||||
LAYOUT_CACHE_KEY: 'rd_layout_cache',
|
||||
|
|
@ -121,6 +122,7 @@ test('storage being unavailable does not throw — a dying panel must still boot
|
|||
else if (HTML[j] === '}' && --depth === 0) { end = j + 1; break; }
|
||||
}
|
||||
const scope = {
|
||||
SCREEN_SUFFIX: '',
|
||||
STORAGE_KEY: 'rd_config', PLAYLIST_CACHE_KEY: 'p', LAYOUT_CACHE_KEY: 'l',
|
||||
getConfig: () => ({}),
|
||||
localStorage: { getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); }, removeItem() { throw new Error('denied'); } },
|
||||
|
|
|
|||
Loading…
Reference in a new issue