mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Found on alpha after deploying rc4, by comparing what a device advertised against what it actually requested. A real BrightSign XT245 has navigator.serviceWorker, passes an `'serviceWorker' in navigator` check, and then never even fetches sw.js — its widget runtime refuses the registration. It was declaring offline.cache to the fleet while unable to cache a single byte, which is precisely the lie the capability model exists to prevent. The claim is now made on a worker that is actually IN CONTROL, and a refused registration sets a flag so the negative sticks on a runtime where it will never succeed. That failure previously went to console.warn, on a display nobody has a console for, so a panel that could cache nothing looked identical to one that could. It now reports app_error/sw_unavailable — as an allow-listed event type, since an unknown one is dropped by the server and would have been just as invisible. The cost is that the first load under-reports, before the worker claims the page. That is the right direction to be wrong in, and it self-corrects: the next register sends the true set. Also corrects docs/player-parity.md, which claimed BrightSign simply inherits the web player's service worker. The failing unit runs BSN's Supervisor rather than our brightsign/autorun.brs, and Supervisor's widget has no storage_path — the setting our own host script does configure and the precondition for a widget having persistent storage. So this is likely a widget config issue rather than a platform limit, but it is UNVERIFIED on hardware and the doc now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
133 lines
6.5 KiB
JavaScript
133 lines
6.5 KiB
JavaScript
'use strict';
|
|
|
|
// The same index.html is BOTH the browser player and the BrightSign player. Identical code,
|
|
// wildly different powers depending on whether a host bridge answered.
|
|
//
|
|
// That is why the declaration is computed rather than constant, and why it is worth testing: a
|
|
// browser tab claiming `system.reboot` puts a button on the dashboard that cannot ever work, and
|
|
// a BrightSign failing to claim it hides one that would. Both are the same bug in opposite
|
|
// directions.
|
|
//
|
|
// The real function is extracted from index.html and run against fake globals, so this asserts
|
|
// what the player will actually send rather than a reimplementation of it.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { CAP_SET } = require('../lib/player-capabilities');
|
|
|
|
const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8');
|
|
|
|
/** Pull declaredCapabilities() out of the player and run it in a controlled world. */
|
|
function declare({ host = false, sync = false, transitions = false, canvas = true, sw = true, swRefused = false } = {}) {
|
|
const start = HTML.indexOf(' function declaredCapabilities() {');
|
|
assert.notEqual(start, -1, 'declaredCapabilities() must exist in the player');
|
|
let depth = 0, end = -1;
|
|
for (let i = HTML.indexOf('{', start); i < HTML.length; i++) {
|
|
if (HTML[i] === '{') depth++;
|
|
else if (HTML[i] === '}' && --depth === 0) { end = i + 1; break; }
|
|
}
|
|
const src = HTML.slice(start, end);
|
|
|
|
const sandbox = {
|
|
console: { log() {}, warn() {} },
|
|
// `sw` now means "a worker is IN CONTROL", not merely "the API exists" — those are different
|
|
// things, and a real BrightSign widget is the difference (see the test below).
|
|
navigator: sw ? { serviceWorker: { controller: {} } } : (swRefused ? { serviceWorker: {} } : {}),
|
|
document: {
|
|
createElement: () => (canvas
|
|
? { width: 0, height: 0, getContext: () => ({ drawImage() {} }), toDataURL: () => 'data:,' }
|
|
: { width: 0, height: 0, getContext: () => null }),
|
|
},
|
|
BS: host ? { hasHost: () => true } : null,
|
|
};
|
|
sandbox.swRegistrationFailed = swRefused;
|
|
sandbox.window = sandbox;
|
|
if (sync) sandbox.ScreenTinkerBSSync = { available: () => true };
|
|
// The real runtime globals the player's own transitionRuntimeReady() checks. An earlier draft
|
|
// faked a global that does not exist, which passed a test while declaring nothing.
|
|
if (transitions) {
|
|
sandbox.TransitionRenderer = {};
|
|
sandbox.TransitionParams = {};
|
|
sandbox.__TRANSITION_SHADERS = {};
|
|
}
|
|
|
|
vm.createContext(sandbox);
|
|
// declaredCapabilities() calls transitionRuntimeReady(), which lives elsewhere in the player.
|
|
const predicate = 'function transitionRuntimeReady() { return !!(window.TransitionRenderer && window.TransitionParams && window.__TRANSITION_SHADERS); }';
|
|
vm.runInContext(`${predicate}\n${src}\nvar __out = declaredCapabilities();`, sandbox);
|
|
return sandbox.__out;
|
|
}
|
|
|
|
test('every declared name is a real capability the server understands', () => {
|
|
// A typo here is silently dropped by parseDeclared, so the control just never appears.
|
|
for (const c of declare({ host: true, sync: true, transitions: true })) {
|
|
assert.ok(CAP_SET.has(c), `player declares unknown capability "${c}"`);
|
|
}
|
|
});
|
|
|
|
test('THE LIE WE ARE REMOVING: a browser tab does not claim host-only powers', () => {
|
|
const web = declare({ host: false });
|
|
for (const cap of ['system.reboot', 'display.power', 'display.resolution', 'system.self_update']) {
|
|
assert.ok(!web.includes(cap), `a browser tab must not claim ${cap}`);
|
|
}
|
|
});
|
|
|
|
test('the same code behind a live host DOES claim them', () => {
|
|
const bs = declare({ host: true });
|
|
for (const cap of ['system.reboot', 'display.power', 'display.resolution', 'system.self_update']) {
|
|
assert.ok(bs.includes(cap), `a hosted player must claim ${cap}`);
|
|
}
|
|
});
|
|
|
|
test('what both share is declared by both', () => {
|
|
const web = declare({ host: false });
|
|
const bs = declare({ host: true });
|
|
for (const cap of ['playback.video', 'playback.zones', 'audio.mute', 'audio.volume',
|
|
'display.rotation', 'remote.input', 'system.restart_player', 'sync.clock']) {
|
|
assert.ok(web.includes(cap), `web should declare ${cap}`);
|
|
assert.ok(bs.includes(cap), `hosted should declare ${cap}`);
|
|
}
|
|
});
|
|
|
|
test('native sync depends on the module resolving, not on being a BrightSign', () => {
|
|
// BOS 8.2.10+ is a firmware question. A hosted player on older firmware has no SyncManager and
|
|
// must not offer frame-accurate sync it cannot perform.
|
|
assert.ok(!declare({ host: true, sync: false }).includes('sync.native'));
|
|
assert.ok(declare({ host: true, sync: true }).includes('sync.native'));
|
|
});
|
|
|
|
test('screenshots are claimed only when there is something to draw on', () => {
|
|
assert.ok(declare({ canvas: true }).includes('remote.screenshot'));
|
|
assert.ok(!declare({ canvas: false }).includes('remote.screenshot'));
|
|
});
|
|
|
|
test('offline cache follows a worker that is actually IN CONTROL', () => {
|
|
assert.ok(declare({ sw: true }).includes('offline.cache'));
|
|
assert.ok(!declare({ sw: false }).includes('offline.cache'));
|
|
});
|
|
|
|
test('THE BRIGHTSIGN CASE: the API exists, no worker controls the page, so no claim', () => {
|
|
// Found on real hardware. A BrightSign XT245 on alpha has navigator.serviceWorker, passes an
|
|
// `'serviceWorker' in navigator` check, and then never even fetches sw.js — its widget runtime
|
|
// refuses to register one. It was advertising offline.cache to the fleet while being unable to
|
|
// cache a single byte, which is precisely the lie this whole capability model exists to stop.
|
|
const caps = declare({ swRefused: true });
|
|
assert.ok(!caps.includes('offline.cache'), 'a runtime that will not run a worker must not claim to cache');
|
|
assert.ok(caps.includes('playback.video'), 'and it must still declare what it genuinely can do');
|
|
});
|
|
|
|
test('transitions are declared only when the bundle actually loaded', () => {
|
|
// It is a progressive enhancement — a failed load means hard cuts, not a broken player.
|
|
assert.ok(!declare({ transitions: false }).includes('playback.transitions'));
|
|
assert.ok(declare({ transitions: true }).includes('playback.transitions'));
|
|
});
|
|
|
|
test('a hostile environment degrades instead of throwing during registration', () => {
|
|
// This runs inside register(). Throwing here would cost the display its registration entirely,
|
|
// which is a far worse outcome than an under-declared capability set.
|
|
assert.doesNotThrow(() => declare({ canvas: false, sw: false, host: false }));
|
|
});
|