screentinker/server/lib/brightsign-package.js
ScreenTinker 8fd6eb75d5 BrightSign: cache content for offline, and let the package update itself
Two gaps that both end the same way — a panel nobody can fix without a van.

OFFLINE. Content bytes were never persistently cached. The service worker
skipped /uploads/content/ and leaned on the browser's HTTP cache, which is
reasonable on a desktop and is not a documented-persistent store here:
BrightSign guarantees survival across reboots for IndexedDB, localStorage and
SQLite, and their own answer for offline video is to cache the bytes explicitly.
A panel could come back from a power cut with its playlist intact — that lives in
localStorage — and no media to play it with.

The reason content was skipped is real, and player-cache-policy.js is what makes
intercepting it safe. Seeking video issues range requests, and naive caching is
worse than none: storing a 206 as the whole file means every later full request
gets a fragment, and answering a range request with a 200 makes some media stacks
fail outright. So only complete 200s are stored, and ranges are served by slicing
the stored body into a correct 206. The content cache survives shell
re-versioning, or every deploy would re-download the playlist over a link that may
be exactly what is broken.

SELF-UPDATE. The package can replace autorun.brs, so a truncated file is a dark
panel with no app underneath. The safety is the ordering: download to .part,
verify sha256 AND size, then delete the .done marker, rename, reboot. Marker
first is not stylistic — leaving it makes the next boot skip the archive and the
update silently never happens. A failed extract parks the zip as .bad instead of
retrying every boot, which would be a loop indistinguishable from a hardware
fault. sha256 because that is what roMessageDigest can compute; a checksum the
player cannot verify is an unverifiable package.

The decision lives on the server and is unit-tested, and the host only executes
it — re-implementing the version comparison in BrightScript would put the
prerelease trap somewhere untestable. That trap is honoured directly: a player on
1.9.29-rc1 is running something semver-OLDER than 1.9.29, so an opted-in player
HOLDS a prerelease of its own core rather than being pulled off the build it was
given to test. Narrowly — a newer core still lands, so opting in never means
never updating again.

Both loop conditions are closed by construction. The manifest and the download
come from one buffer hashed once, so a checksum cannot describe bytes we are not
serving. And the version is stamped into autorun.brs at build time by both
builders, so the script reports the version it actually is — otherwise the player
applies the update, still reports the old version, and is offered the same
package forever.

Failure always degrades to "keep running the old version": an unreachable
manifest, a missing checksum, a failed verification, a full attempt counter and
an unbuildable package all resolve to skip.

998 tests pass (was 954).
2026-08-05 10:09:00 -05:00

119 lines
4.7 KiB
JavaScript

'use strict';
/*
* Builds and serves the BrightSign player package (autorun.zip) for self-update.
*
* THE INVARIANT THIS FILE EXISTS TO HOLD: the checksum in the manifest and the bytes on the
* download route come from the SAME in-memory buffer, built once. Advertising a version whose
* checksum does not match the bytes actually served is the classic OTA-loop condition — the player
* downloads, fails verification, retries, forever — and it is the easiest mistake to make when the
* manifest is computed from one source and the file from another (a file on disk that a deploy
* replaced, say). Here it is impossible by construction: there is one buffer and both routes read
* it.
*
* The zip is built deterministically from brightsign/, not read from a prebuilt artifact, because a
* prebuilt autorun.zip is a CI output that is not present in a git-checkout deployment. Building it
* means the manifest is always available and always describes files that actually exist.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const archiver = require('archiver');
// The payload, mirroring scripts/build-autorun-zip.sh. autozip.brs must be present or nothing
// unpacks the archive on the player; autorun.brs must be INSIDE it and never beside it on the
// storage root, or the player refuses to process the zip at all.
const PACKAGE_FILES = ['autozip.brs', 'autorun.brs', 'offline.html', 'screentinker.json'];
// sha256 rather than sha1 because that is the algorithm BrightScript's roMessageDigest is
// documented against — the player has to be able to verify what we advertise, and an algorithm it
// cannot compute is an unverifiable package, which this whole design exists to refuse.
let cached = null; // { version, sha256, size, buffer }
function brightsignDir() {
return path.join(__dirname, '..', '..', 'brightsign');
}
function readVersion() {
try {
return fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim();
} catch (e) {
return null;
}
}
/*
* Rewrite the stamped version line in autorun.brs.
*
* Anchored on the ST_PACKAGE_VERSION marker rather than on the literal, so a hand-edited default
* cannot cause a silent miss. If the marker is ever removed the stamp is skipped and the package
* ships reporting "0.0.0-dev", which reads as permanently out of date — noisy, but noisy in the
* direction of "someone look at this" rather than a silent update loop.
*/
function stampVersion(source, version) {
return source.replace(
/return "[^"]*"(\s*'\s*ST_PACKAGE_VERSION)/,
`return "${version}"$1`
);
}
/*
* Build the archive in memory. Entries are added in a fixed order with a fixed timestamp so the
* bytes are reproducible: a checksum that changed on every server restart would make every player
* re-download the same package after every deploy.
*/
function buildZip() {
return new Promise((resolve, reject) => {
const dir = brightsignDir();
const chunks = [];
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('data', (c) => chunks.push(c));
archive.on('error', reject);
archive.on('end', () => resolve(Buffer.concat(chunks)));
const version = readVersion();
for (const name of PACKAGE_FILES) {
const p = path.join(dir, name);
if (!fs.existsSync(p)) return reject(new Error(`package file missing: ${name}`));
let body = fs.readFileSync(p);
// Stamp the version into the host so the script REPORTS the version it actually is. Ship it
// unstamped and the player applies the update, still reports the old version, and is offered
// the same package forever — the OTA loop, arriving by the back door.
if (name === 'autorun.brs') body = Buffer.from(stampVersion(body.toString('utf8'), version), 'utf8');
// date fixed for reproducibility; the player never reads it.
archive.append(body, { name, date: new Date(0) });
}
archive.finalize();
});
}
/*
* Get the package, building once and caching. Returns null when the package cannot be built (a
* deployment without the brightsign/ directory, for instance) — callers must treat that as "no
* manifest", which the update decision reads as "keep running", never as "wipe yourself".
*/
async function getPackage() {
if (cached) return cached;
const version = readVersion();
if (!version) return null;
try {
const buffer = await buildZip();
cached = {
version,
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
size: buffer.length,
buffer
};
return cached;
} catch (e) {
return null;
}
}
/* Test seam: drop the cache so a changed file is picked up without a restart. */
function _reset() { cached = null; }
module.exports = { getPackage, _reset, PACKAGE_FILES };