Merge feat/ota-two-channel: serve a beta APK alongside stable, with a real switch back

This commit is contained in:
ScreenTinker 2026-07-30 19:13:51 -05:00
commit 6d33d00cd0
7 changed files with 268 additions and 20 deletions

View file

@ -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

View file

@ -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.',

View file

@ -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.

View file

@ -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 `<apk>.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 };

View file

@ -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

View file

@ -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(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>APK Not Available — ScreenTinker</title><style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0}div{text-align:center;max-width:480px;padding:32px 24px}h1{color:#f87171;font-size:22px;margin:0 0 8px}p{line-height:1.6;color:#94a3b8;font-size:14px;margin:0 0 20px}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}a{color:#3b82f6;text-decoration:none}a:hover{text-decoration:underline}.btn{display:inline-block;background:#2563eb;color:#fff;padding:10px 20px;border-radius:6px;font-size:14px;font-weight:500;text-decoration:none;margin-bottom:24px}.btn:hover{background:#1d4ed8;text-decoration:none}.muted{font-size:12px;color:#64748b}</style></head><body><div><h1>APK Not Available</h1><p>The Android APK has not been compiled yet.</p><a class="btn" href="https://github.com/screentinker/screentinker/releases/latest" target="_blank" rel="noopener">&#128230; Download from GitHub Releases</a><p class="muted">Self-hosting? Mount a built APK at <code>/data/ScreenTinker.apk</code> to serve it from this instance. Or use the <a href="/player">web player</a> instead.</p></div></body></html>`);
}

View file

@ -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 (_) {} });