Merge: BrightSign offline content caching and package self-update

This commit is contained in:
ScreenTinker 2026-08-05 10:20:50 -05:00
commit 9c04e2c113
11 changed files with 1296 additions and 3 deletions

View file

@ -214,6 +214,69 @@ Still Android-only, and correctly inert here: the Tier-2 device-owner commands (
`install_apk`, `shell`, `block_uninstall`, …) and `set_brightness` / `set_screen_timeout`, which
have no BrightSign equivalent — a signage player has no per-window brightness or screen timeout.
## Offline playback
Content bytes are cached by the service worker (`server/player/sw.js`) into a dedicated
`rd-content-v1` cache, so a player that loses its server keeps playing its playlist.
This used to be left to the browser's HTTP cache — the server sends
`Cache-Control: public, max-age=2592000, immutable`. That is fine on a desktop and is **not a
documented-persistent store here**: BrightSign guarantees survival across reloads, app restarts and
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.
The reason content was skipped originally is real, and `server/lib/player-cache-policy.js` is what
makes intercepting it safe. Video elements issue **range requests** when they seek, and naive
caching breaks playback in two ways that are worse than not caching at all:
- storing a `206` as if it were the whole file — every later full request gets a fragment, and it
stays broken until eviction
- answering a range request with a `200` — some media stacks treat the mismatch as fatal and the
video never starts
So only complete `200`s are ever stored, and a range request is served by slicing the stored body
into a correct `206`. The content cache is deliberately **not** dropped when the shell is
re-versioned, or every deploy would re-download the whole playlist over a link that may be exactly
what is broken.
## Self-update
The player can replace its own host package. This is the most dangerous thing it does: a truncated
or half-applied `autorun.brs` is a dark panel and a site visit, because there is no app underneath.
The safety is the **ordering**, and every step earns its place:
1. Download to `autorun.zip.part` — never straight to `autorun.zip`. A file still downloading must
never be a candidate for extraction.
2. Verify **sha256 and size** before promoting. A captive portal answering with a login page
produces a perfectly well-formed small file; the size floor catches that, the hash catches the
rest. sha256 specifically, because that is what BrightScript's `roMessageDigest` can compute —
a checksum the player cannot verify is an unverifiable package.
3. Promote: delete the `.done` marker **first**, then rename `.part``autorun.zip`, then reboot.
Marker first is not stylistic — leaving it makes the next boot skip the new archive and the
update silently never happens.
4. A failed extract renames the archive to `.bad` rather than retrying. A zip that cannot be
unpacked will not unpack on the tenth attempt, and retrying every boot is a loop that looks
exactly like a hardware fault.
**The decision is the server's**, in `server/lib/brightsign-update.js` — unit-tested, and the same
place the prerelease rule lives. The host only executes what it is told; re-implementing the version
comparison in BrightScript would put the prerelease trap somewhere it cannot be tested.
**The version is baked into `autorun.brs`**, stamped at build time by both
`scripts/build-autorun-zip.sh` and `server/lib/brightsign-package.js`, anchored on the
`ST_PACKAGE_VERSION` marker. A version record that can disagree with the code actually running is
the OTA-loop condition by the back door: apply, still report the old version, get offered the same
package forever.
**The manifest and the download come from one buffer**, hashed once. Advertising a version whose
checksum does not match the bytes served is the same loop from the front door.
Config: `self_update` (default **on** — a fleet that cannot be updated remotely needs a van) and
`allow_prerelease` (default off, mirroring the Android beta channel; an opted-in player also
*holds* a prerelease of its own core rather than being pulled back to the release).
## What is NOT done yet
Stated plainly so nobody reads this as finished:

View file

@ -46,6 +46,13 @@ Function LoadConfig() As Object
sync_backend: "auto" ' auto | screentinker | brightsign
output_mode: "single" ' single | dual | clone
inspector: false
' Self-update of the host package. Defaults ON: a fleet that cannot be updated remotely is
' a fleet that needs a van. The DECISION is still the server's, and it refuses anything it
' cannot verify, so "on" does not mean "will apply whatever it is handed".
self_update: true
' Mirrors the Android beta channel. Off by default; an opted-in player also HOLDS a
' prerelease of its own core instead of being pulled back to the release.
allow_prerelease: false
}
' 1) registry
@ -54,6 +61,8 @@ Function LoadConfig() As Object
if reg.Exists("device_id") then cfg.device_id = reg.Read("device_id")
if reg.Exists("sync_backend") then cfg.sync_backend = reg.Read("sync_backend")
if reg.Exists("output_mode") then cfg.output_mode = reg.Read("output_mode")
if reg.Exists("self_update") then cfg.self_update = (reg.Read("self_update") = "1")
if reg.Exists("allow_prerelease") then cfg.allow_prerelease = (reg.Read("allow_prerelease") = "1")
' 2) a JSON file on the card wins — that is how a batch gets imaged without touching each box
ba = CreateObject("roByteArray")
@ -65,6 +74,8 @@ Function LoadConfig() As Object
if json.sync_backend <> invalid then cfg.sync_backend = json.sync_backend
if json.output_mode <> invalid then cfg.output_mode = json.output_mode
if json.inspector <> invalid then cfg.inspector = json.inspector
if json.self_update <> invalid then cfg.self_update = json.self_update
if json.allow_prerelease <> invalid then cfg.allow_prerelease = json.allow_prerelease
end if
end if
@ -140,6 +151,199 @@ Function FullScreenRect() As Object
return CreateObject("roRectangle", 0, 0, vm.GetResX(), vm.GetResY())
End Function
'=== self-update ============================================================================
'
' The package (autorun.zip) can replace THIS SCRIPT. That makes it the most dangerous thing the
' player does: a truncated or half-applied autorun.brs is a dark panel and a site visit, because
' there is no app underneath to fall back to.
'
' The ordering below is the safety, and it is deliberate at every step:
'
' 1. Download to autorun.zip.part — never straight to autorun.zip. A file that is still
' downloading, or that stopped halfway, must never be a candidate for extraction.
' 2. Verify sha256 AND size before promoting. A captive portal that answers with a login page
' produces a perfectly well-formed small file; the size floor catches it, the hash catches
' everything else.
' 3. Only then promote: delete the .done marker, rename .part -> autorun.zip, reboot.
' The marker MUST go first — leaving it would make ApplyPendingPackage skip the new archive
' on the next boot and the update would silently never happen.
' 4. Extraction failure renames the archive to .bad rather than retrying forever. A zip that
' cannot be unpacked will not unpack on the tenth attempt either, and retrying it on every
' boot is a loop that looks exactly like a hardware fault.
'
' THE VERSION IS BAKED IN, not stored in a side file. A version record that can disagree with the
' code actually running is the OTA-loop condition in another guise: the player applies an update,
' reports the old version, is offered it again, forever. Stamped at build time by both
' scripts/build-autorun-zip.sh and server/lib/brightsign-package.js.
Function PackageVersion() As String
return "0.0.0-dev" ' ST_PACKAGE_VERSION (stamped at build time — do not edit by hand)
End Function
Function DoesFileExist(filePath$ As String) As Boolean
files = MatchFiles(filePath$, filePath$)
return files.Count() > 0
End Function
' Unpack a package that is sitting on storage waiting to be applied. Runs BEFORE the widget so a
' pending update lands before the player starts, not halfway through a playlist.
'
' Note this duplicates autozip.brs on purpose. autozip.brs handles the FIRST install, where a bare
' card holds nothing but autorun.zip and the OS processes it. Once autorun.brs exists at the
' storage root the OS no longer auto-processes the archive — so from then on the host has to do it
' itself, or self-update would work exactly once.
Sub ApplyPendingPackage(root As String)
zipPath$ = root + "/autorun.zip"
donePath$ = root + "/autorun.zip.done"
badPath$ = root + "/autorun.zip.bad"
if not DoesFileExist(zipPath$) then return
if DoesFileExist(donePath$) then return ' already unpacked; extracting again is the boot loop
print "[st-update] unpacking pending package"
unzip = CreateObject("roUnzip", zipPath$)
if unzip = invalid then
print "[st-update] ERROR: archive unreadable — parking it as .bad"
fs = CreateObject("roFileSystem")
if fs <> invalid then fs.Rename(zipPath$, badPath$)
return
end if
if unzip.DecompressAllFiles(root + "/") <> 0 then
print "[st-update] ERROR: extract failed — parking it as .bad so we do not retry forever"
fs = CreateObject("roFileSystem")
if fs <> invalid then fs.Rename(zipPath$, badPath$)
return
end if
fs = CreateObject("roFileSystem")
if fs = invalid then return
if not fs.Rename(zipPath$, donePath$) then
' Refusing to reboot without the marker: we would extract and reboot forever.
print "[st-update] ERROR: could not mark done — not rebooting"
return
end if
print "[st-update] package applied — rebooting into it"
sleep(2000)
RebootSystem()
End Sub
' Ask the server what to do, and do exactly that. The DECISION lives on the server
' (server/lib/brightsign-update.js, which is unit-tested); this only executes it. Re-implementing
' the version comparison here would put the prerelease trap somewhere it cannot be tested.
Sub CheckPackageUpdate(cfg As Object, root As String)
if cfg.server_url = "" then return
partPath$ = root + "/autorun.zip.part"
reg = CreateObject("roRegistrySection", "screentinker")
attempts% = 0
if reg.Exists("pkg_attempts") then attempts% = Val(reg.Read("pkg_attempts"))
url$ = cfg.server_url + "/api/brightsign/package?version=" + PackageVersion()
url$ = url$ + "&attempts=" + Stri(attempts%).Trim()
if cfg.allow_prerelease then url$ = url$ + "&allow_prerelease=1"
xfer = CreateObject("roUrlTransfer")
if xfer = invalid then return
xfer.SetUrl(url$)
xfer.EnablePeerVerification(true)
body$ = xfer.GetToString()
if body$ = "" then return ' unreachable: keep running what works
manifest = ParseJson(body$)
if manifest = invalid then return
if manifest.action = invalid then return
if manifest.action <> "download" then
if manifest.reason <> invalid then print "[st-update] no action: "; manifest.reason
return
end if
print "[st-update] downloading package "; manifest.version
' Any earlier partial is deleted first: resuming into an existing file would concatenate two
' downloads into something that hashes to neither.
fs = CreateObject("roFileSystem")
if fs <> invalid and DoesFileExist(partPath$) then fs.Delete(partPath$)
dl = CreateObject("roUrlTransfer")
if dl = invalid then return
dl.SetUrl(cfg.server_url + manifest.url)
dl.EnablePeerVerification(true)
if dl.GetToFile(partPath$) <> 200 then
print "[st-update] download failed"
RecordPackageAttempt(reg, attempts% + 1)
if fs <> invalid then fs.Delete(partPath$)
return
end if
' Verify before promoting. This is the gate that stops a truncated file becoming the boot script.
if not VerifyPackage(partPath$, manifest.sha256, manifest.size) then
print "[st-update] VERIFICATION FAILED — discarding, staying on "; PackageVersion()
RecordPackageAttempt(reg, attempts% + 1)
if fs <> invalid then fs.Delete(partPath$)
return
end if
' Promote. Marker first — see the ordering note above.
if fs = invalid then return
if DoesFileExist(root + "/autorun.zip.done") then fs.Delete(root + "/autorun.zip.done")
if DoesFileExist(root + "/autorun.zip") then fs.Delete(root + "/autorun.zip")
if not fs.Rename(partPath$, root + "/autorun.zip") then
print "[st-update] ERROR: could not stage the package — staying put"
RecordPackageAttempt(reg, attempts% + 1)
return
end if
' A clean attempt counter, so the next version starts from zero rather than inheriting this
' version's failures and being refused before it is ever tried.
RecordPackageAttempt(reg, 0)
print "[st-update] staged "; manifest.version; " — rebooting to apply"
sleep(2000)
RebootSystem()
End Sub
Sub RecordPackageAttempt(reg As Object, n As Integer)
if reg = invalid then return
reg.Write("pkg_attempts", Stri(n).Trim())
reg.Flush()
End Sub
' sha256 + size. Both matter: the hash proves the bytes are the ones we were promised, the size
' floor catches an error page or captive-portal login saved under the package's name.
Function VerifyPackage(path As String, expected As String, expectedSize As Integer) As Boolean
if expected = invalid or expected = "" then return false
fs = CreateObject("roFileSystem")
if fs = invalid then return false
info = fs.Stat(path)
if info = invalid then return false
if info.size < 1024 then
print "[st-update] package is implausibly small ("; info.size; " bytes)"
return false
end if
if expectedSize > 0 and info.size <> expectedSize then
print "[st-update] size mismatch: got "; info.size; " expected "; expectedSize
return false
end if
digest = CreateObject("roMessageDigest")
if digest = invalid then return false
digest.SetAlgorithm("sha256")
file = fs.OpenInputFile(path)
if file = invalid then return false
while true
chunk = file.Read(65536)
if chunk.Count() = 0 then exit while
digest.Update(chunk)
end while
return LCase(digest.Final()) = LCase(expected)
End Function
'=== main ===================================================================================
Sub Main()
@ -152,6 +356,11 @@ Sub Main()
' Must happen BEFORE the widget starts: it can reboot.
EnsurePtpDomain(cfg)
' A package staged by a previous run lands here, before anything is on screen. Doing it after
' the widget started would mean rebooting out of a playing playlist, and the panel would blink
' mid-content for a reason nobody watching could explain.
ApplyPendingPackage(StorageRoot())
port = CreateObject("roMessagePort")
' Second output. The XC5 family exposes more than one HDMI connector (XC2055 dual, XC4055
@ -182,6 +391,15 @@ Sub Main()
lastBeat = CreateObject("roTimespan")
lastBeat.Mark()
' Update check runs AFTER the widget is up, deliberately. A slow or unreachable server must
' never delay first frame — content on screen is the job, updating is housekeeping. It also
' runs on a timer rather than only at boot, because a panel that is never power-cycled would
' otherwise never see an update at all.
lastPkgCheck = CreateObject("roTimespan")
lastPkgCheck.Mark()
PKG_CHECK_MS = 6 * 60 * 60 * 1000 ' 6h: this replaces the boot script, so rarely is right
if cfg.self_update then CheckPackageUpdate(cfg, StorageRoot())
' A watchdog on TOP of load-error: a page can load fine and then wedge (dead socket, JS
' exception, decoder stall) without the OS ever reporting an error. st-bridge.js posts a
' heartbeat every 30s; three missed beats and we rebuild the widget. This is the difference
@ -274,6 +492,13 @@ Sub Main()
widget = RebuildWidget(widget, PlayerUrl(cfg, 1), rect, port, cfg)
lastBeat.Mark()
end if
' Periodic package check. Marked BEFORE the call, not after: a check that blocks on a slow
' server would otherwise be retried immediately on the next tick and hammer it.
if cfg.self_update and lastPkgCheck.TotalMilliseconds() > PKG_CHECK_MS then
lastPkgCheck.Mark()
CheckPackageUpdate(cfg, StorageRoot())
end if
end while
End Sub

View file

@ -39,6 +39,25 @@ cp brightsign/autorun.brs "$STAGE/"
cp brightsign/offline.html "$STAGE/"
cp brightsign/screentinker.json "$STAGE/"
# Stamp the version into the host so the script REPORTS the version it actually is. A package that
# ships reporting the old version is applied, reports the old version, and is offered again on the
# next check — forever. server/lib/brightsign-package.js does the identical substitution, anchored
# on the same ST_PACKAGE_VERSION marker, so a zip built here and one built by the server agree.
VERSION="$(cat VERSION 2>/dev/null | tr -d '[:space:]')"
if [ -n "$VERSION" ]; then
python3 - "$STAGE/autorun.brs" "$VERSION" <<'PY'
import re, sys
path, version = sys.argv[1], sys.argv[2]
src = open(path).read()
out = re.sub(r'return "[^"]*"(\s*\'\s*ST_PACKAGE_VERSION)', 'return "%s"\\1' % version, src)
if out == src:
sys.exit("ERROR: ST_PACKAGE_VERSION marker not found in autorun.brs — refusing to ship an "
"unstamped package, which would loop on every update check.")
open(path, 'w').write(out)
PY
echo " stamped package version $VERSION"
fi
# Point a batch at a specific server without hand-editing each card.
if [ -n "$SERVER" ]; then
python3 - "$STAGE/screentinker.json" "$SERVER" <<'PY'

View file

@ -0,0 +1,118 @@
'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 };

View file

@ -0,0 +1,158 @@
'use strict';
/*
* Should a BrightSign player replace its own host package (autorun.zip)?
*
* This is the riskiest self-update in the product. An Android OTA that goes wrong leaves a player
* on the old APK; a BrightSign package update that goes wrong replaces the SCRIPT THAT BOOTS THE
* PLAYER. A truncated or half-applied autorun.brs is a dark panel and a site visit there is no
* app underneath to fall back to.
*
* So the rule this module encodes is deliberately conservative: refuse unless everything lines up,
* and treat every ambiguity as "keep running what works".
*
* THREE SCARS THIS EXISTS TO HONOUR:
*
* 1. A prerelease sorts BELOW its own release. `1.9.29-rc1` is semver-older than `1.9.29`, so a
* player handed a test build asks "anything newer?", is correctly told yes the release and
* updates itself straight off the build someone was asked to test. That cost a reporter an
* evening on the Android side. Here the same comparison decides whether to overwrite the boot
* script, so it is checked in one place and tested against the exact versions that burned us.
* 2. Advertising a version that does not match the bytes served is the classic OTA-loop condition:
* the player installs, reports the old version, is offered the update again, forever. The
* manifest therefore carries a checksum, and a package whose bytes do not hash to it is never
* applied a mismatch is treated as a failed download, not as a new version.
* 3. On this platform `location.reload()` does not reliably bring the widget back, so anything
* that needs a restart goes through the host. Applying a package ends in a reboot, which is why
* it must never be triggered on a whim.
*
* Pure by design: no filesystem, no network, no clock beyond what the caller passes. The BrightScript
* host asks this what to do and does exactly that.
*/
const MAX_ATTEMPTS_PER_VERSION = 3;
/*
* Compare two semver-ish versions. Returns -1, 0 or 1.
*
* Prerelease handling is the whole point: 1.9.29-rc1 < 1.9.29, and 1.9.29-rc1 < 1.9.29-rc2. A
* missing prerelease outranks a present one at equal core, which is what makes the release beat its
* own candidate.
*/
function compareVersions(a, b) {
const parse = (v) => {
const [core, pre] = String(v || '0.0.0').split('-');
const nums = core.split('.').map((n) => parseInt(n, 10) || 0);
return { nums: [nums[0] || 0, nums[1] || 0, nums[2] || 0], pre: pre || null };
};
const A = parse(a);
const B = parse(b);
for (let i = 0; i < 3; i++) {
if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1;
}
if (A.pre === B.pre) return 0;
if (A.pre === null) return 1; // 1.9.29 beats 1.9.29-rc1
if (B.pre === null) return -1;
return A.pre < B.pre ? -1 : 1; // rc1 < rc2, lexicographic is right for our naming
}
/*
* Is `version` a prerelease of the same core release as `release`?
* 1.9.29-rc1 is a prerelease of 1.9.29; 1.9.29-rc1 is NOT a prerelease of 1.9.30.
*/
function isPrereleaseOf(version, release) {
const core = (v) => String(v || '').split('-')[0];
return String(version || '').includes('-') && core(version) === core(release);
}
/**
* Decide what the host should do about a package update.
*
* @param {object} state
* currentVersion {string} version of the package running now
* manifestVersion {string} version the server advertises (null/absent = no manifest reachable)
* manifestSha256 {string} checksum of the bytes the server will serve
* stagedSha256 {string} checksum of an already-downloaded file awaiting apply (optional)
* attempts {number} failed attempts recorded for manifestVersion
* allowPrerelease {boolean} opt-in, mirroring the Android beta channel
* @returns {{action:'skip'|'download'|'apply', reason:string}}
*/
function decidePackageUpdate(state) {
const s = state || {};
const current = s.currentVersion || '0.0.0';
const advertised = s.manifestVersion;
// No manifest: the server is unreachable or does not publish one. Keep running. This is the
// common case during an outage and must never be mistaken for "no update needed, wipe yourself".
if (!advertised) return { action: 'skip', reason: 'no manifest' };
// A manifest without a checksum cannot be verified, and an unverifiable package is exactly the
// truncated-download risk this module exists to refuse.
if (!s.manifestSha256) return { action: 'skip', reason: 'manifest has no checksum' };
const cmp = compareVersions(advertised, current);
if (cmp <= 0) {
return { action: 'skip', reason: cmp === 0 ? 'already current' : 'advertised version is older' };
}
// THE PRERELEASE TRAP, and the reason a plain "newer wins" comparison is not enough.
//
// A player running 1.9.29-rc1 is running something semver-OLDER than 1.9.29, so the release
// legitimately compares as newer — and a player handed a test build would update straight off it.
// That is exactly what happened on Android: a reporter tested an evening on a build their tablet
// had already replaced.
//
// An OPTED-IN player therefore holds a prerelease of the same core instead of being pulled back to
// its release. A player that never opted in is not testing anything and should rejoin the release
// line, so it updates normally. Narrow by construction: only the same core is held, so a genuinely
// newer core (1.9.30) still lands and opting in can never mean never updating again.
if (s.allowPrerelease && isPrereleaseOf(current, advertised)) {
return { action: 'skip', reason: 'holding prerelease of the same core (opted in)' };
}
const isPrerelease = String(advertised).includes('-');
if (isPrerelease && !s.allowPrerelease) {
return { action: 'skip', reason: 'prerelease requires opt-in' };
}
// Repeated failure on the SAME version means something is durably wrong — a corrupt artifact, a
// proxy mangling the download, a full disk. Retrying forever burns the link and, on a metered
// connection, real money. Stop and stay on the version that works.
if ((s.attempts || 0) >= MAX_ATTEMPTS_PER_VERSION) {
return { action: 'skip', reason: 'too many failed attempts for this version' };
}
// Already downloaded and the bytes hash correctly: apply it. Splitting download from apply is
// what keeps a truncated file from ever becoming autorun.zip.
if (s.stagedSha256) {
if (s.stagedSha256 === s.manifestSha256) return { action: 'apply', reason: 'staged package verified' };
return { action: 'download', reason: 'staged package failed verification' };
}
return { action: 'download', reason: 'newer package available' };
}
/*
* Is a downloaded file safe to promote to autorun.zip?
*
* Separate from the decision above because it is answered AFTER the bytes land, and it is the last
* gate before we overwrite the boot script. Both conditions are non-negotiable: the checksum proves
* the file is whole, and a non-trivial size catches the case where a captive portal or an error page
* was saved as if it were the package a 2KB HTML error page hashes to something, just not this.
*/
function isPackageSafeToApply(actualSha256, expectedSha256, actualBytes, minBytes) {
if (!actualSha256 || !expectedSha256) return false;
if (actualSha256 !== expectedSha256) return false;
const floor = typeof minBytes === 'number' ? minBytes : 1024;
if (!(actualBytes >= floor)) return false;
return true;
}
module.exports = {
decidePackageUpdate,
isPackageSafeToApply,
compareVersions,
MAX_ATTEMPTS_PER_VERSION
};

View file

@ -0,0 +1,148 @@
// Offline content caching policy for the web player's service worker.
//
// WHY THIS EXISTS: content bytes were never persistently cached. The service worker deliberately
// skipped `/uploads/content/` and leaned on `Cache-Control: public, max-age=2592000, immutable`,
// letting the browser's own HTTP cache hold the media. On a desktop browser that is reasonable.
// On BrightSign it is not a guarantee: BrightSign documents persistence across reloads, app
// restarts and REBOOTS for IndexedDB, localStorage and SQLite only — the HTTP disk cache is not on
// that list, and their canonical answer for offline video is to cache the bytes explicitly. So a
// panel that lost its server could come back up with a playlist (that survives in localStorage) and
// no media to play, which is the exact failure signage cannot have.
//
// The service worker's Cache API is the mechanism this repo already trusts for exactly this: widget
// renders are cache-first precisely so a widget keeps rendering when the uplink is gone. This
// extends the same treatment to content.
//
// THE REASON CONTENT WAS SKIPPED IN THE FIRST PLACE IS REAL, and this module is what makes
// intercepting it safe: video elements issue RANGE requests when they seek, and naive caching
// breaks playback in two well-known ways.
//
// 1. Caching a 206 partial as if it were the whole file. A later full request then gets a
// fragment and the video is corrupt — worse than not caching, and it persists until eviction.
// 2. Answering a Range request with a 200 full body. Some media stacks accept it; others treat
// the mismatch as a fatal error and the video simply never plays.
//
// So: only ever STORE complete 200 responses, and when a Range request arrives, slice the stored
// body into a correct 206 ourselves. Both halves are pure functions here, tested without a browser.
//
// Dependency-free UMD: Node (require) + service worker (importScripts -> self.PlayerCachePolicy).
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.PlayerCachePolicy = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
/*
* Is this a request for playable content we should hold for offline use?
*
* Deliberately narrow. `/uploads/content/` is the media the playlist points at; everything else
* on /uploads (thumbnails aside) is dashboard-facing and not needed by a dark player. Non-GET
* never caches a POST is not a thing you can replay.
*/
function isCacheableContent(url, method) {
if (method && method !== 'GET') return false;
var path;
try {
path = typeof url === 'string' ? new URL(url, 'http://x').pathname : url.pathname;
} catch (e) {
return false;
}
return path.indexOf('/uploads/content/') === 0;
}
/*
* Parse a Range header against a known body size.
*
* Returns {start, end} INCLUSIVE, or null when the header is absent/unparseable (caller then
* serves the whole thing), or the string 'unsatisfiable' when the range lies outside the body
* which must become a 416, not a silent clamp, or a seeking player can loop forever asking for
* bytes that do not exist.
*
* Only single ranges are honoured. Multipart ranges are legal HTTP and essentially never used by
* media elements; answering one wrongly is worse than declining to, so those return null and fall
* through to the full body.
*/
function parseRange(rangeHeader, size) {
if (!rangeHeader || typeof rangeHeader !== 'string') return null;
if (!(size > 0)) return null;
var m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!m) return null; // multipart, or junk: serve whole
var startRaw = m[1];
var endRaw = m[2];
if (startRaw === '' && endRaw === '') return null;
var start, end;
if (startRaw === '') {
// Suffix form: "bytes=-500" means the LAST 500 bytes, not "from 0 to 500". Getting this
// backwards hands the player the beginning of the file when it asked for the end.
var suffix = parseInt(endRaw, 10);
if (!(suffix > 0)) return 'unsatisfiable';
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = parseInt(startRaw, 10);
end = endRaw === '' ? size - 1 : parseInt(endRaw, 10);
if (isNaN(start) || isNaN(end)) return null;
// A start at or past EOF is unsatisfiable. An END past EOF is not — it is clamped, which is
// what every media player relies on when it asks for "bytes=0-" style open ranges.
if (start >= size) return 'unsatisfiable';
if (end >= size) end = size - 1;
if (end < start) return 'unsatisfiable';
}
return { start: start, end: end };
}
/*
* Headers for the 206 we build from a cached full body. Content-Range must describe the ORIGINAL
* size, not the slice length a player uses it to learn how long the media is, and reporting the
* slice size makes a long video look like a fragment and stops seeking dead.
*/
function partialHeaders(start, end, size, contentType) {
var h = {
'Content-Range': 'bytes ' + start + '-' + end + '/' + size,
'Content-Length': String(end - start + 1),
'Accept-Ranges': 'bytes'
};
if (contentType) h['Content-Type'] = contentType;
return h;
}
/*
* Is a network response safe to STORE?
*
* A 206 must never be stored: it is a fragment, and storing it means a later full request is
* answered with part of a file. An opaque response (no-cors) has an unreadable body and a status
* of 0, so it cannot be validated or sliced. Both were the reason content caching was avoided;
* refusing them here is what makes it safe.
*/
function isStorable(response) {
if (!response) return false;
if (response.status !== 200) return false;
if (response.type === 'opaque' || response.type === 'opaqueredirect') return false;
return true;
}
/*
* Should we evict to make room? Callers pass current usage and the incoming size.
*
* The player's widget is created with a fixed storage_quota (1GB) and a full cache does not fail
* gracefully writes throw QuotaExceededError, and the failure lands on whichever item happened
* to be next, not on the biggest one. Keeping a headroom margin means eviction happens on our
* terms, in advance, instead of as a surprise mid-playlist.
*/
function needsEviction(usedBytes, incomingBytes, quotaBytes, headroomRatio) {
if (!(quotaBytes > 0)) return false;
var headroom = typeof headroomRatio === 'number' ? headroomRatio : 0.9;
return (usedBytes + incomingBytes) > (quotaBytes * headroom);
}
return {
isCacheableContent: isCacheableContent,
parseRange: parseRange,
partialHeaders: partialHeaders,
isStorable: isStorable,
needsEviction: needsEviction
};
});

View file

@ -1,4 +1,13 @@
const CACHE_NAME = 'rd-player-v18';
const CACHE_NAME = 'rd-player-v19';
// Content lives in its own cache so the shell can be re-versioned (the activate handler deletes
// every cache that is not CACHE_NAME) WITHOUT throwing away megabytes of media that are still
// perfectly valid. Rolling the shell used to mean a player re-downloaded its entire playlist.
const CONTENT_CACHE = 'rd-content-v1';
// Single source, shared with server/lib/player-cache-policy.js and its Node tests. A service worker
// cannot require(), so this is importScripts against the route that serves that same file.
importScripts('/player/cache-policy.js');
const POLICY = self.PlayerCachePolicy;
// Install: skip waiting to activate immediately
self.addEventListener('install', (event) => {
@ -9,7 +18,10 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
// CONTENT_CACHE is spared deliberately: it holds media, not code, and dropping it on every
// shell version bump would make each deploy re-download the whole playlist — over a link
// that may be exactly what is broken.
keys.filter(k => k !== CACHE_NAME && k !== CONTENT_CACHE).map(k => caches.delete(k))
)).then(() => self.clients.claim())
);
});
@ -72,6 +84,113 @@ self.addEventListener('fetch', (event) => {
return;
}
// Everything else (content files, API calls, etc.): don't intercept.
// Content files: cache the bytes so a player that loses its server keeps playing.
//
// This used to be left to the browser's HTTP cache (the server sends
// `Cache-Control: public, max-age=2592000, immutable`). That is fine on a desktop and is NOT a
// documented-persistent store on BrightSign, which guarantees survival across reboots for
// IndexedDB, localStorage and SQLite only. A panel could come back from a power cut with its
// playlist intact (localStorage) and no media to play.
//
// Range requests are the reason this was avoided, and POLICY is what makes it safe: we only ever
// STORE complete 200s, and slice them ourselves when a seeking video asks for a range.
if (POLICY && POLICY.isCacheableContent(url, event.request.method)) {
event.respondWith(handleContent(event.request));
return;
}
// Everything else (API calls, sockets, etc.): don't intercept.
// Returning without event.respondWith lets the browser handle it natively.
});
async function handleContent(request) {
const range = request.headers.get('range');
const cache = await caches.open(CONTENT_CACHE);
// Keyed WITHOUT the range header (Cache API ignores request headers by default), so one stored
// full body serves every range of that file rather than one entry per seek position.
const cached = await cache.match(request, { ignoreVary: true });
if (cached) {
if (!range) return cached;
const sliced = await sliceCached(cached, range);
if (sliced) return sliced;
// Unsatisfiable against the cached copy: fall through to the network rather than inventing a
// 416 that might be wrong if the cached copy is somehow stale.
}
try {
// A ranged request goes to the network as-is; storing its 206 would corrupt the entry, so this
// response is returned and deliberately NOT cached. The full copy arrives on a non-ranged
// request (the player's preloader issues one) and that is what populates the cache.
const response = await fetch(request);
if (!range && POLICY.isStorable(response)) {
const clone = response.clone();
// Not awaited: a slow write must not delay first frame. Failures are swallowed because a
// cache miss is a performance problem, and a thrown error here is a black screen.
storeContent(cache, request, clone).catch(() => {});
}
return response;
} catch (err) {
// Offline with nothing cached. A 504 is more honest than a 200 with an empty body — the player
// treats a failed media load as an item to skip, and an empty 200 would hang on a dead element.
if (cached) return cached;
return new Response('', { status: 504, statusText: 'Offline and not cached' });
}
}
/* Build a correct 206 from a stored full body. */
async function sliceCached(cached, rangeHeader) {
const buf = await cached.arrayBuffer();
const parsed = POLICY.parseRange(rangeHeader, buf.byteLength);
if (parsed === null) return new Response(buf, { status: 200, headers: cached.headers });
if (parsed === 'unsatisfiable') return null;
const body = buf.slice(parsed.start, parsed.end + 1);
return new Response(body, {
status: 206,
statusText: 'Partial Content',
headers: POLICY.partialHeaders(
parsed.start, parsed.end, buf.byteLength, cached.headers.get('content-type')
)
});
}
/* Store a full response, evicting oldest-first when the quota is close rather than waiting for a
QuotaExceededError to land on whichever item happened to be next. */
async function storeContent(cache, request, response) {
const len = Number(response.headers.get('content-length')) || 0;
try {
if (navigator.storage && navigator.storage.estimate) {
const { usage, quota } = await navigator.storage.estimate();
if (POLICY.needsEviction(usage || 0, len, quota || 0)) await evictOldest(cache, len);
}
} catch (e) { /* estimate is unavailable on some builds; proceed and rely on the catch below */ }
try {
await cache.put(request, response);
} catch (e) {
// Quota exceeded despite the check (or no estimate available). Make room once and retry — but
// only once, so a pathologically large item cannot spin evicting the whole cache.
await evictOldest(cache, len);
try { await cache.put(request, response); } catch (e2) { /* give up: playback still works live */ }
}
}
/* Cache API preserves insertion order, so the front of keys() is the least recently ADDED. That is
a rough proxy for least useful and is the only ordering the API exposes without tracking metadata
ourselves. */
async function evictOldest(cache, needBytes) {
const keys = await cache.keys();
let freed = 0;
for (const key of keys) {
const hit = await cache.match(key);
const size = hit ? Number(hit.headers.get('content-length')) || 0 : 0;
await cache.delete(key);
freed += size;
if (freed >= needBytes) break;
}
}

View file

@ -294,6 +294,69 @@ app.get('/player/schedule-eval.js', (req, res) => {
res.sendFile(path.join(__dirname, 'lib', 'schedule-eval.js'));
});
// Offline content-cache policy, imported by the service worker via importScripts and by the Node
// tests via require — one source, so the range arithmetic the player depends on cannot drift from
// the arithmetic that is actually tested. A service worker cannot require(), which is why this is
// a served file rather than a bundled one.
app.get('/player/cache-policy.js', (req, res) => {
res.type('application/javascript').setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(__dirname, 'lib', 'player-cache-policy.js'));
});
// BrightSign player-package self-update. The manifest and the bytes are read from the SAME built
// buffer, so a checksum can never describe a file the server is not actually serving — that
// mismatch is the classic OTA-loop condition (download, fail verification, retry, forever).
//
// Unauthenticated on purpose, exactly like /download/apk: a player fetches this before it has any
// identity, and the payload is the same public host script that ships attached to every release.
const bsPackage = require('./lib/brightsign-package');
const bsUpdate = require('./lib/brightsign-update');
// The SERVER decides, exactly as /api/update/check does for Android, so the rule lives in one
// tested place instead of being re-implemented in BrightScript where it cannot be tested at all.
// The host does only what it is told.
app.get('/api/brightsign/package', async (req, res) => {
const pkg = await bsPackage.getPackage();
res.setHeader('Cache-Control', 'no-cache');
// No package (a deployment without brightsign/, or an unreadable VERSION) is reported as a
// decision of "skip", not an error. A player that cannot be told about an update must keep
// running the one it has — an error here would otherwise be one more thing for the host to
// misinterpret at boot.
if (!pkg) return res.json({ action: 'skip', reason: 'package unavailable' });
const decision = bsUpdate.decidePackageUpdate({
currentVersion: req.query.version || null,
manifestVersion: pkg.version,
manifestSha256: pkg.sha256,
stagedSha256: req.query.staged_sha256 || null,
attempts: parseInt(req.query.attempts, 10) || 0,
allowPrerelease: req.query.allow_prerelease === '1'
});
res.json({
action: decision.action,
reason: decision.reason,
version: pkg.version,
sha256: pkg.sha256,
size: pkg.size,
url: '/api/brightsign/package/download'
});
});
app.get('/api/brightsign/package/download', async (req, res) => {
const pkg = await bsPackage.getPackage();
if (!pkg) return res.status(404).type('text/plain').send('package unavailable');
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', String(pkg.size));
res.setHeader('Content-Disposition', 'attachment; filename="autorun.zip"');
// The checksum rides along so a client that already has the manifest can verify without a second
// round trip, and so a proxy that mangles the body is detectable from the response alone.
res.setHeader('X-Package-Sha256', pkg.sha256);
res.setHeader('Cache-Control', 'no-cache');
res.send(pkg.buffer);
});
// BrightSign bridge, served from its single source (brightsign/st-bridge.js) so the copy the
// player loads can never drift from the one sitting on the SD card next to autorun.brs — the two
// are halves of one messageport contract, and a skew between them is exactly what would leave a

View file

@ -0,0 +1,83 @@
'use strict';
// The manifest and the download must describe the SAME bytes.
//
// Advertising a version whose checksum does not match the file actually served is the classic
// OTA-loop condition: the player downloads, fails verification, retries, forever. It is also the
// easiest mistake to make, because the natural implementation computes the manifest from one source
// (a VERSION file, a build record) and serves the file from another (a path on disk that some
// deploy replaced). These tests pin the invariant that makes that impossible here: one buffer,
// hashed once, read by both routes.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const pkgLib = require('../lib/brightsign-package');
test('THE OTA LOOP: the advertised checksum is the hash of the bytes that are served', async () => {
const pkg = await pkgLib.getPackage();
assert.ok(pkg, 'package should build from brightsign/');
// sha256 because that is what BrightScript's roMessageDigest can compute — a checksum the player
// cannot verify is an unverifiable package.
const actual = crypto.createHash('sha256').update(pkg.buffer).digest('hex');
assert.equal(pkg.sha256, actual, 'a mismatch here loops every player in the fleet');
assert.equal(pkg.size, pkg.buffer.length);
});
test('the package is byte-identical on rebuild — otherwise every deploy re-flashes the fleet', async () => {
// Zip entries carry timestamps. Left at "now" the archive changes on every server restart, the
// checksum changes with it, and every player decides it has an update waiting.
const first = await pkgLib.getPackage();
pkgLib._reset();
const second = await pkgLib.getPackage();
assert.equal(second.sha1, first.sha1);
});
test('the archive contains exactly the payload, at its ROOT with no wrapper directory', async () => {
// A player extracts to the storage root. A wrapper folder puts autorun.brs where the player never
// looks and the card silently does nothing — the failure mode is "blank screen", not an error.
const pkg = await pkgLib.getPackage();
const names = [];
// Minimal central-directory walk: entry names follow the 0x02014b50 signature at offset +46.
const buf = pkg.buffer;
for (let i = 0; i < buf.length - 4; i++) {
if (buf.readUInt32LE(i) === 0x02014b50) {
const nameLen = buf.readUInt16LE(i + 28);
names.push(buf.slice(i + 46, i + 46 + nameLen).toString('utf8'));
}
}
assert.deepEqual(names.sort(), pkgLib.PACKAGE_FILES.slice().sort());
for (const n of names) {
assert.ok(!n.includes('/'), `${n} must be at the archive root, not nested`);
}
});
test('autorun.brs and autozip.brs are both present — either missing is a dead panel', async () => {
// autorun.brs missing: nothing to run after extraction.
// autozip.brs missing: nothing extracts the archive in the first place.
assert.ok(pkgLib.PACKAGE_FILES.includes('autorun.brs'));
assert.ok(pkgLib.PACKAGE_FILES.includes('autozip.brs'));
});
test('THE BACK-DOOR LOOP: the shipped autorun.brs reports the version the manifest advertises', async () => {
// Ship it unstamped and the player applies the update, still reports the old version, and is
// offered the same package on every check — forever. The loop arrives even though the checksum
// was correct and the download was clean.
const unzipper = require('unzipper');
const pkg = await pkgLib.getPackage();
const dir = await unzipper.Open.buffer(pkg.buffer);
const entry = dir.files.find((f) => f.path === 'autorun.brs');
assert.ok(entry, 'autorun.brs must be in the package');
const text = (await entry.buffer()).toString('utf8');
const m = text.match(/return "([^"]*)"\s*' ST_PACKAGE_VERSION/);
assert.ok(m, 'the ST_PACKAGE_VERSION marker must survive — it is what the stamp anchors on');
assert.equal(m[1], pkg.version, 'stamped version must equal the advertised version');
});
test('the version comes from VERSION, so the manifest matches the release it shipped with', async () => {
const fs = require('node:fs');
const path = require('node:path');
const expected = fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim();
const pkg = await pkgLib.getPackage();
assert.equal(pkg.version, expected);
});

View file

@ -0,0 +1,148 @@
'use strict';
// This is the riskiest self-update in the product. An Android OTA that goes wrong leaves a player on
// the old APK. A BrightSign package update that goes wrong replaces THE SCRIPT THAT BOOTS THE
// PLAYER — there is no app underneath to fall back to, so a truncated or half-applied autorun.brs is
// a dark panel and a site visit.
//
// Every test here names a way that happens.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const U = require('../lib/brightsign-update');
const base = {
currentVersion: '1.9.28',
manifestVersion: '1.9.29',
manifestSha256: 'abc123',
attempts: 0
};
// ---------------------------------------------------------------- the prerelease scar
test('THE REVERT: a release must not overwrite the prerelease someone is testing', () => {
// 1.9.29-rc1 is semver-OLDER than 1.9.29. Without this the player asks "anything newer?", is
// correctly told yes, and wipes the build it was handed to test. That cost a reporter an evening
// on the Android side; here it would overwrite the boot script.
const r = U.decidePackageUpdate({
...base, currentVersion: '1.9.29-rc1', manifestVersion: '1.9.29', allowPrerelease: true
});
assert.equal(r.action, 'skip');
});
test('prerelease ordering is right: rc1 < rc2 < release', () => {
assert.equal(U.compareVersions('1.9.29-rc1', '1.9.29-rc2'), -1);
assert.equal(U.compareVersions('1.9.29-rc2', '1.9.29'), -1);
assert.equal(U.compareVersions('1.9.29', '1.9.29-rc1'), 1);
assert.equal(U.compareVersions('1.9.29', '1.9.29'), 0);
assert.equal(U.compareVersions('1.10.0', '1.9.99'), 1, 'numeric, not lexicographic');
});
test('a prerelease is never applied without opt-in, mirroring the Android beta channel', () => {
const r = U.decidePackageUpdate({ ...base, manifestVersion: '1.9.30-rc1' });
assert.equal(r.action, 'skip');
assert.match(r.reason, /opt-in/);
const opted = U.decidePackageUpdate({ ...base, manifestVersion: '1.9.30-rc1', allowPrerelease: true });
assert.equal(opted.action, 'download');
});
// ---------------------------------------------------------------- refusing to brick
test('THE OUTAGE: no reachable manifest means keep running, never wipe', () => {
// The common case during exactly the failure this whole feature is meant to survive.
const r = U.decidePackageUpdate({ ...base, manifestVersion: null });
assert.equal(r.action, 'skip');
assert.equal(r.reason, 'no manifest');
});
test('a manifest with no checksum is refused — unverifiable is the truncation risk', () => {
const r = U.decidePackageUpdate({ ...base, manifestSha256: null });
assert.equal(r.action, 'skip');
assert.match(r.reason, /checksum/);
});
test('THE TRUNCATED DOWNLOAD: a staged package that fails its checksum is re-downloaded, not applied', () => {
// Applying this overwrites autorun.brs with a partial file. The player boots into nothing.
const r = U.decidePackageUpdate({ ...base, stagedSha256: 'WRONG' });
assert.equal(r.action, 'download');
assert.match(r.reason, /failed verification/);
});
test('a staged package that verifies is applied — download and apply are separate gates', () => {
const r = U.decidePackageUpdate({ ...base, stagedSha256: 'abc123' });
assert.equal(r.action, 'apply');
});
test('THE RETRY LOOP: repeated failure on one version stops rather than burning the link forever', () => {
const r = U.decidePackageUpdate({ ...base, attempts: U.MAX_ATTEMPTS_PER_VERSION });
assert.equal(r.action, 'skip');
assert.match(r.reason, /too many failed attempts/);
});
test('attempts below the cap still try — one bad download must not park a player permanently', () => {
assert.equal(U.decidePackageUpdate({ ...base, attempts: 1 }).action, 'download');
assert.equal(U.decidePackageUpdate({ ...base, attempts: U.MAX_ATTEMPTS_PER_VERSION - 1 }).action, 'download');
});
// ---------------------------------------------------------------- idempotence / no loop
test('THE OTA LOOP: once current, the same version is never offered again', () => {
// Install, report the old version, get offered it again, forever — the classic loop. Equality is
// what breaks it, and it must hold for prereleases too.
assert.equal(U.decidePackageUpdate({ ...base, currentVersion: '1.9.29' }).action, 'skip');
assert.equal(U.decidePackageUpdate({
...base, currentVersion: '1.9.29-rc2', manifestVersion: '1.9.29-rc2', allowPrerelease: true
}).action, 'skip');
});
test('an older advertised version is ignored, so a rolled-back server cannot downgrade a fleet', () => {
const r = U.decidePackageUpdate({ ...base, currentVersion: '1.9.30', manifestVersion: '1.9.28' });
assert.equal(r.action, 'skip');
assert.match(r.reason, /older/);
});
test('a genuinely newer package is downloaded', () => {
assert.equal(U.decidePackageUpdate(base).action, 'download');
});
test('missing state is treated as "do nothing" rather than throwing in a boot path', () => {
// A throw here happens before the player starts; it must degrade, not explode.
assert.doesNotThrow(() => U.decidePackageUpdate(undefined));
assert.equal(U.decidePackageUpdate(undefined).action, 'skip');
assert.equal(U.decidePackageUpdate({}).action, 'skip');
});
test('holding a prerelease is NARROW: a newer core still lands on an opted-in player', () => {
// Otherwise "opt into testing" would quietly mean "never update again", which is how testers get
// stranded on a branch nobody maintains.
const r = U.decidePackageUpdate({
...base, currentVersion: '1.9.29-rc1', manifestVersion: '1.9.30', allowPrerelease: true
});
assert.equal(r.action, 'download');
});
test('a player that never opted in rejoins the release line from a stale test build', () => {
// It is not testing anything, so leaving it on an orphaned build is worse than updating it.
const r = U.decidePackageUpdate({
...base, currentVersion: '1.9.29-rc1', manifestVersion: '1.9.29', allowPrerelease: false
});
assert.equal(r.action, 'download');
});
// ---------------------------------------------------------------- the last gate
test('the apply gate demands a matching checksum', () => {
assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 50000), true);
assert.equal(U.isPackageSafeToApply('aaa', 'bbb', 50000), false);
assert.equal(U.isPackageSafeToApply(null, 'aaa', 50000), false);
assert.equal(U.isPackageSafeToApply('aaa', null, 50000), false);
});
test('THE CAPTIVE PORTAL: a tiny file is refused even if the hash is somehow satisfied', () => {
// A network that serves a login page in place of the download produces a small HTML body. It has
// a hash like anything else; what it does not have is a plausible size for a player package.
assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 800), false);
assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 800, 1024), false);
assert.equal(U.isPackageSafeToApply('aaa', 'aaa', 2048, 1024), true);
});

View file

@ -0,0 +1,149 @@
'use strict';
// Content bytes were never persistently cached. The service worker skipped `/uploads/content/` and
// leaned on the browser's HTTP cache, which is fine on a desktop and is NOT a documented-persistent
// store on BrightSign — they guarantee persistence across reboots for IndexedDB, localStorage and
// SQLite, and their own answer for offline video is to cache the bytes explicitly. A panel that lost
// its uplink could therefore come back with a playlist (which survives in localStorage) and no media
// to play it with.
//
// Intercepting content is only safe if range requests keep working, which is exactly why it was
// avoided before. Two failure modes make video WORSE than not caching at all:
//
// * storing a 206 as though it were the whole file — every later full request gets a fragment,
// and it stays broken until something evicts it
// * answering a Range request with a 200 — some media stacks treat the mismatch as fatal and the
// video never starts
//
// These tests pin both, plus the range arithmetic that a seeking player depends on.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const P = require('../lib/player-cache-policy');
// ---------------------------------------------------------------- what gets cached
test('content under /uploads/content is cacheable — that is the media a dark player needs', () => {
assert.equal(P.isCacheableContent('https://s.example/uploads/content/abc.mp4', 'GET'), true);
assert.equal(P.isCacheableContent('https://s.example/uploads/content/nested/x.jpg', 'GET'), true);
});
test('everything else is left alone, so API calls and sockets are never served stale', () => {
assert.equal(P.isCacheableContent('https://s.example/api/status', 'GET'), false);
assert.equal(P.isCacheableContent('https://s.example/uploads/thumbs/x.jpg', 'GET'), false);
assert.equal(P.isCacheableContent('https://s.example/player', 'GET'), false);
});
test('a non-GET is never cached — you cannot replay a POST', () => {
assert.equal(P.isCacheableContent('https://s.example/uploads/content/a.mp4', 'POST'), false);
});
test('a malformed URL is declined rather than throwing inside a fetch handler', () => {
// A throw here takes down the fetch handler and the page loses every request, not just this one.
assert.doesNotThrow(() => P.isCacheableContent('::::not a url::::', 'GET'));
assert.equal(P.isCacheableContent('::::not a url::::', 'GET'), false);
});
// ---------------------------------------------------------------- what gets STORED
test('THE CORRUPTION BUG: a 206 is never stored as if it were the whole file', () => {
// Storing a fragment under the full-file key means every later full request is answered with part
// of a video, and it stays broken until eviction. This is the single most damaging mistake here.
assert.equal(P.isStorable({ status: 206, type: 'basic' }), false);
});
test('an opaque response is never stored — status 0, unreadable body, unsliceable', () => {
assert.equal(P.isStorable({ status: 0, type: 'opaque' }), false);
assert.equal(P.isStorable({ status: 200, type: 'opaque' }), false);
});
test('errors and redirects are not stored, so an outage cannot poison the cache', () => {
assert.equal(P.isStorable({ status: 404, type: 'basic' }), false);
assert.equal(P.isStorable({ status: 503, type: 'basic' }), false);
assert.equal(P.isStorable({ status: 200, type: 'opaqueredirect' }), false);
assert.equal(P.isStorable(null), false);
});
test('a complete 200 IS stored — otherwise nothing is ever available offline', () => {
assert.equal(P.isStorable({ status: 200, type: 'basic' }), true);
assert.equal(P.isStorable({ status: 200, type: 'cors' }), true);
});
// ---------------------------------------------------------------- range arithmetic
test('an open range "bytes=0-" spans the whole body — the common video start', () => {
assert.deepEqual(P.parseRange('bytes=0-', 1000), { start: 0, end: 999 });
});
test('a closed range is honoured exactly', () => {
assert.deepEqual(P.parseRange('bytes=100-200', 1000), { start: 100, end: 200 });
});
test('THE SEEK BUG: a suffix range is the LAST n bytes, not the first n', () => {
// "bytes=-500" means the final 500 bytes. Reading it as 0-500 hands a seeking player the start of
// the file when it asked for the end — MP4 moov-atom probing does exactly this.
assert.deepEqual(P.parseRange('bytes=-500', 1000), { start: 500, end: 999 });
});
test('an end past EOF is clamped, because that is what open-ended seeks rely on', () => {
assert.deepEqual(P.parseRange('bytes=900-99999', 1000), { start: 900, end: 999 });
});
test('a start past EOF is UNSATISFIABLE, not clamped — clamping loops a seeking player forever', () => {
assert.equal(P.parseRange('bytes=1000-', 1000), 'unsatisfiable');
assert.equal(P.parseRange('bytes=5000-6000', 1000), 'unsatisfiable');
});
test('a reversed range is unsatisfiable rather than silently swapped', () => {
assert.equal(P.parseRange('bytes=800-100', 1000), 'unsatisfiable');
});
test('no header, junk, or multipart falls back to the full body instead of guessing', () => {
assert.equal(P.parseRange(null, 1000), null);
assert.equal(P.parseRange('', 1000), null);
assert.equal(P.parseRange('bytes=abc-def', 1000), null);
assert.equal(P.parseRange('bytes=0-99,200-299', 1000), null, 'multipart: serving one part would be wrong');
assert.equal(P.parseRange('items=0-10', 1000), null);
assert.equal(P.parseRange('bytes=-', 1000), null);
});
test('a zero-length body has no satisfiable range', () => {
assert.equal(P.parseRange('bytes=0-', 0), null);
});
// ---------------------------------------------------------------- 206 headers
test('THE DURATION BUG: Content-Range reports the ORIGINAL size, not the slice length', () => {
// The player learns how long the media is from this. Reporting the slice size makes a 90-minute
// video look a few seconds long and kills seeking entirely.
const h = P.partialHeaders(100, 199, 5000, 'video/mp4');
assert.equal(h['Content-Range'], 'bytes 100-199/5000');
assert.equal(h['Content-Length'], '100', 'length is the slice, inclusive of both ends');
assert.equal(h['Accept-Ranges'], 'bytes');
assert.equal(h['Content-Type'], 'video/mp4');
});
test('a single-byte range still reports length 1', () => {
assert.equal(P.partialHeaders(0, 0, 10)['Content-Length'], '1');
});
// ---------------------------------------------------------------- quota
test('eviction is decided BEFORE the write, not after a QuotaExceededError', () => {
// A full cache throws on write, and the throw lands on whatever item came next rather than the
// largest — so the player loses an arbitrary item mid-playlist. Deciding in advance keeps it ours.
const GB = 1024 * 1024 * 1024;
assert.equal(P.needsEviction(0, 10 * 1024 * 1024, GB), false);
assert.equal(P.needsEviction(GB * 0.85, 100 * 1024 * 1024, GB), true);
});
test('headroom leaves room to breathe rather than filling to the brim', () => {
const GB = 1024 * 1024 * 1024;
assert.equal(P.needsEviction(GB * 0.89, 1, GB), false);
assert.equal(P.needsEviction(GB * 0.91, 1, GB), true);
});
test('an unknown quota never triggers eviction — absence of a number is not a full disk', () => {
assert.equal(P.needsEviction(999, 999, 0), false);
assert.equal(P.needsEviction(999, 999, undefined), false);
});