mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
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
141 lines
6.1 KiB
JavaScript
141 lines
6.1 KiB
JavaScript
'use strict';
|
|
|
|
// A screen-only panel has no keyboard, no pointer, and usually no way to clear site data. But
|
|
// the URL it loads is configurable from whatever manages it. ?reset=<token> is the escape hatch:
|
|
// it discards this install's identity so the panel returns as a new device with a fresh pairing
|
|
// code. That is the recovery path when a panel ends up holding an identity that belongs to a
|
|
// different screen, and the ordinary path when redeploying a panel elsewhere.
|
|
//
|
|
// The critical property is ONCE PER TOKEN. The configured URL is permanent — nobody goes back to
|
|
// remove the parameter — so a reset that fired on every load would wipe the pairing on every
|
|
// reboot and present as a screen that cannot hold its pairing at all. Worse, it would look like
|
|
// an intermittent server fault rather than the URL doing exactly what it was told.
|
|
//
|
|
// serverUrl must survive, or a panel that cannot be typed into is stranded.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8');
|
|
|
|
// Lift the real IIFE out of the player and run it against a fake storage + URL.
|
|
function runReset(search, store) {
|
|
const marker = 'function applyIdentityReset()';
|
|
const start = HTML.indexOf(marker);
|
|
assert.notEqual(start, -1, 'applyIdentityReset() should exist');
|
|
let depth = 0, end = -1;
|
|
for (let j = HTML.indexOf('{', start); j < HTML.length; j++) {
|
|
if (HTML[j] === '{') depth++;
|
|
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',
|
|
getConfig: () => { try { return JSON.parse(store.rd_config || '{}'); } catch { return {}; } },
|
|
localStorage: {
|
|
getItem: (k) => (k in store ? store[k] : null),
|
|
setItem: (k, v) => { store[k] = String(v); },
|
|
removeItem: (k) => { delete store[k]; },
|
|
},
|
|
location: { search },
|
|
URLSearchParams,
|
|
console: { warn() {} },
|
|
};
|
|
const fn = new Function(...Object.keys(scope), `${HTML.slice(start, end)} applyIdentityReset();`);
|
|
fn(...Object.values(scope));
|
|
return store;
|
|
}
|
|
|
|
const paired = () => ({
|
|
rd_config: JSON.stringify({ serverUrl: 'https://screentinker.com', deviceId: 'dev-1', deviceToken: 'tok-1', paired: true }),
|
|
st_install_id: 'install-aaaa',
|
|
rd_playlist_cache: '[{"x":1}]',
|
|
rd_layout_cache: '{"zones":1}',
|
|
st_group_sync: '{"offset":5}',
|
|
});
|
|
|
|
test('THE POINT: ?reset= clears the identity so the panel pairs as a new device', () => {
|
|
const s = runReset('?reset=1', paired());
|
|
const cfg = JSON.parse(s.rd_config);
|
|
assert.equal(cfg.deviceId, undefined, 'device id gone');
|
|
assert.equal(cfg.deviceToken, undefined, 'token gone');
|
|
assert.equal(cfg.paired, false);
|
|
assert.equal(s.st_install_id, undefined, 'a NEW identity is minted, not the old one reused');
|
|
});
|
|
|
|
test('the server URL survives — a panel with no keyboard must not be stranded', () => {
|
|
const s = runReset('?reset=1', paired());
|
|
assert.equal(JSON.parse(s.rd_config).serverUrl, 'https://screentinker.com');
|
|
});
|
|
|
|
test('cached content is dropped so the new device does not show the old screen', () => {
|
|
const s = runReset('?reset=1', paired());
|
|
assert.equal(s.rd_playlist_cache, undefined);
|
|
assert.equal(s.rd_layout_cache, undefined);
|
|
assert.equal(s.st_group_sync, undefined);
|
|
});
|
|
|
|
test('THE TRAP: the same token left in the URL forever resets exactly ONCE', () => {
|
|
const s = paired();
|
|
runReset('?reset=1', s);
|
|
// Panel reboots. The configured URL still says ?reset=1 — it always will.
|
|
s.rd_config = JSON.stringify({ serverUrl: 'https://screentinker.com', deviceId: 'dev-2', deviceToken: 'tok-2', paired: true });
|
|
s.st_install_id = 'install-bbbb';
|
|
runReset('?reset=1', s);
|
|
const cfg = JSON.parse(s.rd_config);
|
|
assert.equal(cfg.deviceId, 'dev-2', 'the new pairing SURVIVES the reboot');
|
|
assert.equal(cfg.paired, true);
|
|
assert.equal(s.st_install_id, 'install-bbbb', 'and keeps its identity');
|
|
});
|
|
|
|
test('a DIFFERENT token resets again, so the hatch is reusable', () => {
|
|
const s = paired();
|
|
runReset('?reset=1', s);
|
|
s.rd_config = JSON.stringify({ serverUrl: 'https://screentinker.com', deviceId: 'dev-2', paired: true });
|
|
runReset('?reset=2', s);
|
|
assert.equal(JSON.parse(s.rd_config).deviceId, undefined, 'a new token means a new reset');
|
|
});
|
|
|
|
test('no reset parameter changes nothing at all', () => {
|
|
const s = runReset('', paired());
|
|
const cfg = JSON.parse(s.rd_config);
|
|
assert.equal(cfg.deviceId, 'dev-1');
|
|
assert.equal(s.st_install_id, 'install-aaaa');
|
|
assert.equal(s.st_reset_applied, undefined);
|
|
});
|
|
|
|
test('an unrelated query string is not mistaken for a reset', () => {
|
|
const s = runReset('?preview=1&playlist=abc', paired());
|
|
assert.equal(JSON.parse(s.rd_config).deviceId, 'dev-1', 'preview mode is untouched');
|
|
});
|
|
|
|
test('storage being unavailable does not throw — a dying panel must still boot', () => {
|
|
const marker = 'function applyIdentityReset()';
|
|
const start = HTML.indexOf(marker);
|
|
let depth = 0, end = -1;
|
|
for (let j = HTML.indexOf('{', start); j < HTML.length; j++) {
|
|
if (HTML[j] === '{') depth++;
|
|
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'); } },
|
|
location: { search: '?reset=1' }, URLSearchParams, console: { warn() {} },
|
|
};
|
|
assert.doesNotThrow(() =>
|
|
new Function(...Object.keys(scope), `${HTML.slice(start, end)} applyIdentityReset();`)(...Object.values(scope)));
|
|
});
|
|
|
|
test('it runs BEFORE config is read, or the reset would not take effect this boot', () => {
|
|
const resetAt = HTML.indexOf('function applyIdentityReset()');
|
|
const configAt = HTML.indexOf('let config = getConfig();');
|
|
assert.ok(resetAt !== -1 && configAt !== -1);
|
|
assert.ok(resetAt < configAt, 'identity is cleared before anything reads it');
|
|
});
|