From 6268c1a4c05511e2c0a2470b991a1457640e7ec5 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 14:58:21 -0500 Subject: [PATCH] Let a screen-only panel clear its identity from the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A display 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. Loading the player with ?reset= now discards this install's identity so the panel returns as a new device with a fresh pairing code — the recovery path when a panel is holding an identity that belongs to a different screen, and the ordinary path when redeploying a panel to another site. It applies once per token, which is the whole design. A configured URL is permanent; nobody goes back and removes the parameter. A reset that fired on every load would drop the pairing on every reboot and present as a screen that cannot hold its pairing at all — which reads as an intermittent server fault rather than the URL doing exactly what it was told. The applied token is remembered, so ?reset=1 left in place forever resets exactly once; any other value resets again. The server URL is deliberately kept, since clearing it would strand a panel that cannot be typed into, and the cached playlist and layout are dropped so the new device does not come up showing the previous screen's content. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- server/player/index.html | 35 ++++++ server/test/player-identity-reset.test.js | 138 ++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 server/test/player-identity-reset.test.js diff --git a/server/player/index.html b/server/player/index.html index 7ec4d53..193127f 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -343,6 +343,41 @@ try { return JSON.parse(localStorage.getItem(LAYOUT_CACHE_KEY) || 'null'); } catch { return null; } } + // ==================== Identity reset (?reset=) ==================== + // A screen-only panel has no keyboard, no pointer and often no way to clear site data — but + // the URL it loads IS configurable from whatever manages it (the UniFi display UI, an MDM, a + // kiosk profile). This is the escape hatch for that: loading the player with ?reset= + // discards this install's identity so it comes back as a brand-new device with a fresh + // pairing code. That is the recovery path when a panel is holding an identity that belongs to + // a different screen, and the ordinary one for redeploying a panel to another site. + // + // ONCE PER TOKEN, and that is the whole design. The configured URL is permanent — nobody + // goes back and removes the parameter — so a reset that fired on every load would re-pair the + // screen on every reboot and look exactly like a device that cannot hold its pairing. The + // applied token is remembered, so ?reset=1 left in the URL forever resets exactly once; to + // reset again, change it to any other value (?reset=2). + // + // serverUrl is deliberately preserved: we are being served BY that server, so it is + // known-good, and clearing it would strand a panel that cannot be typed into. + (function applyIdentityReset() { + let token; + try { token = new URLSearchParams(location.search).get('reset'); } catch (e) { return; } + if (!token) return; + try { + if (localStorage.getItem('st_reset_applied') === token) return; // already honoured + const cfg = getConfig(); + delete cfg.deviceId; delete cfg.deviceToken; delete cfg.pairingCode; + cfg.paired = false; + localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); + localStorage.removeItem('st_install_id'); // mint a NEW identity, not the old one + localStorage.removeItem(PLAYLIST_CACHE_KEY); + localStorage.removeItem(LAYOUT_CACHE_KEY); + localStorage.removeItem('st_group_sync'); + localStorage.setItem('st_reset_applied', token); + console.warn('[reset] identity cleared by ?reset=' + token + ' — this panel will pair as a new device'); + } catch (e) { /* storage unavailable: nothing to clear, and nothing to break */ } + })(); + // ==================== State ==================== let socket = null; let config = getConfig(); diff --git a/server/test/player-identity-reset.test.js b/server/test/player-identity-reset.test.js new file mode 100644 index 0000000..b5ed5ab --- /dev/null +++ b/server/test/player-identity-reset.test.js @@ -0,0 +1,138 @@ +'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'); +});