'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= 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 = { 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 = { 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'); });