diff --git a/README.md b/README.md index 1e52397..02a0c1c 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,29 @@ including "already up to date", so the button never just appears to do nothing. invent permissions: if installs need a confirmation tap on that hardware, forcing still raises the dialog. It is the right button once you have fixed whatever was breaking the update. +#### Running a beta channel + +By default an instance serves one APK to every display, at `/download/apk`. You can publish a second +build alongside it and send it only to displays you choose: + +1. Put the beta APK next to the stable one, as **`ScreenTinker-beta.apk`** (same locations as + `ScreenTinker.apk` — `/data/` in a container, or the install root). +2. Declare its version in a sidecar text file, **`ScreenTinker-beta.apk.version`**, containing just + the version — e.g. `1.9.27-rc1`. This is required. The server cannot read the version out of an + APK cheaply, and advertising a version that does not match the bytes it serves is how update + loops start, so **a beta with no declared version is ignored entirely** and opted-in displays + keep getting the stable build. +3. Tick **Accept pre-release builds** on any display that should receive it. + +Untick the box to move a display back to the release build — the server offers it the stable build +even though it is technically "older" than the beta. Displays you never put on the channel are +untouched by any of this. + +> **Cut beta builds with the same `versionCode` as the stable release they branch from.** Android +> refuses to install a lower `versionCode`, so a beta numbered above stable can be installed but +> never returned without uninstalling the app (which loses the display's pairing). Equal numbers +> install in both directions, which is what makes switching back work. + #### Deleting and re-pairing a display A display's settings are keyed to the hardware, not to its row in the database. Delete a display and diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index d338fa9..9359ccf 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -525,7 +525,7 @@ export default { '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.beta_hint': 'Puts this display on the pre-release channel: it receives the beta build if the server has one published, and keeps a test build instead of being updated back to the current release. Untick to move it back to the release build. Does nothing if no beta is published.', '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/server/db/database.js b/server/db/database.js index e12bc4a..ff84795 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -305,6 +305,11 @@ const migrations = [ // 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", + // The channel we last SERVED this display. Needed to tell "an operator just switched this + // display off beta" apart from "this display has always run a build of its own" — only the + // first may be pulled back to stable. Without it, publishing a beta would drag every existing + // pre-release tester backwards, which is the harm the opt-in exists to prevent. + "ALTER TABLE devices ADD COLUMN ota_channel_served TEXT", // #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/apk-cache.js b/server/lib/apk-cache.js index 29f409d..4940f2f 100644 --- a/server/lib/apk-cache.js +++ b/server/lib/apk-cache.js @@ -3,6 +3,20 @@ // /download/apk does a per-request synchronous filesystem call. The path/size/mtime are // resolved once at boot and refreshed on an interval (like the frontend-hash refresh), // so a poll/download flood can't turn into an existsSync/statSync flood on the loop. +// +// Two channels. The STABLE slot is the APK every display gets. The BETA slot is optional and +// only reaches displays with devices.ota_beta = 1. +// +// A beta build must DECLARE its version, in a sidecar `.version` file beside it. The server +// cannot infer it: latest_version on the stable channel is the server's own VERSION constant +// (server and APK ship together), but a beta APK is by definition a different version, and +// reading it out of the APK would mean parsing binary AndroidManifest.xml on the request path. +// A one-line text file is explicit, greppable, and cannot drift silently. +// +// If the sidecar is missing or unparseable the beta channel does NOT activate and opted-in +// displays keep getting stable. Failing closed matters here: advertising a version that does not +// match the bytes actually served is precisely the OTA-loop condition this fleet has been bitten +// by before. const fs = require('fs'); const path = require('path'); @@ -10,21 +24,54 @@ const config = require('../config'); // A copy under DATA_DIR wins (container operators mount /data/ScreenTinker.apk), // else the legacy in-repo root path — same order as the old resolveApkPath(). -function candidates() { - return [path.join(config.dataDir, 'ScreenTinker.apk'), path.join(__dirname, '..', '..', 'ScreenTinker.apk')]; +function candidates(name) { + return [path.join(config.dataDir, name), path.join(__dirname, '..', '..', name)]; } -let cache = { path: null, exists: false, size: 0, mtime: 0 }; +const EMPTY = { path: null, exists: false, size: 0, mtime: 0, version: null }; + +let stable = { ...EMPTY }; +let beta = { ...EMPTY }; + +function statFirst(name) { + for (const p of candidates(name)) { + try { + const st = fs.statSync(p); + return { path: p, exists: true, size: st.size, mtime: st.mtimeMs, version: null }; + } catch (_) { /* next */ } + } + return { ...EMPTY }; +} + +// Version declared alongside the APK. First non-empty line, trimmed; anything that is not a +// plausible semver is treated as absent rather than trusted. +function readDeclaredVersion(apkPath) { + if (!apkPath) return null; + try { + const raw = fs.readFileSync(apkPath + '.version', 'utf8'); + const v = String(raw).split('\n')[0].trim(); + return /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(v) ? v : null; + } catch (_) { return null; } +} function refresh() { - for (const p of candidates()) { - try { const st = fs.statSync(p); cache = { path: p, exists: true, size: st.size, mtime: st.mtimeMs }; return cache; } catch (_) { /* next */ } - } - cache = { path: null, exists: false, size: 0, mtime: 0 }; - return cache; + stable = statFirst('ScreenTinker.apk'); + const b = statFirst('ScreenTinker-beta.apk'); + b.version = b.exists ? readDeclaredVersion(b.path) : null; + beta = b.exists && b.version ? b : { ...EMPTY }; // no declared version -> no beta channel + return stable; } -function get() { return cache; } +function get() { return stable; } +function getBeta() { return beta; } + +/** The slot to serve for a channel, falling back to stable whenever beta is not usable. */ +function forChannel(channel) { + return channel === 'beta' && beta.exists ? beta : stable; +} + +/** Whether a usable beta build is published right now. */ +function betaAvailable() { return beta.exists && !!beta.version; } let timer = null; function start() { @@ -33,7 +80,7 @@ function start() { timer = setInterval(refresh, config.otaApkRefreshMs); if (timer.unref) timer.unref(); } - return cache; + return stable; } -module.exports = { start, refresh, get }; +module.exports = { start, refresh, get, getBeta, forChannel, betaAvailable }; diff --git a/server/lib/ota-breaker.js b/server/lib/ota-breaker.js index 0c072fd..b55e1d3 100644 --- a/server/lib/ota-breaker.js +++ b/server/lib/ota-breaker.js @@ -68,14 +68,35 @@ 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(), betaChannel = false) { +function decide(clientVersion, latestVersion, deviceId = null, now = Date.now(), betaChannel = false, wasOnBeta = 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); if (!pc || !pl) return { update_available: false, reason: 'unrecognized-version', log: logOnce(clientVersion, `[ota] unrecognized client version '${clientVersion}' — no offer (latest=${latestVersion})`) }; 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 (full > 0) { + // Normally a client ahead of the server is left alone — never offer a downgrade. But a display + // running a PRE-RELEASE while not opted into betas is a display someone has just switched back + // to the release line, and stable is legitimately "older" than the beta it is replacing. Without + // this it is stranded on the beta build forever, and unticking the box would appear to do + // nothing — the same silent no-op the opt-in exists to remove. + // + // A client ahead on a genuine RELEASE still gets client-newer: that is a rolled-back server, and + // pushing it backwards would be wrong. + // + // NOTE: the server can only OFFER. Android refuses to install a lower versionCode, so a beta + // build must be cut with a versionCode no higher than the stable it branches from — equal is + // ideal, since equal codes install in both directions. A beta with a higher code cannot be + // returned to stable without an uninstall, whatever this endpoint says. + // wasOnBeta is the evidence that this display was actually being served the beta channel. A + // display that has simply always run its own pre-release build is left alone, exactly as + // before — #144's protection for a tester ahead of the server is untouched. + if (!betaChannel && wasOnBeta && !isReleased(pc)) { + return { update_available: true, reason: 'channel-return' }; + } + return { update_available: false, reason: 'client-newer' }; + } // 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 diff --git a/server/server.js b/server/server.js index e7c4c7d..678bc8f 100644 --- a/server/server.js +++ b/server/server.js @@ -703,8 +703,9 @@ const { getBand } = require('./services/loop-lag'); // #146 Item C: critical-ba 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 latestVersion = VERSION; // replaced by the beta build's declared version for opted-in displays let betaChannel = false; // per-display pre-release opt-in, set from the device row below + let wasOnBeta = false; // whether we have actually served this display the beta channel // #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 @@ -716,12 +717,13 @@ app.get('/api/update/check', (req, res) => { let otaDeviceOff = false; if (deviceId) { try { - const row = require('./db/database').db.prepare('SELECT ota_enabled, ota_beta FROM devices WHERE id = ?').get(deviceId); + const row = require('./db/database').db.prepare('SELECT ota_enabled, ota_beta, ota_channel_served 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; + wasOnBeta = !!row && row.ota_channel_served === 'beta'; } catch (_) { /* device unknown / pre-migration — treat as enabled */ } } if (otaGloballyOff || otaDeviceOff) { @@ -736,7 +738,23 @@ 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, Date.now(), betaChannel); + // Channel selection. An opted-in display is compared against — and offered — the BETA build's + // declared version, not the server's. Falls back to stable whenever no usable beta is published, + // so ticking the box on a server with no beta build is a no-op, not a broken display. + const onBeta = betaChannel && apkCache.betaAvailable(); + if (onBeta) latestVersion = apkCache.getBeta().version; + + // The hold-my-prerelease guard only applies when we are NOT actively serving a beta: on the beta + // channel the beta build is the target, so normal comparison does the right thing. + const verdict = otaBreaker.decide(currentVersion, latestVersion, deviceId, Date.now(), betaChannel && !onBeta, wasOnBeta); + + // Record that this display is being served beta, so switching it back later is distinguishable + // from a display that has always run its own build. Written only on a change, not per check. + if (onBeta && !wasOnBeta && deviceId) { + try { + require('./db/database').db.prepare("UPDATE devices SET ota_channel_served = 'beta' WHERE id = ?").run(deviceId); + } catch (_) { /* best-effort bookkeeping; never break a check over it */ } + } // #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 @@ -754,14 +772,17 @@ app.get('/api/update/check', (req, res) => { // Offering — read the CACHED apk metadata (no per-request statSync; refreshed on an // interval by apkCache). Never offer if the APK isn't actually present. - const apk = apkCache.get(); + const apk = onBeta ? apkCache.getBeta() : apkCache.get(); const updateAvailable = apk.exists; if (verdict.log) console.log(verdict.log); logOtaCheck(deviceId, currentVersion, latestVersion, updateAvailable, updateAvailable ? verdict.reason : 'apk-missing'); res.json({ latest_version: latestVersion, current_version: currentVersion || 'unknown', update_available: updateAvailable, reason: updateAvailable ? verdict.reason : 'apk-missing', - download_url: '/download/apk', + // The client fetches whatever URL we hand back, so channel routing needs no APK change — + // a display already in the field can be moved between channels from the dashboard. + download_url: onBeta ? '/download/apk?channel=beta' : '/download/apk', + channel: onBeta ? 'beta' : 'stable', apk_size: updateAvailable ? apk.size : 0, apk_modified: updateAvailable ? apk.mtime : 0, // #166 escape hatch (OTA_ALLOW_MANAGED_DEVICES). Tells a player it may self-update even when @@ -1054,7 +1075,10 @@ const otaDownloadGuard = require('./lib/ota-download-guard'); const otaDownloadState = otaDownloadGuard.prodState(); // #146 P3.8: shared singleton so /api/status can read stats app.get('/download/apk', (req, res) => { - const apk = apkCache.get(); + // Serve the slot the check advertised. If these disagree the client is handed bytes whose + // size does not match apk_size, which is how an OTA loop starts — so both sides resolve + // the channel the same way, and both fall back to stable identically. + const apk = apkCache.forChannel(req.query.channel === 'beta' ? 'beta' : 'stable'); if (!apk.exists) { return res.status(404).send(`APK Not Available — ScreenTinker

APK Not Available

The Android APK has not been compiled yet.

📦 Download from GitHub Releases

Self-hosting? Mount a built APK at /data/ScreenTinker.apk to serve it from this instance. Or use the web player instead.

`); } diff --git a/server/test/ota-two-channel.test.js b/server/test/ota-two-channel.test.js new file mode 100644 index 0000000..6806862 --- /dev/null +++ b/server/test/ota-two-channel.test.js @@ -0,0 +1,128 @@ +'use strict'; + +// Serving two APKs, and letting a display move between them. +// +// The passive opt-in shipped in 1.9.26 only stopped a sideloaded build being reverted. It did not +// let the server DISTRIBUTE a beta: there was one APK slot, and latest_version was the server's own +// VERSION, so a beta build had to be installed by hand on every display. +// +// Two things have to hold or this becomes an OTA loop rather than a feature: +// +// 1. The version advertised must match the bytes served. The check and the download resolve the +// channel the same way and fall back to stable identically, and a beta with no declared +// version does not activate at all. +// 2. Switching back must actually move the display. Stable is semver-OLDER than the beta it +// replaces, so the ordinary "never downgrade" rule strands it — unticking the box would be +// another silent no-op. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-chan-')); +process.env.DATA_DIR = tmp; + +const apkCache = require('../lib/apk-cache'); +const breaker = require('../lib/ota-breaker'); + +const STABLE = path.join(tmp, 'ScreenTinker.apk'); +const BETA = path.join(tmp, 'ScreenTinker-beta.apk'); + +function writeStable() { fs.writeFileSync(STABLE, Buffer.alloc(100, 1)); } +function writeBeta(version) { + fs.writeFileSync(BETA, Buffer.alloc(250, 2)); + if (version === null) { try { fs.unlinkSync(BETA + '.version'); } catch (_) {} } + else fs.writeFileSync(BETA + '.version', version + '\n'); +} +function clearBeta() { + try { fs.unlinkSync(BETA); } catch (_) {} + try { fs.unlinkSync(BETA + '.version'); } catch (_) {} +} + +const ask = (client, latest, beta, wasOnBeta = false) => + breaker.decide(client, latest, null, Date.now(), beta, wasOnBeta); + +test('with no beta published, the beta channel simply is not available', () => { + writeStable(); clearBeta(); apkCache.refresh(); + assert.equal(apkCache.betaAvailable(), false); + // Ticking the box on a server with no beta build must be a no-op, not a broken display. + assert.equal(apkCache.forChannel('beta').path, STABLE, 'must fall back to stable'); +}); + +test('a beta APK with NO declared version does not activate', () => { + // Failing closed: the server cannot know what version those bytes are, and advertising a + // version that does not match what is served is how an OTA loop starts. + writeStable(); writeBeta(null); apkCache.refresh(); + assert.equal(apkCache.betaAvailable(), false, 'an undeclared beta must be ignored entirely'); + assert.equal(apkCache.forChannel('beta').path, STABLE); +}); + +test('a beta APK with a junk version file is treated as absent, not trusted', () => { + writeStable(); fs.writeFileSync(BETA, Buffer.alloc(250, 2)); + fs.writeFileSync(BETA + '.version', 'latest\n'); + apkCache.refresh(); + assert.equal(apkCache.betaAvailable(), false); +}); + +test('a properly declared beta activates and is served on the beta channel only', () => { + writeStable(); writeBeta('1.9.27-rc1'); apkCache.refresh(); + assert.equal(apkCache.betaAvailable(), true); + assert.equal(apkCache.getBeta().version, '1.9.27-rc1'); + assert.equal(apkCache.forChannel('beta').path, BETA); + assert.equal(apkCache.forChannel('stable').path, STABLE, 'stable displays must be unaffected'); + assert.equal(apkCache.get().path, STABLE); +}); + +test('the two slots report their own sizes, so apk_size matches the bytes served', () => { + writeStable(); writeBeta('1.9.27-rc1'); apkCache.refresh(); + assert.equal(apkCache.forChannel('stable').size, 100); + assert.equal(apkCache.forChannel('beta').size, 250); + assert.notEqual(apkCache.get().size, apkCache.getBeta().size); +}); + +test('an opted-in display is offered the beta build over the current stable', () => { + // The check compares against the beta's declared version, not the server's. + const v = ask('1.9.26', '1.9.27-rc1', false); + assert.equal(v.update_available, true); + assert.equal(v.reason, 'offer'); +}); + +test('once on the beta build, an opted-in display is up to date', () => { + assert.equal(ask('1.9.27-rc1', '1.9.27-rc1', false).reason, 'up-to-date'); +}); + +test('THE SWITCH BACK: unticking beta moves a display off the beta build', () => { + // Stable 1.9.26 is semver-OLDER than 1.9.27-rc1, so the plain "never downgrade" rule would + // strand it and unticking the box would appear to do nothing. wasOnBeta is the evidence that + // we actually served this display the beta channel. + const v = ask('1.9.27-rc1', '1.9.26', false, true); + assert.equal(v.update_available, true, 'the display must be offered the release build'); + assert.equal(v.reason, 'channel-return'); +}); + +test('a display we never served beta to is NOT pulled back, even on a pre-release', () => { + // #144 protects a tester who is ahead of the server on their own build. Publishing a beta must + // not drag every such display backwards — that is the exact harm the opt-in exists to prevent. + const v = ask('1.9.4-beta1', '1.9.3', false, false); + assert.equal(v.update_available, false); + assert.equal(v.reason, 'client-newer'); +}); + +test('a display AHEAD on a real release is still never downgraded', () => { + // This is a rolled-back server, not a channel switch. Pushing it backwards would be wrong. + const v = ask('1.9.27', '1.9.26', false); + assert.equal(v.update_available, false); + assert.equal(v.reason, 'client-newer'); +}); + +test('a still-opted-in display on a newer prerelease is NOT dragged back to stable', () => { + // Only unticking the box should return it. While opted in it keeps its beta build, even though + // we have served it beta before. + const v = ask('1.9.27-rc1', '1.9.26', true, true); + assert.equal(v.update_available, false); + assert.equal(v.reason, 'client-newer'); +}); + +test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });