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