mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Reviewed autorun.brs and st-bridge.js line-by-line against the real examples
instead of the prose docs. Five defects, three of which would have been silent.
The registry API is asynchronous and section-oriented: read(section, key)
returns a Promise and writes take an object, write(section, {k: v}). The bridge
treated both as synchronous, so deviceId() returned a Promise object — truthy
and non-empty — and a panel would have registered as "[object Promise]" while
its real row sat unclaimed. It now prefetches into a cache behind onReady(), and
connect() waits for that before registering.
brightsign_js_objects_enabled: true is required alongside nodejs_enabled for
require("@brightsign/*"). Without it the bridge degrades to no-ops and the
player loses identity and restart delegation — which would have read as
"BrightSign doesn't work" rather than as one missing flag.
storage_path is a directory name, not a volume, and storage_quota is a string;
the local fallback URL needs its volume (file:/SD:/offline.html). Added
security_params and hwz_default to match the examples.
SyncManager does not work unless networking/ptp_domain is "0", which needs a
reboot to apply. Done only when this player is configured for native sync, and
read-before-write so it reboots once rather than on every boot.
Confirmed correct as written: messageport, the roHtmlWidgetEvent loop, and
RebootSystem(). The notes also state a widget URL may be an externally hosted
page with the same JS API access — the favourable answer to the question the
original probe was built to ask.
Bridge tests now model the async section-oriented registry, so a synchronous
stand-in can never hide this class of bug again. 931 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
218 lines
8.8 KiB
JavaScript
218 lines
8.8 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 sandbox = {
|
|
console: { log() {}, warn() {}, error() {} },
|
|
navigator: { userAgent: ua },
|
|
location: { search, reload() { sandbox.__reloaded = true; } },
|
|
setInterval: () => 1,
|
|
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
Promise,
|
|
Object,
|
|
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/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 };
|
|
}
|
|
|
|
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());
|
|
});
|