mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Recover an unpaired player without needing a keyboard
A display panel usually has no keyboard and no pointer, so a recovery path that waits for input is not a recovery path. When the server stopped recognising a device, the player revealed the server-URL form — typing that cannot happen on a screen-only panel — and hid the pairing section, which was the one thing that would have rescued it. The screen then sat on "Device was removed from server" until someone physically reloaded it, even though the player was still connected to the right server and could have asked for a new code itself. Both handlers now drop the stale credentials and reconnect on a short countdown. Reconnecting re-registers with no device_id, so the server issues a fresh pairing code and the existing registered handler puts it on screen. config.serverUrl is known-good by construction — we are talking to that server at the moment we are rejected — so there is nothing for a human to re-enter. The URL field stays editable throughout, and typing cancels the countdown, so someone who does have a remote and wants to repoint the player is not yanked mid-edit. The countdown is the same helper the first-boot path already used, lifted out and shared rather than duplicated; its input listener is bound once at setup instead of per countdown, which would have stacked a listener each time. The Android player already behaved this way (ProvisioningActivity repair mode); this brings the web player in line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
00c294cb3b
commit
8f2195a6e3
|
|
@ -645,12 +645,39 @@
|
|||
|
||||
// Function used by connect button and auto-connect
|
||||
let autoContinueTimer;
|
||||
function connectBtnFunc() {
|
||||
if (autoContinueTimer) {
|
||||
clearInterval(autoContinueTimer);
|
||||
autoContinueTimer = null;
|
||||
document.getElementById('connectBtn').textContent = _t('connect');
|
||||
|
||||
// Shared "editable URL + short countdown" behaviour. Used on first boot AND on
|
||||
// recovery (server unpaired / rejected us). A display panel usually has no keyboard
|
||||
// or pointer, so anything that WAITS for a click is a dead end there — the countdown
|
||||
// is what lets an input-less screen heal itself. The field stays editable the whole
|
||||
// time for the case where someone IS standing there with a remote and needs to point
|
||||
// the player somewhere else; typing cancels the countdown so it can't yank the form
|
||||
// out from under them mid-edit.
|
||||
function cancelAutoContinue() {
|
||||
if (!autoContinueTimer) return;
|
||||
clearInterval(autoContinueTimer);
|
||||
autoContinueTimer = null;
|
||||
document.getElementById('connectBtn').textContent = _t('connect');
|
||||
}
|
||||
function startAutoContinue(seconds) {
|
||||
cancelAutoContinue();
|
||||
let countdown = seconds;
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = `${_t('connect')} (${countdown})`;
|
||||
autoContinueTimer = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown > 0) {
|
||||
connectBtn.textContent = `${_t('connect')} (${countdown})`;
|
||||
} else {
|
||||
cancelAutoContinue();
|
||||
connectBtnFunc();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function connectBtnFunc() {
|
||||
cancelAutoContinue();
|
||||
unlockAudio();
|
||||
const url = document.getElementById('serverUrl').value.trim().replace(/\/$/, '');
|
||||
if (!url) return;
|
||||
|
|
@ -728,29 +755,7 @@
|
|||
}
|
||||
} else {
|
||||
// Auto-Continue after 5s if not configured. If user interacts with form (typing in the box), stop the timer.
|
||||
|
||||
let countdown = 5;
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
|
||||
connectBtn.textContent = `${_t('connect')} (${countdown})`;
|
||||
|
||||
autoContinueTimer = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown > 0) {
|
||||
connectBtn.textContent = `${_t('connect')} (${countdown})`;
|
||||
} else {
|
||||
clearInterval(autoContinueTimer);
|
||||
connectBtn.textContent = _t('connect');
|
||||
connectBtnFunc()
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
document.getElementById('serverUrl').addEventListener('input', () => {
|
||||
if (countdown > 0) {
|
||||
clearInterval(autoContinueTimer);
|
||||
connectBtn.textContent = _t('connect');
|
||||
}
|
||||
});
|
||||
startAutoContinue(5);
|
||||
}
|
||||
} // #104: end preview-mode gate (else branch wrapping the normal boot)
|
||||
|
||||
|
|
@ -787,6 +792,10 @@
|
|||
}
|
||||
|
||||
document.getElementById('connectBtn').onclick = connectBtnFunc;
|
||||
// Bound once, not per countdown: startAutoContinue() can run more than once in a
|
||||
// session (first boot, then again if the server unpairs us), and re-binding here
|
||||
// would stack a listener each time.
|
||||
document.getElementById('serverUrl').addEventListener('input', cancelAutoContinue);
|
||||
|
||||
// ==================== #104 Device-free preview ====================
|
||||
// #104: device-free dashboard preview. Renders EITHER a draft playlist
|
||||
|
|
@ -1044,29 +1053,37 @@
|
|||
if (showIdle) showStatus('Waiting for content...');
|
||||
});
|
||||
|
||||
socket.on('device:unpaired', () => {
|
||||
console.warn('Device not found on server — clearing credentials');
|
||||
// Server no longer accepts our identity (row deleted, or token rejected). Drop the
|
||||
// stale credentials and get a NEW pairing code on screen without anyone touching the
|
||||
// panel: config.serverUrl is known-good — we are talking to that server right now —
|
||||
// so there is nothing for a human to re-enter. The old code showed the URL form and
|
||||
// HID the pairing section, which on a screen-only display is a dead end: it asks for
|
||||
// typing that cannot happen, and hides the one thing that would rescue it. Recovery
|
||||
// then needed a physical reload. The Android player already does this correctly
|
||||
// (ProvisioningActivity "repair mode"); this brings the web player in line.
|
||||
function enterRepairMode(message) {
|
||||
delete config.deviceId;
|
||||
delete config.deviceToken;
|
||||
config.paired = false;
|
||||
saveConfig(config);
|
||||
savePlaylistCache([]);
|
||||
document.getElementById('setupScreen').style.display = 'flex';
|
||||
document.getElementById('urlForm').style.display = 'block';
|
||||
document.getElementById('pairingSection').style.display = 'none';
|
||||
document.getElementById('setupStatus').textContent = 'Device was removed from server. Please reconnect.';
|
||||
document.getElementById('urlForm').style.display = 'block'; // still editable
|
||||
document.getElementById('pairingSection').style.display = 'none'; // until a code arrives
|
||||
document.getElementById('setupStatus').textContent = message;
|
||||
// Reconnecting re-registers with no device_id, so the server issues a fresh pairing
|
||||
// code and the device:registered handler reveals pairingSection.
|
||||
startAutoContinue(10);
|
||||
}
|
||||
|
||||
socket.on('device:unpaired', () => {
|
||||
console.warn('Device not found on server — clearing credentials');
|
||||
savePlaylistCache([]);
|
||||
enterRepairMode('Device was removed from the server. Re-pairing…');
|
||||
});
|
||||
|
||||
socket.on('device:auth-error', (data) => {
|
||||
console.warn('Device auth rejected:', data?.error || 'unknown');
|
||||
delete config.deviceId;
|
||||
delete config.deviceToken;
|
||||
config.paired = false;
|
||||
saveConfig(config);
|
||||
document.getElementById('setupScreen').style.display = 'flex';
|
||||
document.getElementById('urlForm').style.display = 'block';
|
||||
document.getElementById('pairingSection').style.display = 'none';
|
||||
document.getElementById('setupStatus').textContent = 'Authentication failed. Please re-pair this device.';
|
||||
enterRepairMode('This device needs to be re-paired. Getting a new code…');
|
||||
});
|
||||
|
||||
socket.on('device:playlist-update', (data) => {
|
||||
|
|
|
|||
128
server/test/player-repair-mode.test.js
Normal file
128
server/test/player-repair-mode.test.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use strict';
|
||||
|
||||
// A display panel usually has no keyboard and no pointer. So any recovery path that WAITS
|
||||
// for a click is not a recovery path at all — it is a dead end that needs someone to drive
|
||||
// to the site and power-cycle the screen.
|
||||
//
|
||||
// That is what the unpaired / auth-error handlers used to do: they revealed the server-URL
|
||||
// form (typing you cannot do) and HID the pairing section (the one thing that would rescue
|
||||
// the screen). A real panel sat stuck on "Device was removed from the server" until it was
|
||||
// physically reloaded, even though the player was still talking to the right server the
|
||||
// whole time and could have asked for a new pairing code by itself.
|
||||
//
|
||||
// The rule pinned here: those handlers must hand off to something that recovers WITHOUT
|
||||
// input, while leaving the URL editable for whoever does have a remote. The Android player
|
||||
// already worked this way (ProvisioningActivity repair mode).
|
||||
//
|
||||
// The player is one big inline script with no jsdom in this repo, so this file does two
|
||||
// things: structural checks on the wiring, and a real behavioural test of the countdown
|
||||
// itself, which is lifted out of the HTML and run against a small shim.
|
||||
|
||||
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');
|
||||
|
||||
const bodyOf = (name) => {
|
||||
const start = HTML.indexOf(`function ${name}(`);
|
||||
assert.notEqual(start, -1, `${name}() should exist`);
|
||||
let i = HTML.indexOf('{', start), depth = 0;
|
||||
for (let j = i; j < HTML.length; j++) {
|
||||
if (HTML[j] === '{') depth++;
|
||||
else if (HTML[j] === '}' && --depth === 0) return HTML.slice(start, j + 1);
|
||||
}
|
||||
throw new Error(`unbalanced braces reading ${name}`);
|
||||
};
|
||||
const handlerOf = (event) => {
|
||||
const start = HTML.indexOf(`socket.on('${event}'`);
|
||||
assert.notEqual(start, -1, `a handler for ${event} should exist`);
|
||||
return HTML.slice(start, HTML.indexOf('});', start));
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------- structural wiring
|
||||
|
||||
test('THE BUG: neither recovery handler dead-ends waiting for input', () => {
|
||||
for (const ev of ['device:unpaired', 'device:auth-error']) {
|
||||
const h = handlerOf(ev);
|
||||
assert.match(h, /enterRepairMode\(/, `${ev} routes into repair mode`);
|
||||
assert.doesNotMatch(h, /pairingSection'\)\.style\.display\s*=\s*'none'/,
|
||||
`${ev} must not hide the pairing section and then stop`);
|
||||
}
|
||||
});
|
||||
|
||||
test('repair mode recovers on its own AND leaves the URL editable', () => {
|
||||
const b = bodyOf('enterRepairMode');
|
||||
assert.match(b, /startAutoContinue\(\s*\d+\s*\)/, 'it starts an unattended countdown');
|
||||
assert.match(b, /urlForm'\)\.style\.display\s*=\s*'block'/, 'the URL field stays available');
|
||||
assert.match(b, /config\.paired\s*=\s*false/, 'stale credentials are dropped');
|
||||
assert.match(b, /saveConfig\(config\)/, 'and persisted, so a reload agrees with memory');
|
||||
});
|
||||
|
||||
test('the cancel-on-typing listener is bound exactly once', () => {
|
||||
// startAutoContinue() runs more than once per session (first boot, then repair), so
|
||||
// binding inside it would stack a listener per countdown.
|
||||
const bindings = HTML.match(/getElementById\('serverUrl'\)\.addEventListener\('input'/g) || [];
|
||||
assert.equal(bindings.length, 1, 'one binding, outside the countdown');
|
||||
assert.doesNotMatch(bodyOf('startAutoContinue'), /addEventListener/,
|
||||
'the countdown itself binds nothing');
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- behaviour
|
||||
|
||||
// Run the real countdown source against a shim, so this tests the shipped code rather
|
||||
// than a paraphrase of it.
|
||||
function loadCountdown() {
|
||||
const calls = { connect: 0 };
|
||||
const btn = { textContent: '', disabled: true };
|
||||
let timer = null, tickFn = null;
|
||||
const scope = {
|
||||
document: { getElementById: (id) => (id === 'connectBtn' ? btn : null) },
|
||||
_t: (k) => k,
|
||||
connectBtnFunc: () => { calls.connect++; },
|
||||
setInterval: (fn) => { tickFn = fn; timer = {}; return timer; },
|
||||
clearInterval: () => { timer = null; tickFn = null; },
|
||||
};
|
||||
const src = `let autoContinueTimer; ${bodyOf('cancelAutoContinue')} ${bodyOf('startAutoContinue')}
|
||||
return { startAutoContinue, cancelAutoContinue, get armed() { return !!autoContinueTimer; } };`;
|
||||
const api = new Function(...Object.keys(scope), src)(...Object.values(scope));
|
||||
return { api, calls, btn, tick: (n = 1) => { for (let i = 0; i < n; i++) if (tickFn) tickFn(); } };
|
||||
}
|
||||
|
||||
test('the countdown reaches zero and connects with nobody touching the screen', () => {
|
||||
const { api, calls, btn, tick } = loadCountdown();
|
||||
api.startAutoContinue(3);
|
||||
assert.match(btn.textContent, /\(3\)/, 'it shows the remaining seconds');
|
||||
assert.equal(btn.disabled, false, 'and the button is usable meanwhile');
|
||||
tick(2);
|
||||
assert.equal(calls.connect, 0, 'not yet');
|
||||
tick(1);
|
||||
assert.equal(calls.connect, 1, 'reconnects by itself — this is the whole fix');
|
||||
assert.equal(api.armed, false, 'and disarms');
|
||||
});
|
||||
|
||||
test('typing cancels it, so an operator mid-edit is never yanked', () => {
|
||||
const { api, calls, tick } = loadCountdown();
|
||||
api.startAutoContinue(3);
|
||||
api.cancelAutoContinue();
|
||||
tick(5);
|
||||
assert.equal(calls.connect, 0, 'no auto-connect once cancelled');
|
||||
assert.equal(api.armed, false);
|
||||
});
|
||||
|
||||
test('restarting the countdown does not leave the old one running', () => {
|
||||
// Both timers would otherwise fire, double-connecting.
|
||||
const { api, calls, tick } = loadCountdown();
|
||||
api.startAutoContinue(2);
|
||||
api.startAutoContinue(2);
|
||||
tick(2);
|
||||
assert.equal(calls.connect, 1, 'exactly one connect');
|
||||
});
|
||||
|
||||
test('the countdown fires once, not every tick after zero', () => {
|
||||
const { api, calls, tick } = loadCountdown();
|
||||
api.startAutoContinue(1);
|
||||
tick(4);
|
||||
assert.equal(calls.connect, 1);
|
||||
});
|
||||
Loading…
Reference in a new issue