# Changelog ## 1.9.35 A maintenance release. Two faults where the product was working correctly and still looked broken to whoever was standing in front of the screen, plus the dependency advisories that could reach a running server. No migrations and no configuration changes. See the upgrade note at the end of this entry. ### Fixed — a player could get stuck on an update it was never able to install A staged update is saved under a filename built from the version the server advertised. If a server advertised one version while still serving the file for an older one, the player saved the old file under the new name — and from then on found it, verified its signature, accepted it, and installed something that changed nothing. The version never moved, so the same update was offered again, and the player retried the same no-op until it hit its attempt limit. The signature check passed the whole time, correctly: the file was genuine, it was simply the wrong one. Worse, fixing the server did not help, because the bad file was reused before anything was downloaded. Recovery meant deleting the file on the device by hand. A staged update is now reused only when the version inside the file matches the version being installed, and a fresh download is checked the same way before it is applied. A server serving the wrong file now says so — *"server served 1.9.33 but advertised 1.9.34 — the update on the server is stale"* — and the file is deleted instead of kept. That makes this self-healing: once the server is corrected, the player recovers on its own. **Clear update cache** on the device page discards every staged update on a player. The version check should make it rarely necessary; it exists because a player already holding a bad file predates this release and cannot benefit from the check, and because the alternative is a cable and a laptop. ### Fixed — directory search showed the system keyboard on top of its own The directory-search widget draws its own on-screen keyboard, sized and themed to the panel and on by default. On Android it was never visible. The page puts the cursor in a real text field, which is the signal for the device to raise its system keyboard — over the bottom of the screen, exactly where the widget's keyboard is. So a wall-mounted directory showed the phone keyboard: split across the screen, with microphone, GIF, emoji and a settings key that opens the keyboard vendor's own interface on a kiosk. On one panel the only keyboard installed was voice input, so touching the search box opened a microphone. The widget's own keyboard had been underneath the whole time. When the widget draws a keyboard, it now tells the device not to raise one. Turn the built-in keyboard off and the system keyboard behaves as before — with nothing to cover, it is the only way left to type. ### Changed — the dependency advisories that could reach a running server are cleared Every high-severity advisory affecting a production install is resolved, including eight in the mail library covering SMTP command injection and header injection. The remaining advisories are in development-only tooling that is not installed on a server and cannot be reached from one. The real-time connection to players is deliberately untouched: the fix there was a patch to the message parser with no change to the format players speak, so nothing about an existing player's connection changes. Sending mail was previously covered only by tests that substituted the mail library for a stand-in, which would have stayed green through any change in the library itself. It is now also tested against the real one. ### Added — an install that collects statistics can show the total on its landing page Where install statistics are being collected, the landing page can show how many screens have been deployed in total. It is an aggregate across every install that chooses to report, so it says nothing about any single one. This does nothing on a normal install: the figure is served only where collection is switched on, so a private server never publishes its own screen count, and the line is hidden entirely rather than showing a zero. ### Changed — release notes are the written ones Published release notes now come from this file rather than from a list of commit subjects. The previous release announced itself as one commit titled "chore(release)" while the entry describing it sat here unread. ### ⚠️ Upgrading from 1.9.34 reinstalls dependencies This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in both directions. `scripts/upgrade.sh` already runs it, and the server repairs a missed install at startup where it can reach the npm registry. Docker deployments need no action; dependencies are installed inside the image. ## 1.9.34 Single sign-on is the headline, rebuilt rather than extended — because of a vulnerability in what was there before. Alongside it: the last native image dependency is gone, several players that could not install updates now can, and an install can optionally report how many screens it runs. No migrations and no configuration changes. See the upgrade note at the end of this entry. ### Fixed — the old sign-in path could be replayed by any site you had signed into What shipped as "OAuth" verified almost nothing. It asked whether an **access** token was valid and then trusted the email address that came back, never asking the only question that matters: *who was this token issued for?* Any other site a user had signed into — anything holding a token with the right scope — could replay it against ScreenTinker and receive a session as that user. No password, no interaction from the victim. Identity now comes from an **ID token only**, with the signature checked against the provider's keys and `iss`, `aud`, `azp`, `exp` and `nonce` all verified. One flow for every provider: Authorization Code with PKCE, completed server-side. Google and Microsoft became ordinary entries rather than hand-written special cases, which is what removed the two paths that were wrong. ### Added — organizations bring their own single sign-on Instance-wide providers stay the default and are now unlimited in number. On top of that, an organization can connect its own identity provider — Entra, Okta, Auth0, Keycloak, anything speaking OpenID Connect — configured by that organization's own admins in Settings, with no operator involvement and no restart. A provider may only assert addresses at domains the organization has **proved it controls**, via a TXT record at `_screentinker-verify.`. An unverified claim lapses after eight hours and releases the domain, so a typo cannot park someone else's domain indefinitely. A domain belongs to one organization only. Proof by delegated name (CNAME) is refused outright: it would need a wildcard zone we do not operate, and it would turn a subdomain takeover into an apex takeover. An organization's provider never appears publicly. The login page reveals it only after someone enters an address at a verified domain, so a guessed domain cannot confirm who your customers are. **Require single sign-on** is available per organization: passwords refused, other providers refused, the instance's own Google and Microsoft buttons refused — otherwise "requires SSO" would just be renaming the bypass. Turning it *off* again needs a platform administrator to approve the request, so one compromised org admin cannot quietly reopen password login. Break-glass for a platform administrator is the correct password and nothing else, and a wrong password returns the same refusal everyone else gets, so it cannot be used to discover whether an account exists. ⚠️ **Enabling it clears the passwords** of members at verified domains. That is not reversible without a reset. Entra sends no `email_verified` claim, which is why a Microsoft provider is trusted on other grounds: an instance-wide Microsoft entry is pinned to a single directory chosen by the operator, and an organization's own provider is believed once it has verified a domain — the DNS proof stands in for the claim, since whoever controls a domain's DNS controls its mail. A provider that has verified nothing assumes nothing, and an explicit `email_verified: false` is refused from anyone. Other providers that verify addresses without saying so can opt in with `OIDC__ASSUME_EMAIL_VERIFIED=true`. **With no SSO environment variables set, the product behaves exactly as it did before.** ### Added — an existing account can move to single sign-on Signing in with a provider has always refused to take over an account that already has a password, and that refusal is right — otherwise anyone who could persuade a provider to assert your address would inherit your account. But the way out had never been built, so an account created with a password simply could not use single sign-on. **Settings → Sign-in method** now offers it, in both directions. An account has exactly **one** credential: linking **deletes** the password, and the confirmation says so, because a password left behind is a second way in that you believe you replaced. Unlinking asks for the new password first and applies both changes together, so the account is never left without a way in. The account being linked is the one you are **signed in as**, never whichever account matches the address the provider returns — that is what separates linking from the takeover the login page refuses. Only providers configured on this server can be linked; an organization's own provider cannot attach itself to an account. ### Changed — the login page asks who you are before how you sign in The password box appears once you have entered your address and continued, rather than sitting there from the start. That is what lets the page check whether your organization uses single sign-on *before* offering you a credential, so someone whose company requires it is shown that rather than a password box that was always going to be refused. Correcting your address takes you back a step. The address is no longer looked up on every keystroke — it answered for half-finished domains, changed the form under you mid-address, and could exhaust a shared office network's lookup budget before anyone had tried to sign in. The instance's own provider buttons stay visible throughout, so the page no longer changes shape while you type. Setup instructions for both operators and organization admins are in [docs/sso-setup.md](docs/sso-setup.md), written from configuring real Google and Entra applications — including the one that catches everyone: the Microsoft tenant setting names the directory that *authenticates the user*, which for personal accounts is not the directory the application is registered in. ### Changed — image processing no longer needs a native library Thumbnails and image measurement are now pure JavaScript, with WebAssembly decoders for webp and avif, running on a worker thread. Nothing in the image path is a compiled binary any more, and `better-sqlite3` is the only native module left. A native module needs a prebuilt binary matching both the platform and the Node version; when there isn't one the server fails at load with an error that reads like database corruption rather than a missing image library. That class of failure is gone from this half of the product. Format support is unchanged in practice: jpeg, png, gif, tiff and bmp decode directly, webp and avif through WebAssembly. `.heic` still produces no thumbnail — it never did, because the image library in use decodes AV1 but refuses HEVC. Decoding moved off the main thread deliberately. Pure JavaScript costs about a second for a 12-megapixel photo, which in-process would stall everything else — and the thumbnail backfill walks an entire library at startup, which is exactly how a maintenance task turns into missed heartbeats and players marked offline. Thumbnailing is slower in wall-clock terms and no longer competes with serving requests. ### Fixed — players that could not install an update Three separate faults, each able to strand a player on an old version. **Updates were written to external storage.** Where that location is absent, or exists but cannot be written to, the download failed the instant it began — before any data arrived — and reported only that it had failed to download or verify. The same player could be caching content perfectly well throughout, because content goes to internal storage. Updates now go to the first location that genuinely accepts them, starting with internal storage, and each candidate is tested by *writing to it* rather than by asking whether it is writable — the previous check asked, was told yes, and the write failed anyway. **Prerelease versions were ordered as text**, so a build numbered 10 or higher sorted below one numbered 8 or 9. A player on such a build was told it was already up to date and could not be moved forward, while the server named the newer build as latest in the same reply. Numbers in version names are now compared as numbers. The BrightSign host package carried the same comparison and is fixed with it — there, a wrong answer replaces the script that starts the player. **A readable update was refused on Android 9 and 10**, where a downloaded file's signing certificate comes from a legacy path that can return nothing. The player now reads the signature itself before giving up. Verification is unchanged: the certificate is still compared against the installed app, and anything unsigned, tampered with, or signed by a different key is still rejected. A failed update now also says which of those things went wrong, instead of one message covering every possible cause. ⚠️ **A player already stuck cannot be rescued by this release**, because the broken path is how updates arrive and the "Push an APK" button used it too. Such a player needs one update installed by hand, after which it recovers on its own and stays fixed. ### Fixed — the Android player could leave a band down one edge of the screen A panel would sometimes not fill its display, leaving a bar the exact size of the hidden system bar. It was intermittent because it depended on whether the app was measured before or after the system UI was hidden — the same screen could come up correct after a reboot and wrong after an app restart. The stage is now measured from the current window and re-measured when focus changes. Reported on an RK356x Android box, where it was compounded by an unrelated HDMI mode problem; pinning the output resolution fixed the corruption, and this fixes the band that remained. ### Added — opt-in install statistics ScreenTinker cannot see how widely it is deployed, because self-hosted installs are private by design and should stay that way. A platform administrator is asked, once, whether this install will share how many screens it runs. The whole payload is three fields — a random instance ID, the version, and the screen count — and nothing else: no hostnames, addresses, organization or user names, device names, content or configuration. Settings shows the **actual payload this server would send**, generated live from its own data, alongside what it last really sent and when, so the claim can be checked rather than taken on trust. Turning it on reports immediately, and a blocked outbound connection is named along with the address to allow, rather than failing silently. Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not return after an update. `TELEMETRY_EXTRA_ENDPOINT` posts the same three fields to a collector you run; it is **additional, not a redirect**, and independent of the sharing switch, so an operator who wants their own numbers and nothing sent to us can set it and leave sharing off. The random ID exists only so repeat reports from one server count as one server, which makes a report pseudonymous rather than anonymous — the wording says so plainly. Because sharing is opt-in, any total published from it is a floor, never an estimate of the install base. Full detail in [docs/telemetry.md](docs/telemetry.md). ### Added — organizations may re-enable same-origin widgets, deliberately Widget isolation removed `allow-same-origin`, which also broke embedding for sites that enforce strict CORS. There is now an organization-level switch to put it back, behind a modal requiring a typed acknowledgement, with a persistent banner while it is on. It needs an organization owner or admin — a workspace admin is deliberately not enough — and the change is written to the activity log. Contributed by @ChrisChrome. The widget editor's **Preview is excluded** from that switch. Preview renders inside the dashboard where the admin's session token lives, so honouring the setting there would let anyone who can author a widget lift the session of whichever admin clicked Preview. The setting exists so *displays* can embed origin-strict sites; a display holds a device token, an admin's browser does not. ### Fixed — RSS tickers ran at a speed that depended on how much news there was Scroll speed set a fixed total time for the whole strip to cross the screen regardless of length, so a feed with twenty items was dragged past in the same seconds as a feed with one — too fast to read, and it appeared to jump back to the start. It now holds a constant rate, so more items simply take proportionally longer and every item scrolls fully into and out of view. Contributed by @ChrisChrome. ### Fixed — user-controlled text is escaped where it reaches the page An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data. Also here: dashboard banners no longer overlap the sidebar, shift the layout, or vanish when switching views, and the main content no longer collapses to a narrow column. ### Added — an operations runbook [docs/operations.md](docs/operations.md): how to deploy, verify and roll back an instance in both shapes it runs in, what to back up first, how to upgrade Node.js safely, and the traps that are only obvious once they have bitten you — including three from a Raspberry Pi 5 report, two of which are not Pi-specific. A piped installer cannot really ask you anything, because the pipe is its input and every prompt takes the default. X11 tools fail silently on Wayland, so screen blanking and cursor hiding can be entirely absent while appearing configured. And an overlay filesystem protects an SD card by discarding writes — safe for a player, quietly destructive for a server whose database is written continuously. ### Changed — `better-sqlite3` pinned to 12.9.0 Preparation for a future Node.js 22 upgrade, landed separately so the runtime and the database driver can move independently rather than as one flag day. The pin is **exact on purpose**. 12.9.0 is the last release publishing prebuilt binaries for both the current and the next Node major; later 12.x releases dropped the older one while still advertising support for it. A caret range would resolve to one of those and silently turn installation into a source build. Nothing in the query API changed. ### ⚠️ Upgrading from 1.9.33 reinstalls dependencies This release changes `server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in both directions. - **Upgrading**: `scripts/upgrade.sh` already runs it, and the server repairs a missed install at startup where it can reach the npm registry. - **Rolling back past this release**: mandatory. Earlier builds load a native image library at runtime that this release removes, so rolling back the code without reinstalling leaves a server whose image ingest cannot load its decoder. Docker deployments need no action either way; dependencies are installed inside the image. ### Known limitations Deliberately unresolved, and worth knowing: - Requiring single sign-on **clears the passwords** of members at verified domains, irreversibly without a reset. - Turning that requirement back off depends on a platform administrator approving the request; if nobody does, the organization stays on single sign-on. - `landing.html` still interpolates plan names into HTML without escaping. Those values come from the plans table rather than from end users, so it is a loose end rather than an exposure. - `/api/provision` is limited to 5 requests per minute, so a twenty-display install day involves some waiting. Pre-existing and unchanged by this release. ### Thanks This release — and a good deal of what came before it — exists because people outside the project reported problems and sent patches. Credit was recorded inconsistently at the time, so it is collected here rather than left scattered. **Code contributed** - **@ChrisChrome** — the organization-level widget sandbox toggle (#254) and the RSS ticker rate fix, both in this release. Earlier: the Debian player/server install script (#137) and web player auto-connect (#6). - **@BlazzzPlay** — eight merged pull requests across 1.9.4 to 1.9.13: server-side preview sessions to work around CSP (#151), the Android hidden settings menu (#152), sending device identity on reconnect before pairing (#164), the dashboard version indicator and update check (#165, #181), authenticated thumbnail loading (#182), the server URL in the Add Display modal and the Releases link on the APK download page (#210), and uploads respecting the current folder (#211). - **@a10kiloham** — boot-time thumbnail healing with ffmpeg diagnostics and packaging (#244), the screenshot-request verdict toast and the reverse-proxy header pitfall it documented (#243), and a configurable maximum upload size (#233). - **@albanobattistella** — the Italian translation, and its updates since (#2, #145, #232). **Reported** - **@carloblu74** — the Raspberry Pi 5 report behind #245, which found five defects in the installer and kiosk launcher that nothing in this repository would have caught, because nothing here had ever executed those scripts on a Pi. The runbook notes above come from it. - **@bold-media-group** — by a wide margin the largest source of field reports, across roughly fifty issues: the OTA rollout and version-advertising problems, event-loop lag under long uptime, video wall behaviour, Tizen playback regressions, and the content-loading failures that led to resumable downloads. - **@Smiley-k**, **@Semetra22**, **@patrickfinardi09**, **@hapishyguy**, **@Nikhil12656**, **@gittyguy92** and **@Obe-BoldMediaGroup** — bug reports and feature requests across the 1.9.x line, including SMTP transport, playlist item scheduling, and the Android playlist-order fault behind #234. Several of the hardest faults this year were found by someone running the product on hardware the project does not own. That is worth saying plainly. ## 1.9.33 A patch off 1.9.32. The headline is a boot-time crash that could brick a display permanently — a player that died on startup, every startup, and could not be recovered by rebooting it. The rest is the live debug log finally working on the web player, and the playlist-skipping bug that log found within minutes of being switched on. ### Fixed — a cached playlist could brick a display across reboots The most serious of these. On startup the player restores its **cached** playlist and renders the first item immediately. If that item was a video carrying a transition, it read an internal flag before that flag's declaration had run — which in JavaScript is a *throw*, not an empty value. The player died during boot. The loop is what made it fatal rather than annoying: the playlist came from the display's own local cache, so it never stayed up long enough to receive a corrected one. Every boot re-read the same cache and died the same way. **Rebooting the player — the one remedy an operator has — did nothing.** Recovery meant changing the player the server hands out; nothing in the dashboard would have helped. Found on a BrightSign, but nothing about it was BrightSign-specific: any browser-based display could have hit it. No customer display was in this state, and the one playlist that mixed video with a transition happened to start on an image, which was luck rather than protection. ### Fixed — one broken clip could skip several playlist items A media error scheduled a skip *per error event*, and each new skip orphaned the previous timer instead of cancelling it, so all of them fired. Four decode errors on one clip meant four advances. On a single-item playlist that merely replayed the same file, which is why it hid for so long; on a real playlist it silently dropped the next three items and nothing said why. One failure now means one skip. A clip that is still playable is no longer discarded on a stray event, while anything genuinely undecodable is still skipped, so a broken file can never stall a playlist. Failures also now report the actual media error instead of an anonymous "Video error". ### Added — the live debug log works on browser-based displays The per-device **Debug logging** checkbox has always sent its command, but only the Android player ever answered it. The panel opened on every other display and streamed almost nothing. It now streams what the player has always been recording internally: its own log, uncaught errors with file and line, failed downloads, and on BrightSign the host's boot report. Switching it on also **replays what was buffered before you opened it**, timestamped with how long ago each line really happened — so the failure you came to investigate is already on screen instead of needing to happen again. It matters most where there is no alternative: on a signage player there is no console to open and no cable to attach, and this is the only way to see what the display thinks it is doing. **Freeze** holds the view still while continuing to buffer underneath, because the moment you freeze a log is the moment the lines explaining it are still arriving. **Copy** puts the visible capture on the clipboard, stamped with the display and time, and works on self-hosted dashboards served over plain HTTP where the browser clipboard API is unavailable. Errors and warnings are now coloured, so the one line that explains the fault no longer sits in a wall of grey. ### Changed — display controls sit above the status panels Reboot, screen on/off, launch, force update and shutdown were flush against the status cards, which read as though they belonged to them. ## 1.9.32 A patch off 1.9.31. The headline is that a BrightSign can finally photograph its own screen; the rest is a thumbnail library that heals itself, a Raspberry Pi installer that asks the operator rather than the pipe, IPv6 on the dashboard, and a pairing code you can read from across a room. ### Fixed — a BrightSign can now screenshot itself, video included That platform has never managed it. Video decodes onto a hardware plane the DOM cannot read, so the player's in-page canvas composite came back with the content missing, and the panel truthfully but uselessly reported *"Video is playing on the hardware plane and cannot be captured"* while playing perfectly. It now uses **BrightSign's own `@brightsign/screenshot` API**, which composites the video and graphics layers — exactly the thing a canvas cannot do. The capture is written to RAM rather than the boot flash: the remote-control view asks for one every second, and a screenshot per second written to flash wears it out for nothing, since the file is read back and deleted immediately. Remote control gets it for free — the live view and the screenshot button share one capture path, so the live view now shows real video instead of a card explaining why it can't. The long way round is kept as a fallback for firmware without the module, and its own bug is fixed on the way: the host asked the player's diagnostic web server on a hardcoded port 80, while that port is configurable and commonly moved (the unit this was found on serves it on 8080 with nothing on 80 at all). It now reads the port from the registry the server is configured from. ### Fixed — per-item dayparting was silently dead on BrightSign A BrightSign widget runs with Node integration, which puts `module` into the page's scope. Every shared module that exported with an `else` therefore took the CommonJS branch and never assigned its browser global — and every consumer has a silent fallback, so nothing ever complained. The visible casualty was the transition engine, which is gated on exactly those globals and so never initialised. The costly one was `schedule-eval`: without it the player falls back to "always active", so **scheduled content played outside its window** on that platform, with nothing in any log. Modules now export to both targets. ### Fixed — thumbnails that never appear, and never retry Thumbnail generation is best-effort by contract, and three gaps made its failures invisible and permanent: ffmpeg is a system dependency nothing surfaced (and the Docker image did not install it), a row that missed generation was never retried, and a failed image thumbnail stored a path to a file that was never written — which the dashboard then requested forever as a broken image. There is now a `[MEDIA]` startup diagnostic, a once-per-boot backfill that heals old rows, ffmpeg in the runtime image, and the phantom path is gone. Video probing moved off the synchronous spawn it had always used: two subprocess calls with a 15-second timeout each, run synchronously, stop the whole server for their duration — survivable for one human-initiated upload, not for a sweep walking an entire library unattended. ### Fixed — Raspberry Pi 5 installer (#245) `curl … | sudo bash` makes stdin the *script*, and bash has consumed it by the time any prompt runs — so the mode menu answered itself and Player-Only could not be reached through the documented install at all. Prompts now read the terminal. Pi 5 on Bookworm defaults to Wayland, where `xset`, `unclutter` and `xrandr` are no-ops that log an error and do nothing: those Pis had no blanking suppression and no cursor hiding while appearing configured. The launcher now detects the session and branches. Chromium is told not to ask for a keyring password no kiosk can answer, and the crash-restore surface that put a white page over the player on every boot but the first is cleared properly. The login banner also spelled the product name wrong. ### Added — a display's IPv6 address on the dashboard The player only ever collected IPv4, so a v6-only panel reported no address at all and the dashboard showed a dash for a perfectly reachable screen. Both are now reported, in their own fields, because a dual-stack panel has both and either may be the one you need. Link-local addresses are excluded — every interface has one and none can be dialled without a zone index. ### Fixed — the pairing code was unreadable on 4K and 8K panels Every size on the player's setup screens was a hard-coded pixel value. A CSS pixel covers a quarter of the screen area on a 4K panel that it does on 1080p, and a sixteenth on 8K, so the code that fills a 1080p screen was a smudge on the wall it was installed on. Sizing is now proportional to the viewport: identical at 1080p, twice the size at 4K, four times at 8K. ### Fixed — the screenshot button lied when it could not work The server already answered `offline` or `unsupported`, but no dashboard sender listened, so clicking Screenshot on an offline display showed "Screenshot requested" and did nothing. The verdict now surfaces as a toast. Thanks to @a10kiloham for this and for the thumbnail work above. ### Fixed — CI judged the capability baselines against the wrong source The baselines describe what an un-updated display can do, so they are checked against the shipped source via a release tag. A shallow checkout has no tags, so the check silently fell back to the working tree — and a release commit made the newest tag HEAD, flipping every assertion at once. Both are fixed; the matrix is judged against the previous release. ## 1.9.31 A patch off 1.9.30 carrying the video-wall and playlist-preview work, a QA sweep that drove real browsers and real panels rather than reading code, and the fix for a loop stall our own maintenance was inflicting on a customer's fleet every morning. ### Fixed — a wall of portrait panels had to be built backwards (#236) The wall canvas was secretly framebuffer space, not the wall as you see it. That is invisible while every panel is the normal way up, and actively misleading the moment one isn't: two portrait-mounted panels standing side by side had to be **stacked vertically** in the editor, with a pre-rotated copy of every video, before the output came out right. It worked, but only after trial and error, and it meant a portrait wall could never reuse existing content. Each panel now carries a mounting rotation (0/90/180/270), the canvas means the physical wall, and the player works out the mapping — so side by side is drawn side by side and landscape content plays across portrait panels unmodified. Applied on the web, Tizen and Android players. **Existing walls are untouched and need no migration.** Every wall in the field is rotation 0, which takes the original code path verbatim — an operator who upgrades will not find a wall that was aligned yesterday has moved. Rebuilding an existing portrait wall the natural way round is an opt-in change the operator makes when they choose to. While a display is a member of a wall, its per-panel rotation replaces its own Orientation setting: the two describe the same physical fact, and honouring both turned the content twice. ### Added — a wall no longer hides its own screens (#235) Grouping displays into a wall replaced their individual cards, so one dead panel of a four-panel wall was invisible from the dashboard, and inspecting a single screen meant pulling it out of the wall (re-syncing the live wall) and putting it back. The wall screen now lists its panels with live online state and a link straight to each device's page, and the wall card on the dashboard shows a per-member status chip. A screenshot can be requested per panel without disturbing playback. ### Fixed — the checkpointer was stalling the event loop for seconds at a time (#240) Reported as loop lag that grew with uptime and reset on restart, with a distinctive signature: mean, p50, p99 and max identical to two decimal places. That signature is not a fixed cost paid on every cycle. It is what a `perf_hooks` histogram reports when a window recorded **exactly one** delay — the mean is the raw value and every percentile returns the bucket ceiling above it. Reproducible against the reported figures to the decimal (`record(1329070000)` gives mean 1329.07, p50/p99/max 1329.59). So the loop took one long turn that swallowed the whole sampling second, episodically. The long turn was ours, and it is measured rather than argued. Running the real checkpointer worker against a real WAL with one reader mid-transaction: a single main-thread write blocked for **4,936 ms**, and the checkpoint that blocked it reported `WAL 8.8MB -> 8.8MB` — it reclaimed nothing. `wal_checkpoint(TRUNCATE)` is the blocking form and its locks are held across *connections*, so moving it to a worker thread in 1.9.2-patch3 took the fsync off the loop but not the lock. It also does not throw when it cannot get those locks: it returns `busy=1` after sitting on SQLite's five-second busy timeout. Five seconds of stalled loop for no benefit, reported as a success. It was reached far too easily. The rule was "escalate if the WAL grew across three consecutive 15s runs", which **any sustained 45-second write burst** satisfies — a fleet powering on in the morning does it daily. Escalation now needs the WAL to be in the upper half of its budget (`WAL_CHECKPOINT_STARVATION_FLOOR_MB`, default 8) **and** to be outside a cooldown (`WAL_CHECKPOINT_ESCALATE_COOLDOWN_MS`, default 5 min). Both gates are needed: a size floor alone does nothing for a server whose WAL already sits above it, which was exactly the reported case. The 16 MB high-water escalation bypasses both gates and is untouched, so *the WAL still cannot grow unbounded*. A checkpoint that reclaimed nothing now says so in the log instead of reading like a success. Also softened the recovery path: when the checkpointer worker is declared unrecoverable, inline autocheckpoint is re-armed on the main connection — a state that lasts the life of the process, and therefore looks exactly like "degrades with uptime, a restart fixes it". It used to also run an unconditional blocking checkpoint on the main thread on the way in; that now happens only above the high-water mark, and the fallback state is served on `/api/status` rather than being inferable only from a log line that may have rolled. ### Added — loop-lag telemetry that can be read correctly (#240) The reported numbers were interpreted, reasonably, as a per-cycle cost, because nothing in them said how many samples they were made of. `/api/status` now carries `samples` alongside the percentiles (around 50 in a healthy second, 1 when a single turn swallowed it), `tick_gap_ms` measured on the wall clock independently of the histogram, and `worst_tick_gap_ms` / `worst_tick_at` — monotone, so five-minute polling can no longer miss an episode entirely. The `debug` block adds the checkpointer's worker, fallback and respawn state. Band semantics are deliberately unchanged: a one-sample window during a real stall is the correct trigger for the shed valve, and suppressing it would blind the protection at the moment it is needed. ### Fixed — `device_telemetry` grew forever for any display that stopped reporting The only trim was a per-device row cap applied on that device's own heartbeat, so a decommissioned, swapped or seasonally-dark panel left its rows behind permanently. There is now a matching age sweep (`TELEMETRY_RETENTION_DAYS`, default 30), per-device so it rides the existing index rather than scanning, chunked and yielding like the status-log sweep. The default matches the uptime report's own default window, so it cannot remove rows that report would have shown. ### Added — a playlist preview you can skip through (#239) Reviewing item 8 of a playlist cost seven durations of waiting. The preview takes a skip/next control. ### Added — a video playlist item defaults to the clip's own length (#237) Rather than the generic default duration, which had to be corrected by hand for every video. ### Fixed — the dashboard preview of a rotated display (#238) A display rotated 90°/270° was previewed the way its framebuffer is laid out rather than the way people see it. It now matches what the wall shows. ### Fixed — three controls that did nothing, and a parity matrix that said otherwise An audit of all four players against their shipped sources found controls a customer can press today that change nothing. The volume slider worked on Android only: the dashboard sends `set_volume { level: 0..1 }`, while the web player read `payload.value` and divided by 100 and Tizen read `payload.value ?? payload.volume` — three complete, working volume implementations that could not be driven. Correcting only the key would have been worse than leaving it broken, since `level: 0.5` would have become 0.5%: the scale is now chosen by which key arrived, not by the magnitude of the number. Tizen's offline media cache could never have worked on a panel — its adapter used the deprecated Filesystem API in three ways the IDL rules out, so `MediaCache.create()` returned null on every panel in the fleet. BrightSign carried calls that compile and are documented to do something else. Every fix cites the vendor document that proves it, and the linter now fails on each next time. Four dashboard→device socket handlers had no capability gate, and a re-register could erase a panel's recorded platform. The parity matrix and the capability baselines are now tested against the players' **shipped** sources in both directions, so a baseline that over-claims and a player that gains a handler without its baseline moving both fail the build. ### Fixed — a fresh panel skipped the first item of its playlist A newly paired panel always learns its playlist before the media arrives, so the 3-second content re-check is what really begins playback — and it advanced *past* the index already seeded for a playlist that had not started. The first pass ran 1, 2, 3, 0, and item 1 appeared only after the list wrapped. Reproduced on the emulator on every fresh pair. ### Fixed — a rotated wall panel screenshotted as a black rectangle The mounting rotation introduced with #236 is the first real rotation on an ancestor of the video surface, and the screenshot compositor pasted the frame with an axis-aligned rectangle — so on a rotated panel it landed outside the capture bitmap and the dashboard received plain black. A panel that looks dead while it is playing perfectly is the worst thing a diagnostic can say. ### Fixed — the service worker claimed credit for offline widgets it never sees `sw.js` said its cache-first widget branch was what kept a widget rendering with the network gone. It is not: the player mounts widgets in an iframe sandboxed without `allow-same-origin`, making it an opaque-origin client that a service worker does not control. Measured, not reasoned — five mounts over 25 seconds of real playback left zero widget entries in the cache. ### Fixed — CI judged the capability baselines against the wrong source The baselines describe what an un-updated display can do, so they are checked against the shipped source via the latest tag. The default shallow checkout has no tags, so the lookup found nothing and the suite silently fell back to the working tree — where a player's payload bug had just been fixed, making the build demand a baseline change for displays that cannot possibly have the fix yet. Green locally, red in CI, for a reason visible nowhere in the diff. The test job now fetches tags, and the bidirectional assertions skip rather than invert when there are none. ## 1.9.30 A patch off 1.9.29 carrying two fixes for faults that are live and silent. Both were found by a QA pass driving real browsers rather than by reading code, and both fail in the direction that leaves a screen dark with nothing in any log. ### Fixed — a missing media file answered 200 with the dashboard, cached for a month `express.static` calls `next()` on a miss and the only thing downstream was the SPA catch-all, so `GET /uploads/content/.mp4` returned **200 OK, `Content-Type: text/html`**, 15KB of `index.html`, under the `public, max-age=2592000, immutable` header the mount had already set on the way in. For a player that is the worst possible answer. Every downloader in this product treats 200 as success, so a panel stores the HTML page **as the video**, caches it for a month, and renders a black frame. Android's cache validates the byte COUNT against `Content-Length`, not the content type, so a correctly-sized page passes the integrity check and is promoted as a valid asset. It is reachable exactly when it hurts: a content replace writes a new randomly-named file and unlinks the old one, so any snapshot still pointing at the old name asks for a file that is gone. A miss now terminates in a 404 with no cache header — `immutable` is a promise about a file that exists. ### Fixed — an empty playlist wiped a display's entire offline library The player asks the service worker to hold its current media and to drop anything else. An empty list was honoured as "drop everything" — and `assignments: []` is what the server sends for a device between playlists, for a playlist never published, and from inside the `catch` when a stored snapshot fails to parse. Reproduced: three cached assets, one empty payload, cache emptied. That is only survivable while the uplink is up, which is precisely when the offline cache does not matter. A cache kept too long costs disk the quota reclaims anyway; one dropped at the wrong moment is a dark screen with no way back. An empty list is no longer a prune instruction. ## 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 `