mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
The web player handles four of the ~20 fleet commands, because a browser tab genuinely cannot do more. A BrightSign can, and was inheriting the browser's limits for no reason. screen_on/screen_off now send CEC Image View On (0x0D) / Standby (0x36) so the display actually sleeps. The overlay only painted the screen black: the panel stayed lit, drawing power and at risk of burn-in. Best effort by design — some displays ignore broadcast CEC and need direct addressing — so displayPower() returns false when unavailable and the overlay is applied either way, meaning something visible always happens. reboot was silently ignored: the dashboard button did nothing on a web player. It now goes through the host to RebootSystem, and still logs a clear "not supported" off-platform rather than failing quietly. set_volume applies to whatever is playing AND is re-applied on every subsequent 'play' event, caught in the capture phase because media events do not bubble. Media elements are created per item across fullscreen, zone and preload paths, so setting volume once would otherwise last only until the playlist advanced. Wall followers stay silent throughout — that is deliberate, not an oversight. A dual-output player addresses HDMI-N for the screen it actually paints, so output 2 sleeps its own display rather than output 1's. 954 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
283 lines
12 KiB
JavaScript
283 lines
12 KiB
JavaScript
'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', seed = {} } = {}) {
|
|
const posted = [];
|
|
const registryStore = new Map(Object.entries(seed));
|
|
const cec = { sent: [] };
|
|
const cecConnectors = [];
|
|
|
|
const sandbox = {
|
|
console: { log() {}, warn() {}, error() {} },
|
|
navigator: { userAgent: ua },
|
|
location: { search, reload() { sandbox.__reloaded = true; } },
|
|
setInterval: () => 1,
|
|
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
Promise,
|
|
Object,
|
|
Array,
|
|
Uint8Array,
|
|
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') {
|
|
// The real API is async and section-oriented:
|
|
// read(section, key) -> Promise<string>; write(section, {k: v}) -> Promise
|
|
// Modelling that exactly is the point of this fake — a synchronous stand-in would have
|
|
// hidden the bug where the bridge cached a Promise object as the device id.
|
|
return function () {
|
|
return {
|
|
read: (section, k) => Promise.resolve(registryStore.get(section + ':' + k)),
|
|
write: (section, values) => {
|
|
Object.keys(values).forEach((k) => registryStore.set(section + ':' + k, values[k]));
|
|
return Promise.resolve();
|
|
},
|
|
};
|
|
};
|
|
}
|
|
if (name === '@brightsign/cec') {
|
|
return function (connector) {
|
|
cecConnectors.push(connector);
|
|
return { send: (bytes) => { cec.sent.push(Array.from(bytes)); return Promise.resolve(); },
|
|
addEventListener: () => {} };
|
|
};
|
|
}
|
|
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);
|
|
const api = sandbox.ScreenTinkerBS;
|
|
// onReady always fires, so this resolves off-platform too.
|
|
const ready = new Promise((resolve) => api.onReady(resolve));
|
|
return { api, sandbox, posted, registryStore, ready, cec, cecConnectors };
|
|
}
|
|
|
|
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');
|
|
});
|
|
|
|
test('THE ASYNC TRAP: a Promise from registry.read is never cached as the device id', async () => {
|
|
// registry.read() resolves a Promise. Treating it as a value would make deviceId() return the
|
|
// Promise object itself — truthy, non-empty — and the player would register a display called
|
|
// "[object Promise]" while its real row sat unclaimed.
|
|
const { api, ready } = load({ mods: true, seed: { 'screentinker:device_id': 'existing-id' } });
|
|
await ready;
|
|
assert.equal(typeof api.deviceId(), 'string');
|
|
assert.equal(api.deviceId(), 'existing-id', 'a provisioned panel must come back as itself');
|
|
});
|
|
|
|
test('a panel with nothing in the registry becomes ready with no identity, not a stuck one', async () => {
|
|
const { api, ready } = load({ mods: true });
|
|
await ready;
|
|
assert.equal(api.isReady(), true);
|
|
assert.equal(api.deviceId(), null);
|
|
});
|
|
|
|
test('onReady fires off-platform too, so a browser never blocks on hardware that is absent', async () => {
|
|
const { api, ready } = load();
|
|
await ready;
|
|
assert.equal(api.isReady(), true);
|
|
});
|
|
|
|
test('a rejected registry read still lets the player boot', async () => {
|
|
const { api, ready } = load({ mods: true });
|
|
// The fake resolves; what matters is that readiness is reached and nothing throws.
|
|
await ready;
|
|
assert.doesNotThrow(() => api.deviceId());
|
|
});
|
|
|
|
test('THE DUPLICATE-ROW BUG: the token is persisted alongside the id', async () => {
|
|
// Persisting device_id alone looked correct and still spawned a new device row on every boot:
|
|
// the server authenticates a claim to an existing display with the TOKEN, so an id presented
|
|
// without one reads as a brand-new display. Found on an XT245, not in a test — hence this one.
|
|
const { api, ready } = load({ mods: true });
|
|
await ready;
|
|
api.setIdentity('dev-9', 'https://alpha.screentinker.com', 'tok-abc123');
|
|
assert.equal(api.deviceId(), 'dev-9');
|
|
assert.equal(api.deviceToken(), 'tok-abc123', 'without this the display re-pairs every boot');
|
|
});
|
|
|
|
test('an id with no token is still stored — it is better than nothing', async () => {
|
|
// An unpaired display has no token yet; the server issues one at pairing. Storing the id alone
|
|
// must not throw or wipe anything.
|
|
const { api, ready } = load({ mods: true });
|
|
await ready;
|
|
api.setIdentity('dev-10', null, null);
|
|
assert.equal(api.deviceId(), 'dev-10');
|
|
assert.equal(api.deviceToken(), null);
|
|
});
|
|
|
|
test('clearIdentity forgets the token too, or the reset leaks a credential', async () => {
|
|
const { api, ready } = load({ mods: true });
|
|
await ready;
|
|
api.setIdentity('dev-11', null, 'tok-xyz');
|
|
api.clearIdentity();
|
|
assert.equal(api.deviceId(), null);
|
|
assert.equal(api.deviceToken(), null, 'a stale token must not outlive the identity it belongs to');
|
|
});
|
|
|
|
test('displayPower sends the CEC power codes, not just a black overlay', async () => {
|
|
// The overlay only paints the screen black — the panel stays lit, drawing power and at risk of
|
|
// burn-in. This is the difference between a signage player and a browser tab.
|
|
const { api, ready, cec } = load({ mods: true });
|
|
await ready;
|
|
assert.equal(api.displayPower(true), true);
|
|
assert.deepEqual(cec.sent.at(-1), [0x4f, 0x0d], 'Image View On');
|
|
assert.equal(api.displayPower(false), true);
|
|
assert.deepEqual(cec.sent.at(-1), [0x4f, 0x36], 'Standby');
|
|
});
|
|
|
|
test('displayPower reports false with no CEC, so the caller still draws the overlay', async () => {
|
|
const { api, ready } = load(); // plain browser
|
|
await ready;
|
|
assert.equal(api.displayPower(false), false);
|
|
});
|
|
|
|
test('output 2 addresses HDMI-2 — a dual-output player must sleep the screen it paints', async () => {
|
|
const { api, ready, cecConnectors } = load({ mods: true, search: '?screen=2' });
|
|
await ready;
|
|
api.displayPower(true);
|
|
assert.deepEqual(cecConnectors, ['HDMI-2']);
|
|
});
|