mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -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
129 lines
5.8 KiB
JavaScript
129 lines
5.8 KiB
JavaScript
'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 (_) {} });
|