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