# Changelog ## 1.9.29 The release candidates 1.9.29-rc1 through rc5 are folded in here; the entries below record what changed since 1.9.28 in the form it actually ships. Two of these were found only by driving real hardware and a real browser, and neither could have been caught by a test in this repo. ### Fixed — the web player's offline cache was switched off at the URL everyone uses A service worker's default scope is its own directory, so `/player/sw.js` could only control `/player/` **and below** — which does not include `/player` itself, the URL the dashboard shows and the one panels are configured with. Registration succeeded, logged success, and then controlled nothing: no shell cache, no content cache, no offline playback, and no error to notice. ### Fixed — screens went black on a bad link instead of playing cached content The offline playback path was never the problem: the cache could never be **filled**. Every download began at byte 0 and the partial was discarded on any interruption, so an asset larger than one uninterrupted transfer was re-fetched forever. Downloads now resume, with `If-Range` and a 416 guard so a changed or over-long asset can never be spliced. ### Added — every player caches media for offline playback Tizen caches the media itself now, not just the playlist; the web player (and BrightSign) accumulate in resumable chunks driven by the playlist rather than by playback. Content carries a revision, so replacing an asset reaches displays that already hold the old bytes — previously it could not, ever. ### Added — players declare what they can actually do Each player reports its real capabilities at registration and the dashboard stops offering controls that cannot work. A display that declares nothing keeps its per-platform baseline, so nothing in the field loses controls on upgrade. ### Fixed — the BrightSign host scripts were written against Roku's API reference BrightScript is Roku's language and the two references read alike, so calls to objects that do not exist looked exactly like calls to ones that do. A string literal that stopped the script compiling, an existence check that could never return true, and a self-update path that could never mark a package applied — all corrected, and guarded by a checker, since nothing in CI can run BrightScript. ## 1.9.29-rc5 ### Fixed — the BrightSign host scripts were written against Roku's API reference BrightScript is Roku's language, the two references read almost identically, and nothing in CI can run either — so a call to an object that does not exist looked exactly like a call to one that does. Found by auditing against BrightSign's published reference after a consultant's deployment failed, and verified on an XT245. - **A string literal stopped the whole script loading.** `"{""width"":"` is not an escaped quote; BrightScript has no escape sequences, so it is three adjacent literals with no operator between them. The compiler rejects the entire file — `ScriptLoadError: Syntax Error (compile error &h02)` — which is not a broken feature but **no player at all**, on a display showing nothing. - **`MatchFiles` was called with a path as both arguments.** It takes a DIRECTORY plus a pattern and returns nothing when the pattern contains a separator, so the existence check could never return true for any file on any player. That is the reported failure: `no autorun.zip on any volume` printed while `dir SD:` listed it. It also silently disabled the entire self-update path. - **Roku objects that do not exist on BrightSign**, each quietly disabling a feature: `roFileSystem` (~20 sites — an update could never be marked applied), `roMessageDigest` (verification returned false unconditionally and burned the retry counter), `PostFromStringWithRetry` (a snapshot request raised "member function not found" from inside the event loop and took the player down). - **`Unpack()` deletes everything already in its target directory.** Unpacking an update to the volume root would have erased the player's provisioning and its whole content pool as a side effect of a routine upgrade. It now stages to a directory of its own and never overwrites `screentinker.json`. - Rotation moves to `SetScreenModes()` (`SetMode()` takes one argument) and fires only on a real change, because that call reboots the player. `server/test/brightscript-api-surface.test.js` guards all of it — a deny-list of Roku APIs plus the argument shapes and literal forms that compile and then do nothing. ### Fixed — a player that could not cache was telling the fleet it could A real BrightSign exposes `navigator.serviceWorker`, passes an `'serviceWorker' in navigator` check, and then never even fetches the worker: its runtime refuses to register one. It advertised `offline.cache` while unable to cache a byte. The capability is now claimed only when a worker is genuinely in control, and a refused registration reports itself to the server instead of a `console.warn` on a display nobody has a console for. ### Fixed — storage paths assumed a card slot that may not exist `StorageRoot()` knew only internal flash and SD. Fitting real storage to a flash-booting player and moving the deployment onto it resolved every derived path — the offline page, the widget's local storage, the update paths — to a slot with nothing in it. It now probes in the order the OS itself searches for an autorun script. The widget's `storage_path` is likewise an absolute path on the boot volume rather than a bare `/cache`, which carried no drive specifier and so had nowhere to persist. ## 1.9.29-rc4 ### Fixed — the web player's offline cache was switched off at the URL everyone uses A service worker's default scope is its own directory, so `/player/sw.js` could only ever control `/player/` **and below** — which does not include `/player` itself. The player is served at all three of `/player`, `/player/` and `/player/index.html`, and `/player` is the one that gets used: it is what the dashboard shows and what gets typed into a panel. On that URL registration *succeeded*, logged "Service Worker registered", and then controlled nothing at all: no shell cache, no content cache, no offline playback, and no error to notice. Registration now asks for scope `/`, and the server sends `Service-Worker-Allowed` to permit it. Both halves are load-bearing — without the header the registration does not narrow, it fails outright. Found by driving a real browser at the player; no unit test could have seen it, because the bug lived entirely in the relationship between a URL and a header. ### Fixed — screens went black on a bad link instead of playing cached content Reported from a one-bar 5G site. The offline playback path was never the problem: the cache could never be **filled**. Every download attempt started at byte 0 and the partial was deleted on any interruption, so an asset larger than one uninterrupted transfer was discarded and re-fetched forever — minutes of progress thrown away, back off, repeat. With nothing cached, the player showed its waiting state, which from across a room reads as a black screen. Downloads now resume: an interrupted transfer keeps its partial and asks for the rest with `Range`. Two ways that could corrupt a cache, both closed — `If-Range` makes a changed asset come back as a full body (restart) rather than a spliceable tail, and a partial longer than the asset is discarded on a 416. Bytes are kept only where they can be built upon: with no validator there is no safe resume, so the partial is dropped and the attempt backs off as the failure it is. ### Added — every player now caches media for offline playback - **Tizen** cached nothing but the playlist, so a panel came back from a reboot knowing exactly what to show and fetched every frame of it from a server that was not there. It now caches the media itself to `wgt-private`, resumable, with the transfer asynchronous so a stalled chunk cannot freeze the player. `offline.cache` is declared at runtime rather than assumed: a build with no writable private storage still says nothing. - **The web player** (and BrightSign, which runs it) stored only what a single `fetch()` happened to complete — nothing at all on a marginal link. It now accumulates in resumable chunks, driven by the player's playlist rather than by playback, so the prefetch does not compete with the video on screen for the same scarce bandwidth. ### Fixed — replacing an asset could never reach a screen that had already cached it `PUT /api/content/:id/replace` changes an asset's bytes under a stable id, and every player caches by that id — so the new bytes could not reach a panel that already held the old ones. Not "until the next refresh": never. Content now carries a revision, stamped onto each item at send time, and every player keys its cache on it. The same send-time refresh fixes a second bug: a replace writes a new randomly-named file and unlinks the old one, so the filepath baked into a published playlist snapshot pointed at a **deleted** file, and web panels 404'd on that item until somebody thought to republish. The route now also pushes to affected devices, which it never did. Superseded copies are reclaimed rather than left for the quota: the player declares the complete set of media it needs and the worker drops everything else. ### Added — capability declaration across all four players Each player declares what it can actually do at registration, and the dashboard stops offering controls that cannot work on that hardware. An absent declaration falls back to a per-platform baseline, so the displays already in the field keep their controls rather than losing them the moment this ships. ## 1.9.29-rc3 ### Fixed — autorun.zip could not be opened by a player Reported from a real automated deployment: the rc2 archive reached the player and was rejected as invalid. Two causes, both ours. - **The archive must be STORED, not compressed.** The player bootstrap extracts `autozip.brs` by itself before any script runs, and `roBrightPackage` supports a specific set of methods, of which "no compression" is the universally safe one. Both builders now store — and the server-side package builder had been using maximum deflate, so **every self-update package it produced would have failed the same way**, silently and in the field. - **`roBrightPackage`, not `roUnzip`**, is the supported reader. Converted in `autozip.brs` and in the self-update path. Both builders now assert the property instead of trusting the flag: the build script refuses a compressed member, and a test walks the archive's local file headers. A compressed package uploads, downloads and deploys perfectly and only then fails to open, which reads as a broken deployment rather than a broken zip. `autozip.brs` also adopts the shipped volume-discovery pattern — probe `USB1:`/`SD:`/`SSD:`/`FLASH:` for the archive rather than guessing, since a player may boot from internal flash. ### Fixed — muting never reached a YouTube item A YouTube item is a cross-origin iframe, so `el.muted` reaches nothing. The two browser-family players failed in opposite directions: the web player consulted autoplay policy and nothing else, so an item muted in the admin console **played with sound** and a wall follower blared alongside its leader; Tizen hardcoded `mute=1`, so YouTube there was **permanently silent** and no toggle could change it. Android was already correct. The rule now lives once in `server/lib/media-mute.js`, and the unmute prompt no longer appears on an item an operator deliberately muted. ### Fixed — screenshots reported success while sending blank frames Capture marked itself successful because the draw did not throw. On a hardware plane `drawImage(video)` returns a fully transparent image and throws nothing, so the dashboard showed a dead screen while the panel played perfectly. Capture is now proven by an alpha probe, so a genuine fade-to-black still reads as captured. ### Added - **BrightSign native synchronisation**, wired end to end and chosen per group, reusing the existing leader election. A group whose leader is offline falls back to the clock protocol rather than waiting for an announcement that never comes. - **Real telemetry and hardware identity** — temperature, player storage, model, OS version, serial and output index — instead of a block of nulls and a `wifi_ssid` of "Web Player" on a PoE appliance. - **Offline content caching** with correct range-request handling, and a **package self-update** whose version is stamped into `autorun.brs` at build time — unstamped, a player applies an update, still reports the old version, and is offered it forever. - **Command parity**: real `reboot`, real display blanking, and `set_volume` on BrightSign. ### Removed - The `user_agent` fallback in BrightSign detection. `devices` has no such column, so the branch was unreachable and passed only in a test that fabricated the field. ## 1.9.29-rc2 Fixes for three things rc1 only revealed once it was deployed and pointed at real hardware. ### Fixed - **The player assets 404'd in a container.** `/player/st-bridge.js` and `/player/st-sync.js` are served from `../brightsign` so the copy the player loads can never drift from the copy on the player's own storage — but the Dockerfile never copied that directory into the image, so both routes worked from a dev checkout and failed in Docker. Note how this fails when the route is absent entirely: the SPA fallback answers **200 with `text/html`**, so the browser gets a page where it expected JavaScript and the bridge silently never exists. - **A BrightSign kept re-pairing on every boot.** The bridge persisted `device_id` but not `device_token`. The server authenticates a claim to an existing display with the token, so an id presented without one reads as a brand-new player and gets a fresh device row. - **A BrightSign was labelled "Web Player".** It runs the same web player, so `client_type` is `player` and the device view fell through to a hardcoded label — indistinguishable from a browser tab, for a dedicated signage appliance. ### Added - **`autorun.zip` — a single-file player installer**, attached to every release and built by `scripts/build-autorun-zip.sh`. Drop it on the root of a player's storage and power-cycle. - **Booting from internal flash.** A player runs `FLASH:/autorun.brs` with no card present at all, so a failed card slot no longer ends a player's life. Confirmed on an XT245 with a physically dead microSD interface. ## 1.9.29-rc1 **BrightSign port.** The player on BrightSign is the ordinary web player running in an `roHtmlWidget` — that part already worked. This release adds the host around it, which covers what a page cannot do for itself, and a per-group choice of synchronisation protocol. Release candidate: cut for testing on alpha, not for production fleets. ### Added - **`brightsign/autorun.brs` — a supervised host, not a URL wrapper.** It owns the widget lifecycle, because a page-initiated `location.reload()` does not reliably bring an `roHtmlWidget` back: a deploy on 2026-07-28 reloaded every connected player and the BrightSign was the only one that never returned. The page now posts `{type:"restart"}` and the host rebuilds the widget. It also retries `load-error` with backoff, falls back to a local page, and runs a heartbeat watchdog that catches a page which loaded fine and then wedged — the case `load-error` never reports. - **`brightsign/st-bridge.js` — the page's half of that contract**, over `@brightsign/messageport`. Registry-backed identity (the registry outlives `localStorage` on this platform), restart-instead-of-reload, heartbeat, and sync-backend reporting. Every method degrades to a no-op off-platform, so it is served to every player rather than gated on a user agent. - **`brightsign/st-sync.js` — native SyncManager support.** Frame-accurate video sync between BrightSign players via `setSyncParams` on the standard `