Serve a beta APK alongside the stable one, and let a display move between them

1.9.26's opt-in was passive: it stopped a sideloaded build being reverted, but there was still one
APK slot and latest_version was the server's own VERSION, so a beta had to be installed by hand on
every display. This makes it a real channel.

- apk-cache tracks two slots. ScreenTinker-beta.apk is optional and reaches only displays with
  ota_beta = 1.
- A beta must DECLARE its version in a sidecar ScreenTinker-beta.apk.version. The server cannot
  infer it — stable's version is the server's own constant because the two ship together, and
  reading it from the APK means parsing binary AndroidManifest.xml on the request path. If the
  sidecar is missing or unparseable the channel does not activate at all and opted-in displays keep
  getting stable. Failing closed matters: advertising a version that does not match the bytes served
  is the OTA-loop condition this fleet has been bitten by before.
- The check and the download resolve the channel identically and fall back to stable identically, so
  apk_size always describes the bytes actually delivered. No APK change was needed — the client
  already fetches whatever download_url it is handed, so displays in the field can be moved between
  channels from the dashboard today.

Switching back needed care. Stable is semver-OLDER than the beta it replaces, so the ordinary
"never offer a downgrade" rule stranded the display and unticking the box would have been another
silent no-op. The first attempt returned any non-opted-in display running a pre-release — which
broke a #144 test, correctly: that would have dragged every existing pre-release tester back to
stable the moment their server upgraded, the exact harm the opt-in exists to prevent. So the return
now requires evidence we actually served that display the beta channel (devices.ota_channel_served,
written once on change, not per check). A tester ahead of the server on their own build is left
alone exactly as before.

Documented in the README, including the constraint that makes the switch-back physically possible:
beta builds must carry a versionCode no higher than the stable they branch from, because Android
refuses to install a lower one. Equal numbers install in both directions.

Verified end to end against a live server with two real signed APKs: stable serves 1.9.26, beta
serves 1.9.27-rc1, an unknown channel falls back to stable, removing the version file deactivates
the channel, and the full opt-in -> serve -> switch-back lifecycle produces offer / up-to-date /
channel-return in order. 859 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
Claude 2026-07-30 19:12:46 -05:00
parent d70764991e
commit b44f9d4f03
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 (_) {} });