mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Merge: BrightSign offline content caching and package self-update
This commit is contained in:
commit
9c04e2c113
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
118
server/lib/brightsign-package.js
Normal file
118
server/lib/brightsign-package.js
Normal 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 };
|
||||
158
server/lib/brightsign-update.js
Normal file
158
server/lib/brightsign-update.js
Normal 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
|
||||
};
|
||||
148
server/lib/player-cache-policy.js
Normal file
148
server/lib/player-cache-policy.js
Normal 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
|
||||
};
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
83
server/test/brightsign-package.test.js
Normal file
83
server/test/brightsign-package.test.js
Normal 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);
|
||||
});
|
||||
148
server/test/brightsign-update.test.js
Normal file
148
server/test/brightsign-update.test.js
Normal 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);
|
||||
});
|
||||
149
server/test/player-cache-policy.test.js
Normal file
149
server/test/player-cache-policy.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue