mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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
87 lines
3.4 KiB
JavaScript
87 lines
3.4 KiB
JavaScript
'use strict';
|
|
// #146 hardening (Item C) — cache the OTA APK resolution so no /api/update/check or
|
|
// /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');
|
|
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(name) {
|
|
return [path.join(config.dataDir, name), path.join(__dirname, '..', '..', name)];
|
|
}
|
|
|
|
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() {
|
|
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 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() {
|
|
refresh(); // resolve once at boot
|
|
if (!timer) {
|
|
timer = setInterval(refresh, config.otaApkRefreshMs);
|
|
if (timer.unref) timer.unref();
|
|
}
|
|
return stable;
|
|
}
|
|
|
|
module.exports = { start, refresh, get, getBeta, forChannel, betaAvailable };
|