mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
BrightSign: cache content for offline, and let the package update itself
Two gaps that both end the same way — a panel nobody can fix without a van. OFFLINE. Content bytes were never persistently cached. The service worker skipped /uploads/content/ and leaned on the browser's HTTP cache, which is reasonable on a desktop and is not a documented-persistent store here: BrightSign guarantees survival across reboots for IndexedDB, localStorage and SQLite, and their own answer for offline video is to cache the bytes explicitly. A panel could come back from a power cut with its playlist intact — that lives in localStorage — and no media to play it with. The reason content was skipped is real, and player-cache-policy.js is what makes intercepting it safe. Seeking video issues range requests, and naive caching is worse than none: storing a 206 as the whole file means every later full request gets a fragment, and answering a range request with a 200 makes some media stacks fail outright. So only complete 200s are stored, and ranges are served by slicing the stored body into a correct 206. The content cache survives shell re-versioning, or every deploy would re-download the playlist over a link that may be exactly what is broken. SELF-UPDATE. The package can replace autorun.brs, so a truncated file is a dark panel with no app underneath. The safety is the ordering: download to .part, verify sha256 AND size, then delete the .done marker, rename, reboot. Marker first is not stylistic — leaving it makes the next boot skip the archive and the update silently never happens. A failed extract parks the zip as .bad instead of retrying every boot, which would be a loop indistinguishable from a hardware fault. sha256 because that is what roMessageDigest can compute; a checksum the player cannot verify is an unverifiable package. The decision lives on the server and is unit-tested, and the host only executes it — re-implementing the version comparison in BrightScript would put the prerelease trap somewhere untestable. That trap is honoured directly: a player on 1.9.29-rc1 is running something semver-OLDER than 1.9.29, so an opted-in player HOLDS a prerelease of its own core rather than being pulled off the build it was given to test. Narrowly — a newer core still lands, so opting in never means never updating again. Both loop conditions are closed by construction. The manifest and the download come from one buffer hashed once, so a checksum cannot describe bytes we are not serving. And the version is stamped into autorun.brs at build time by both builders, so the script reports the version it actually is — otherwise the player applies the update, still reports the old version, and is offered the same package forever. Failure always degrades to "keep running the old version": an unreachable manifest, a missing checksum, a failed verification, a full attempt counter and an unbuildable package all resolve to skip. 998 tests pass (was 954).
This commit is contained in:
parent
141deb97a5
commit
8fd6eb75d5
|
|
@ -190,6 +190,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