From 301c76c3f7c2668846682b794cf60d66de079095 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 18:35:31 -0500 Subject: [PATCH] Let a display opt in to pre-release builds, so a test build is not reverted under the tester MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handing someone a test build was a trap. A prerelease sorts BELOW its own release — 1.9.25-fix234d is semver-older than 1.9.25 — so a sideloaded display asked "anything newer?", was correctly told yes, and updated itself straight back off the build we had asked someone to test. Same versionCode, so Android installed it without complaint. Silent, and within minutes. That is what happened on #234: the reporter installed the fix, tested for an evening, and reported nothing had changed. They were right. Their tablet was running the old code again by then, and I had told them it was fixed without ever checking what the device reported. Adds a per-display opt-in (devices.ota_beta, default 0, checkbox next to the OTA toggle). When set, the display keeps a prerelease of the CURRENT core instead of being pulled back to its release. Deliberately narrow in one direction and deliberately wide in the other: - Narrow: it only holds a prerelease of the core already installed. A plain release, a -patchN build, an upgrade to a newer core, and a display ahead of the server all behave exactly as before, and the flag defaults off so a fleet that never sets it is unaffected. - Wide: an opted-in display is exempted from the superseded-prerelease guard. That guard would otherwise pin a tester on an old test build permanently — an older-core prerelease is never offered anything, so they would have to notice and sideload their way out. Writing the test is what surfaced that; opting in must never mean never updating again. 9 tests covering both directions, including that shipping a newer release pulls a beta display back onto the release line. 845 server tests green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- frontend/js/i18n/en.js | 2 + frontend/js/views/device-detail.js | 5 ++ server/db/database.js | 6 +++ server/lib/ota-breaker.js | 18 ++++++- server/routes/devices.js | 7 ++- server/server.js | 9 +++- server/test/ota-beta-channel.test.js | 79 ++++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 server/test/ota-beta-channel.test.js diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 9238bc4..d338fa9 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -524,6 +524,8 @@ export default { 'device.debug.toggle': 'Debug logging (live)', 'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.', 'device.ota.toggle': 'Self-update (OTA)', + 'device.ota.beta': 'Accept pre-release builds', + 'device.ota.beta_hint': 'Keeps this display on a test build instead of updating it back to the current release. Only affects pre-releases of the version already installed — once a newer release ships, this display updates to it normally.', 'device.ota.hint': 'When off, this device is never offered an update — an MDM or operator owns its updates instead. Turn OFF for MDM-managed panels (e.g. Pivot/MAXHUB) so the app never shows a self-install dialog.', 'device.reboot_schedule.label': 'Nightly reboot', 'device.reboot_schedule.hint': 'Reboot this panel once a day at this device-local time (leave blank for off). A clean nightly reboot clears memory leaks and re-syncs the clock. Silent on device-owner panels; a no-op on panels that can\'t self-reboot.', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 53b6159..fad4b94 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -446,6 +446,10 @@ async function loadDevice(deviceId, activeTab = null) { ${t('device.ota.toggle')}
${t('device.ota.hint')}
+ +
${t('device.ota.beta_hint')}
@@ -977,6 +981,7 @@ function setupActions(device) { orientation: document.getElementById('deviceOrientation').value, default_content_id: document.getElementById('deviceDefaultContent').value || null, ota_enabled: document.getElementById('otaToggle')?.checked ? 1 : 0, + ota_beta: document.getElementById('otaBetaToggle')?.checked ? 1 : 0, reboot_schedule: document.getElementById('rebootSchedule')?.value || null, }); showToast(t('device.toast.settings_saved'), 'success'); diff --git a/server/db/database.js b/server/db/database.js index d49470c..e12bc4a 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -299,6 +299,12 @@ const migrations = [ // device an update (an MDM/operator owns its updates). Default 1 (self-update on). // UPDATE devices SET ota_enabled = 0 WHERE id = ''; (1 to re-enable) "ALTER TABLE devices ADD COLUMN ota_enabled INTEGER NOT NULL DEFAULT 1", + // Opt a single display into pre-release builds. Without this, handing someone a test build is a + // trap: a prerelease sorts BELOW its own release (1.9.25-fix234d < 1.9.25), so the next OTA check + // correctly "upgrades" the device straight back off the build you asked them to test — silently, + // within minutes. It cost a reporter on #234 an evening of testing code that had already been + // replaced under them. Set this and the display keeps a same-core prerelease. + "ALTER TABLE devices ADD COLUMN ota_beta INTEGER NOT NULL DEFAULT 0", // #161: privilege tier reported by the player (0 unprivileged / 1 device-admin / 2 owner-or- // delegated-install) + whether a foreign device owner (MDM) manages it. Drives dashboard gating // of Tier-2 controls (reboot/kiosk/time) — shown only for owned panels. diff --git a/server/lib/ota-breaker.js b/server/lib/ota-breaker.js index b359be0..0c072fd 100644 --- a/server/lib/ota-breaker.js +++ b/server/lib/ota-breaker.js @@ -68,7 +68,7 @@ function isReleased(p) { return p.pre === null || /^patch\d+$/i.test(p.pre); } // decide(clientVersion, latestVersion, deviceId?, now?) -> // { update_available, reason, retry_after_seconds?, log? } -function decide(clientVersion, latestVersion, deviceId = null, now = Date.now()) { +function decide(clientVersion, latestVersion, deviceId = null, now = Date.now(), betaChannel = false) { // ---- PHANTOM / unrecognized guard (immediate, version-based, no rate state) ---- if (!clientVersion) return { update_available: false, reason: 'no-version' }; const pc = parseVer(clientVersion), pl = parseVer(latestVersion); @@ -76,10 +76,24 @@ function decide(clientVersion, latestVersion, deviceId = null, now = Date.now()) const full = cmpParsed(pc, pl); if (full === 0) return { update_available: false, reason: 'up-to-date' }; if (full > 0) return { update_available: false, reason: 'client-newer' }; // never offer a downgrade - if (!isReleased(pc) && coreCmp(pc, pl) < 0) { // GENUINE superseded old-core prerelease (e.g. 1.9.1-beta4) — a -patchN release is NOT one, so it still gets offered + // betaChannel is exempt: this guard would otherwise strand the very displays we hand test + // builds to. A tester on 1.9.25-fix234d has an older core than a released 1.9.26, so without + // the exemption they are told "superseded" forever and never rejoin the release line — the + // opposite of what opting in should mean. Opting in must be reversible by shipping a release. + if (!betaChannel && !isReleased(pc) && coreCmp(pc, pl) < 0) { // GENUINE superseded old-core prerelease (e.g. 1.9.1-beta4) — a -patchN release is NOT one, so it still gets offered return { update_available: false, reason: 'superseded-prerelease', log: logOnce(clientVersion, `[ota] superseded prerelease '${clientVersion}' (older core than latest=${latestVersion}) — no offer`) }; } + // A display opted into pre-release builds keeps a prerelease of the CURRENT core. Semver puts + // 1.9.25-fix234d below 1.9.25, so without this the only "upgrade" on offer is dropping the very + // build we asked this display to run — which is how a test build silently reverts. Scoped to the + // same core on purpose: an older-core prerelease is genuinely stale and still gets offered, and + // once 1.9.26 ships a 1.9.25-anything device is behind and updates normally. So opting in cannot + // strand a display on an abandoned branch. + if (betaChannel && !isReleased(pc) && coreCmp(pc, pl) === 0) { + return { update_available: false, reason: 'beta-channel' }; + } + // ---- offerable (recent real older version) -> RATE breaker, keyed per device / per version ---- const key = deviceId ? 'd:' + deviceId : 'v:' + clientVersion; let b = state.get(key); diff --git a/server/routes/devices.js b/server/routes/devices.js index e098d59..faf455a 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -252,7 +252,7 @@ router.put('/:id', (req, res) => { const device = checkDeviceOwnership(req, res); if (!device) return; - const { name, notes, timezone, orientation, default_content_id, layout_id, ota_enabled, reboot_schedule } = req.body; + const { name, notes, timezone, orientation, default_content_id, layout_id, ota_enabled, ota_beta, reboot_schedule } = req.body; // #150: validate orientation against the known enum (previously accepted any string, which // let a bad value reach the player -> unknown rotation falls back to landscape silently). if (orientation !== undefined && !deviceSettings.ORIENTATIONS.has(orientation)) { @@ -282,6 +282,11 @@ router.put('/:id', (req, res) => { if (ota_enabled !== undefined) { updates.push('ota_enabled = ?'); values.push(ota_enabled ? 1 : 0); } + if (ota_beta !== undefined) { + // Per-display pre-release opt-in (#234 follow-up). Stops a test build being reverted by the + // next OTA check, which is what a prerelease version sorting below its own release causes. + updates.push('ota_beta = ?'); values.push(ota_beta ? 1 : 0); + } // #12 scheduled reboot: device-local "HH:MM" (null/'' clears -> off). Reset the // once-per-day guard on any change so a newly-set time can still fire later today. if (reboot_schedule !== undefined) { diff --git a/server/server.js b/server/server.js index bfa2590..e7c4c7d 100644 --- a/server/server.js +++ b/server/server.js @@ -704,6 +704,7 @@ app.get('/api/update/check', (req, res) => { const currentVersion = req.query.version; const deviceId = req.query.device_id || null; // #144: optional; beta4+ clients send it for per-device keying const latestVersion = VERSION; + let betaChannel = false; // per-display pre-release opt-in, set from the device row below // #155/#161: self-update kill switch, enforced SERVER-SIDE so it covers EVERY client // version (not just ones with the client-side stand-down). If OTA is off globally @@ -715,8 +716,12 @@ app.get('/api/update/check', (req, res) => { let otaDeviceOff = false; if (deviceId) { try { - const row = require('./db/database').db.prepare('SELECT ota_enabled FROM devices WHERE id = ?').get(deviceId); + const row = require('./db/database').db.prepare('SELECT ota_enabled, ota_beta FROM devices WHERE id = ?').get(deviceId); otaDeviceOff = !!row && row.ota_enabled === 0; + // #234 follow-up: per-display pre-release opt-in, read from the same row rather than a + // second query. Without it, handing someone a test build is a trap — a prerelease sorts + // BELOW its own release, so the next check "upgrades" the display straight back off it. + betaChannel = !!row && row.ota_beta === 1; } catch (_) { /* device unknown / pre-migration — treat as enabled */ } } if (otaGloballyOff || otaDeviceOff) { @@ -731,7 +736,7 @@ app.get('/api/update/check', (req, res) => { // #144: circuit-breaker + phantom-version guard. Keys per device_id when present, else // per reported version (NOT IP — SNAT). Rate-trips a looping client in seconds. - const verdict = otaBreaker.decide(currentVersion, latestVersion, deviceId); + const verdict = otaBreaker.decide(currentVersion, latestVersion, deviceId, Date.now(), betaChannel); // #146 Item C: EARLY-RETURN before any filesystem work when we won't serve // (rate-backoff, up-to-date, phantom, client-newer, …). A looping client that gets diff --git a/server/test/ota-beta-channel.test.js b/server/test/ota-beta-channel.test.js new file mode 100644 index 0000000..572d8c0 --- /dev/null +++ b/server/test/ota-beta-channel.test.js @@ -0,0 +1,79 @@ +'use strict'; + +// Handing someone a test build was a trap. +// +// A prerelease sorts BELOW its own release: 1.9.25-fix234d < 1.9.25. So a display sideloaded with a +// test build asked the server "anything newer?", was correctly told yes — the released 1.9.25 — and +// updated itself straight back off the build we had asked someone to test. Same versionCode, so +// Android installed it without complaint. Silent, within minutes. +// +// It happened on #234: the reporter installed the fix, tested for an evening, and reported that +// nothing had changed. They were right — their tablet was running the old code again by then. +// +// The opt-in is per display and deliberately narrow: it holds a prerelease of the CURRENT core +// only. An older-core prerelease is genuinely stale and must still be offered an update, and once a +// newer release ships the display must rejoin it — otherwise "beta" quietly becomes "abandoned on a +// branch nobody maintains". + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const breaker = require('../lib/ota-breaker'); + +// decide(client, latest, deviceId, now, betaChannel) +const ask = (client, latest, beta) => breaker.decide(client, latest, null, Date.now(), beta); + +test('THE BUG: without the opt-in, a test build is offered its own release and reverts', () => { + const v = ask('1.9.25-fix234d', '1.9.25', false); + assert.equal(v.update_available, true, 'this is the revert that cost a reporter an evening'); + assert.equal(v.reason, 'offer'); +}); + +test('THE FIX: an opted-in display keeps a prerelease of the current release', () => { + const v = ask('1.9.25-fix234d', '1.9.25', true); + assert.equal(v.update_available, false); + assert.equal(v.reason, 'beta-channel'); +}); + +test('opting in does NOT strand a display once a newer release ships', () => { + // The whole risk of a beta flag is that it becomes permanent. 1.9.26 is a real newer core, so an + // opted-in display on any 1.9.25 build must take it. + const v = ask('1.9.25-fix234d', '1.9.26', true); + assert.equal(v.update_available, true, 'a beta display must rejoin the next real release'); +}); + +test('the superseded-prerelease guard is untouched for displays that did NOT opt in', () => { + // #144's phantom protection: a device reporting an ancient beta is not chased with offers. + const v = ask('1.9.1-beta4', '1.9.25', false); + assert.equal(v.update_available, false); + assert.equal(v.reason, 'superseded-prerelease'); +}); + +test('but an opted-in display on an old prerelease IS offered the current release', () => { + // This is the escape hatch, and it is the difference between "beta" and "abandoned". Without + // it the superseded guard pins a tester on an old test build permanently — they would have to + // notice and sideload their way out, which is exactly the trap the opt-in exists to remove. + const v = ask('1.9.1-beta4', '1.9.25', true); + assert.equal(v.update_available, true, 'opting in must never mean never updating again'); +}); + +test('the opt-in changes nothing for a display on a plain release', () => { + assert.equal(ask('1.9.25', '1.9.25', true).reason, 'up-to-date'); + assert.equal(ask('1.9.24', '1.9.25', true).update_available, true, 'a real upgrade is unaffected'); + assert.equal(ask('1.9.24', '1.9.25', false).update_available, true); +}); + +test('a display ahead of the server is never downgraded, opted in or not', () => { + assert.equal(ask('1.9.26', '1.9.25', true).reason, 'client-newer'); + assert.equal(ask('1.9.26', '1.9.25', false).reason, 'client-newer'); +}); + +test('a -patchN build is a release, not a prerelease, so beta does not pin it', () => { + // isReleased() treats patchN as released; it must keep being offered real updates. + const v = ask('1.9.2-patch3', '1.9.25', true); + assert.equal(v.update_available, true, 'a patch release must not be mistaken for a beta build'); +}); + +test('the flag defaults to off, so nothing changes for a fleet that never sets it', () => { + const withDefault = breaker.decide('1.9.25-fix234d', '1.9.25', null, Date.now()); + assert.equal(withDefault.update_available, true, 'default must match pre-existing behaviour'); +});