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