# Changelog

## 1.9.34-alpha9

### 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 now 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 → Install statistics 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. Full detail in [docs/telemetry.md](docs/telemetry.md).

Off until enabled, and both answers are remembered — declining is permanent, so the prompt does not
return after an update.

The random ID exists only so repeat reports from one server count as one server. It makes a report
pseudonymous rather than anonymous, which the wording says plainly. Because sharing is opt-in, any
total published from this is a floor — "at least N screens" — never an estimate of the install base.

## 1.9.34-alpha8

### Fixed — an APK download with no external storage went nowhere, silently
A panel could not install an update by OTA **or** by the dashboard's "Push an APK" button. Both
staged the download in `getExternalFilesDir()`, which returns null when external storage is
unavailable — not exotic on signage hardware: no emulated volume, a vendor ROM that never mounts
one, an ejected card, storage still unmounted early in boot. A null parent silently produces a
*relative* path, so the download was written to the process working directory, which is not
writable. The write failed and the panel reported only "failed to download or failed signature
verification".

Every signal pointed away from the cause: the HTTP request succeeds (the server records a served
download at the exact second of each failure), the signing key is fine, and because nothing is ever
written there is no partial file to find. It also never recovers — every attempt fails the same way.

Downloads now fall back to internal storage, which cannot be unmounted.

⚠️ **This cannot repair a panel already affected.** The broken download path is the delivery
mechanism, and the Push APK button shared the bug, so a panel in this state needs one manual install
to escape it. The release protects panels that are not yet affected.

## 1.9.34-alpha7

### ⚠️ Upgrading to this build requires reinstalling dependencies

The two dependency changes below — dropping `sharp` and pinning `better-sqlite3` — alter
`server/package.json`, so **`npm ci --omit=dev` is required, not optional** — in both directions.
The runbook's rollback step marks that command "only if dependencies changed"; for this release
they did.

- **Upgrading**: `scripts/upgrade.sh` already runs it. A hand-rolled deploy that skips it leaves a
  `better-sqlite3` that no longer matches `package.json`.
- **Rolling back past this build**: the reinstall is **mandatory**. Earlier builds import `sharp` at
  runtime to thumbnail images, and this build removes it from the production dependencies — so
  rolling back the code without reinstalling leaves a server whose image ingest cannot load its
  decoder. `lib/preflight-deps.js` catches this at boot and repairs it, but do not rely on that as
  the plan.

Docker deployments need no action either way: dependencies are installed inside the image.

No migrations, no configuration changes, no player-side changes.

### Fixed — the Android player could leave a band down one edge of the screen
A panel would sometimes not fill its display: content rendered into a stage sized from a window that
no longer existed, 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, so a late
change to the usable area is picked up instead of being baked in at startup.

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 — an operations runbook
`docs/operations.md`. How to deploy, verify, and roll back an instance in both shapes it runs in
(git + systemd, and Docker), including what to back up first, how to tell a deploy actually worked,
and the traps that are only obvious once they have bitten you.

### Changed — image processing no longer uses a native module
`sharp` is gone. Thumbnailing and image measurement are pure JavaScript (Jimp) with WebAssembly
codecs for webp and avif, running on a worker thread. `sharp` remains as a development dependency
for test fixtures, so `--omit=dev` excludes it from a deployed install entirely.

The motivation is that a native module needs a prebuilt binary matching both the platform and the
Node ABI; when there isn't one, the failure arrives at load time and reads like database corruption
rather than a missing image library. `better-sqlite3` is now the only native module left.

Format support is unchanged in practice. jpeg, png, gif, tiff and bmp decode natively; webp and avif
via WebAssembly. `.heic` still produces no thumbnail — it never did, because the `sharp` builds in
use decode AV1 but refuse HEVC.

Images are decoded on a worker thread rather than in-process. Pure-JavaScript decoding costs about a
second for a 12-megapixel photo, which in-process would block the event loop — and the thumbnail
backfill walks an entire library at boot, which is exactly how a maintenance task turns into missed
heartbeats and players marked offline. Thumbnailing is slower in wall-clock terms than the native
library was, and no longer competes with serving requests.

### Changed — `better-sqlite3` pinned to 12.9.0
Preparation for a future Node 22 upgrade, landed separately so the runtime move and the database
driver move stay independently reversible.

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 in `engines`. A caret range would resolve to one of those and silently
turn installation into a from-source compile. `lib/preflight-deps.js` explains this at the point
anyone debugging the resulting failure would be reading.

Nothing in the query API changed — every major since 9.x was bumped only to drop end-of-life Node
and Electron versions.

## 1.9.34-alpha6

### Added — a setup guide for single sign-on
`docs/sso-setup.md`. The README explained what SSO is and listed the settings; it did not say where
to click, which of several plausible values to use, or what a given failure means. The guide walks
both the operator configuring Google or Microsoft for the whole instance and an organization admin
bringing their own provider, and ends with a table of every error the sign-in can produce alongside
what usually causes it.

Written from configuring real Google and Entra applications rather than from the code, so the
pitfalls in it are the ones that actually cost time — most of all that the Microsoft tenant setting
names the directory that *authenticates the user*, which for personal accounts is not the directory
the application is registered in.

## 1.9.34-alpha5

### Fixed — the Link button in Settings always said "Authentication required"
`alpha4` shipped account linking with a button that could never work. It navigated the browser
straight at the link endpoint, and this app's session is a token held in the page rather than a
cookie — so the request arrived with no credentials and was refused, every time.

The page now asks the server for the sign-in URL first, using its session, and follows that. Nothing
about the link itself changed.

## 1.9.34-alpha4

Two changes to how signing in works, both found by configuring real Google and Microsoft sign-in
rather than by reading the code.

### 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.
That refusal is right: otherwise anyone who could get a provider to assert your address would inherit
your account. But the way out — sign in with your password, then link from Settings — had never been
built, so it was a dead end. An account created with a password simply could not use single sign-on.

**Settings → Sign-in method** now offers it. An account with a password can link one of the
providers this server offers; an account on a provider can unlink back to a password.

An account has exactly **one** credential. Linking **deletes** the password, and the confirmation
says so plainly, 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, even briefly, with no way to sign in.

The account being linked is the one you are **signed in as** — never whichever account matches the
email the provider returns. That is what separates linking from the takeover the login page refuses.
Only the 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 now appears once you have entered your email address and continued, rather than
sitting there from the start. That is what lets the page ask whether your organization uses single
sign-on *before* offering you a credential: if it does, you are shown that instead of a password box
that was going to be refused. Correcting your address takes you back a step so the answer matches
what you actually typed.

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 IP's lookup budget before anyone
had tried to sign in.

Google and Microsoft buttons now stay visible throughout, including for organizations that require
their own provider. Such a login is still refused by the server; the page no longer changes shape
while you type.

## 1.9.34-alpha3

Completes what `alpha2` half-fixed. That release made the operator's own Microsoft button work and
left customers' Entra tenants broken, which is the wrong way round.

### Fixed — a customer's own Entra tenant was refused after its domain went green
An organization that brings its own Microsoft tenant publishes the DNS record, watches its domain
verify, and was then refused at login with `email_unverified`. Entra sends no such claim, and an
organization's provider was never allowed to assume one.

The refusal fired *after* domain confinement had already passed, so it was not the domain check doing
its job — it was a second check asking for something Microsoft does not emit. Requiring a claim a
provider cannot send is not a security control; it is an outage.

An organization's provider is now believed **once it has verified a domain**. The DNS proof is what
stands in for the claim: whoever controls a domain's DNS controls its mail, which is the same trust
that makes a verification link meaningful. A provider that has verified nothing still assumes
nothing, domain confinement is unchanged, and an explicit `email_verified: false` is still refused
from anyone.

The assumption follows from the proof and is never a stored setting — an organization cannot switch
it on for itself.

## 1.9.34-alpha2

Everything in `1.9.34-alpha1`, plus one fix without which the headline feature could not be used with
Microsoft at all.

### Fixed — Microsoft sign-in could never complete
The login callback required the identity provider to assert `email_verified: true`. **Entra ID v2
does not send that claim**, so a Microsoft login would authenticate correctly against the tenant and
then be refused on the way back with `email_unverified`. Found while configuring a real Entra
application, before a single sign-in was attempted; the SSO tests checked how the Microsoft issuer
string is built but never put a Microsoft-shaped token through the policy.

The strict check was itself a fix — an earlier version accepted an omitted claim — and it stays
exactly as strict for a provider a **customer** configures, because such a provider is chosen by the
party it vouches for and its bare assertion is worth nothing. What changed is recognising this as a
question about *who was trusted* rather than about what the token contained: an instance-wide
provider is chosen by the operator, and a Microsoft entry is pinned to a single tenant GUID, so only
that directory can issue a token this server will accept.

An explicit `email_verified: false` is still refused from anyone, and an organization's own provider
can never make the assumption. Google is unchanged — it does send the claim.

Other identity providers that verify addresses without saying so in the token can opt in with
`OIDC_<SLUG>_ASSUME_EMAIL_VERIFIED=true`.

### Fixed — documentation that would have cost you an afternoon
`MICROSOFT_CLIENT_SECRET` is read by the server but was missing from the README table. The redirect
URI must be registered under Entra's **Web** platform, not **SPA** — a SPA registration is rejected
at the token endpoint, because this exchange runs server-side and sends no browser `Origin`. And the
`email` optional claim has to be added under *Token configuration → ID*, or the token arrives with no
address at all.

## 1.9.34-alpha1

**A prerelease, for the alpha instance.** It is not a production build and no display should be
pulled onto it except deliberately: a prerelease sorts *below* its own release in semver, so a player
that takes `1.9.34-alpha1` without being opted in will see `1.9.33` as newer and roll itself back.
Opting a display in is what stops that.

The headline is single sign-on, rebuilt from nothing — and the reason it was rebuilt rather than
extended is a vulnerability in what was there before.

### Fixed — the old sign-in path could be replayed by any site you had signed into
What shipped as "OAuth" verified almost nothing. The Google path asked `tokeninfo` whether an
**access** token was valid and then trusted the email address in the reply. The Microsoft path handed
a bearer token to Graph `/me` and trusted that. Neither asked the only question that matters: *who
was this token issued for?*

So any other site a user had signed into — anything that had requested `email` or `User.Read` — held
a token it could replay 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 signature checked against the provider's JWKS 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 special
cases, which is what removed the two hand-written paths that were wrong.

### Added — organizations bring their own identity provider
Instance-wide providers stay the default and are now unlimited in number. On top of that an
organization may configure its own provider, but only for domains it has **proved it controls** — a
TXT record at `_screentinker-verify.<domain>`. An unverified claim lapses after eight hours and
releases the domain, so a typo cannot park someone else's domain indefinitely.

Proof by delegated name (CNAME) is refused outright. It would have required a wildcard zone we do not
operate, and worse, it would turn a subdomain takeover into an apex takeover.

**SSO-only** is available per organization: passwords refused, other providers refused, the instance
Google button refused. Turning it *off* again needs a platform admin to approve the request, so one
compromised org admin cannot quietly reopen password login. Break-glass for a platform admin is the
correct password and nothing else — and a wrong password returns the same 403 everyone else gets, so
it cannot be used to discover whether an account exists.

With no SSO environment variables set, the product behaves exactly as it did before. That was
verified in a browser, not merely reasoned about.

### 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 org-level switch to put it back, behind a modal that requires the operator to
type an acknowledgement, with a persistent banner while it is on. Enabling 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 have 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` was wired straight into `animation-duration`, so it set a fixed total time for the
whole strip to cross the screen regardless of length. 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 calibrates a constant pixels-per-second 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 actually reaches HTML
An audit pass over the frontend's HTML sinks, escaping the ones that receive user-controlled data.
Also in this release: 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.

### Known limitations in this alpha
Deliberately not resolved yet, and worth knowing before testing against them:

- Enabling SSO-only **clears the passwords** of members at verified domains. That is irreversible
  without a reset.
- The SSO-only removal queue is an availability dependency on the operator: if nobody approves, the
  organization stays SSO-only.
- `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/min, so a twenty-display install day takes four minutes of waiting.
  Pre-existing, unchanged by this release.

## 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/<gone>.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 `<video>` element.
- **`server/lib/sync-backend.js` — whose protocol a group runs.** `auto` picks BrightSign's native
  sync when every member is a BrightSign and ours otherwise. Native sync selected for a mixed group,
  or for players on different subnets, downgrades and reports why: BrightWall is multicast and
  cannot cross networks, and a half-synced group looks perfectly healthy on the dashboard while one
  panel drifts alone.
- **Boot from internal flash.** A player will run `FLASH:/autorun.brs` with no card present at all,
  confirmed on an XT245 whose microSD interface is physically dead. `StorageRoot()` probes for it and
  falls back to `SD:`, so a failed card slot no longer ends a player's life.

### Changed
- The web player restarts through a single `restartPlayer()` path instead of four separate
  `location.reload()` call sites. Off BrightSign the behaviour is unchanged.
- Storage keys carry a per-output suffix so a dual-output player's two widgets, which share an origin
  and one `localStorage`, cannot collapse into a single device row.

## 1.9.28

**Platform-wide QA sweep — 25 fixes.** Findings from an audit of the Android player, the browser and
Tizen players, and the server and dashboard, plus the issues a customer reported on #234 while
testing. Several are data-loss or isolation defects reachable by an ordinary user doing an ordinary
thing.

### Fixed — data loss and isolation
- **Saving a layout no longer destroys zone bindings and schedules.** Zones were deleted and
  re-inserted with the same ids, on the assumption that reusing an id preserved what pointed at
  them. It does not: SQLite runs referential actions on the DELETE, so every multi-zone playlist item
  was un-assigned (`ON DELETE SET NULL`) and every zone-bound schedule was permanently deleted
  (`ON DELETE CASCADE`). Nudging one zone by a pixel did this, and returned 200. Zones are now
  diffed; only genuinely removed zones are deleted, where those cascades are correct.
- **Schedules that outlive a deleted device group keep their workspace.** The conversion INSERT
  omitted `workspace_id`, so converted rows were invisible in the list and calendar, undeletable
  (403), and still fired every 60 seconds. A boot migration repairs rows already orphaned.
- **A saved device snapshot only applies inside the workspace it was taken in.** The lookup keyed on
  the hardware fingerprint alone, so a panel deleted from one workspace and paired into another
  inherited the first workspace's playlist and blocked flag.
- **Overlay pushes are held to the same write check as every other fleet action.** The three PiP
  routes carried only a token-scope check, which is a deliberate pass-through for dashboard sessions.
- **A schedule's zone is checked against the caller's workspace** — the one polymorphic reference
  missing from the existing validation.
- **Relayed playback progress is stamped with the authenticated device**, rather than trusting the
  id in the payload.

### Fixed — Android
- **Per-item scheduling works on Android 7.** `java.time` is API 26 and `minSdk` is 24 with no core
  library desugaring, so dayparting threw `NoClassDefFoundError` — an Error, which sailed past the
  deliberate fail-open guard, aborted the playlist update before content downloaded and then cleared
  the cache. Those panels sat on "waiting for content" and a reboot did not help. Verified on a real
  API 24 image.
- **A slow image decode can no longer strand a screen.** A remote image that finished after the
  playlist moved on mounted itself over the current item and called `exoPlayer.stop()`, landing in
  `STATE_IDLE` where no advance is ever scheduled.
- **The playlist and OTA checker stop when the Activity is destroyed.** Both kept running on the main
  looper, so every relaunch left a second controller reporting playback — inflating Reports — and
  another OTA checker whose install receiver was never unregistered.
- **A server rejection is handled once**, and a transient reclaim-settle hold no longer wipes the
  offline cache and jumps to pairing. Two handlers were assigned to the same callback; the later
  silently replaced the one that surfaces the server's reason.
- **Widget edits reach multi-zone layouts**, and a zone whose video fails recovers instead of going
  black permanently.

### Fixed — players
- **The web player notices layout and zone changes.** Editing zones or moving an item between them
  was judged "unchanged" and never reached the screen — the same defect fixed on Android, still live
  on web. Tizen already handled it.
- **Replacing the only item of a one-item playlist works.** The change was deferred until "the next
  advance", but single-item rendering deliberately never advances, so the old content played forever.
- **Leaving a sync group or a video wall re-renders** instead of freezing on the current clip.
- **A group-synced screen shows the idle card** when every daypart has closed, instead of looping the
  last in-window item out of hours.
- **The suspended-account card no longer breaks the player.** It replaced the status overlay,
  destroying an element every later status update wrote to — so the player reported itself crashed
  every few minutes and could be stranded on a stale card with no retry timer.
- **A zone video that fails now recovers** on web as well as Android.

### Fixed — dashboard and content
- **Editing a content item no longer rewrites types the dropdown cannot represent.** Opening a
  YouTube item and pressing Save turned it into an MP4 — a dead slide on every screen, and
  unrecoverable from the dialog.
- **Hand-written text widgets render at the size they were written.** Every `px` font size was
  converted to `vw` to rescue legacy designer output, including markup people typed themselves:
  `font-size:16px` became 2.8px on a 1080p screen.
- **Text taller than the screen is no longer silently clipped** — a text widget can now shrink to
  fit, scroll, or clip, defaulting to shrink (a no-op when the content already fits).
- **Eight dashboard views stop reporting success for refused requests.** Their local fetch helpers
  resolved on any status, so a 403 showed a success toast and the UI kept displaying a value the
  server had rejected.
- **Deleting a playlist tells the screens showing it**, rather than leaving the content up.
- **The onboarding checklist no longer counts a field no player reads**, which told operators
  "content assigned" while the screen showed "waiting for content".
- **An empty `device_info` no longer wipes 17 device columns.** The browser player's refresh-register
  sends `{}` every five minutes, and a blind full-row overwrite nulled version, resolution, OTA state
  and capability flags — degrading exactly the client family that cannot be inspected any other way.

### Fixed — scheduling
- **The calendar draws a recurring schedule on every day it actually fires.** A Mon-Fri rule drew as
  Mondays only, or as nothing at all if it had been created on a weekend, and a schedule started more
  than a year ago drew nothing — while the engine ran all of them correctly the whole time.
- **A recurring schedule respects its start and end dates.** The engine compared weekday and time
  only, so a campaign set to finish weeks ago kept running. ⚠️ This changes live behaviour: any
  recurring schedule past its end date will stop.
- **A content-only schedule now puts that content on the screen.** `content_id` was stored, validated
  and read by nothing, while the calendar drew a block labelled with the filename as confirmation. It
  now gets a playlist holding that item.

### Added
- **Per-display pre-release channel.** Publish `ScreenTinker-beta.apk` with a declared version
  alongside the stable APK and send it only to displays you choose; untick to move a display back.
  See the README.

## 1.9.27

**A real pre-release channel: publish a second APK and choose which displays get it.**
1.9.26 added a per-display opt-in, but it was passive — it stopped a sideloaded test build being
reverted, while the build itself still had to be installed by hand on every display. This makes the
opt-in mean something the server can act on.

### Added — beta channel
- **A second APK slot.** Put `ScreenTinker-beta.apk` beside the stable one and it is served only to
  displays with **Accept pre-release builds** ticked. Everyone else continues to get the stable APK,
  unchanged.
- **The beta build must declare its version**, in a sidecar `ScreenTinker-beta.apk.version` holding
  just the version (e.g. `1.9.27-rc1`). This is not optional and it fails closed: a beta with no
  declared version — or an unparseable one — does not activate the channel at all, and opted-in
  displays keep getting stable. The server cannot infer it (stable's version is the server's own
  constant because the two ship together, and reading it from the APK means parsing binary
  `AndroidManifest.xml` on the request path), and advertising a version that does not match the
  bytes served is exactly the condition that produces an update loop.
- **The check and the download resolve the channel identically**, and fall back to stable
  identically, so `apk_size` always describes the bytes actually delivered. An unrecognised channel
  serves stable rather than failing.
- **No player update is required.** The client already fetches whatever `download_url` the server
  hands back, so displays already in the field can be moved between channels from the dashboard.

### Fixed — switching back off a beta
- **Unticking the box now actually moves the display.** Stable is semver-*older* than the beta it
  replaces, so the ordinary "never offer a downgrade" rule stranded the display and unticking would
  have been another silent no-op. It is now offered the release build, reported as `channel-return`.
- The return requires **evidence that the display was actually served the beta channel**
  (`devices.ota_channel_served`, written once when it changes rather than on every check). Returning
  every non-opted-in display that happens to run a pre-release would have dragged existing testers
  back to stable the moment their server upgraded — the precise harm the opt-in exists to prevent.
  A tester who is ahead of the server on a build of their own is left alone, as before.

> **Cut beta builds with the same `versionCode` as the stable release they branch from.** Android
> refuses to install a lower `versionCode`, so a beta numbered above stable can be installed but
> never returned without uninstalling the app — which loses the display's pairing. Equal numbers
> install in both directions, and that is what makes switching back physically possible.

Verified end to end against a live server with two real signed APKs: stable served 1.9.26, beta
served 1.9.27-rc1, an unknown channel fell back to stable, deleting the version file deactivated the
channel mid-run, and the opt-in → serve → switch-back lifecycle produced `offer`, `up-to-date` and
`channel-return` in order.

## 1.9.26

**Android playback fixes for #234, and a way to hand someone a test build without it reverting.**
The YouTube fault below was not specific to the reporter: any playlist containing a YouTube item
stopped rotating at that item, on every Android display, indefinitely.

### Fixed — Android playback
- **A YouTube item now ends on its configured duration.** It never ended at all: images and widgets
  get a timer, ordinary videos end on playback completion, and a YouTube link is played by loading
  an embed into a WebView — which reports no completion, and had no timer armed for it. The item's
  `duration_sec` was passed to the player and never read. The web and Tizen players already timed
  YouTube off its duration; Android was the only player that did not, so this restores parity rather
  than inventing behaviour. Local and remote video are untouched and still end on completion, so
  clips are not cut short.
- **A playlist change is no longer stranded behind an item that never ends.** #157 defers a change
  when the item on screen is dropped from the new list, applying it at the next advance. With a
  YouTube item that advance never came, so assigning a different playlist appeared to be ignored.
  Two guards: an **empty** list is never deferred (clearing a playlist is an operator saying stop,
  not an item rotating out), and a deferral now has a 60-second deadline so no future item type that
  ends on a callback can strand one again.

Verified on an Android 12 emulator against the reporter's exact shape (a 5s image and a long YouTube
video set to 10s): thirteen clean cycles at exactly the configured durations, a playlist swap
applying immediately while the YouTube item was on screen, and a clear stopping playback entirely.

### Fixed — "No playlist" did nothing
- **A display's playlist can now actually be cleared.** The dashboard offered a *No playlist* option
  whose handler discarded the selection (`if (!newPlaylistId) return; // Don't allow deselecting for
  now`) — no request, no change, no error. The guard was honest about why: there was no way to do it.
  `PUT /devices/:id` has never read `playlist_id`, and `POST /playlists/:id/assign` can only set one.
  New `DELETE /api/devices/:id/playlist`, device-scoped because there is no playlist to authorize
  against when clearing, gated by the same ownership check as every other device mutation. Clearing
  an already-clear display is a no-op success, and the empty playlist is pushed to the device so the
  screen stops rather than holding the old content.

### Added — per-display pre-release opt-in
- **"Accept pre-release builds"**, a checkbox beside the existing self-update toggle
  (`devices.ota_beta`, default off). Handing someone a test build was a trap: a prerelease sorts
  *below* its own release (`1.9.25-fix234d` < `1.9.25`), so a sideloaded display asked for updates,
  was correctly told the release was newer, and updated itself straight back off the build it had
  been given — same versionCode, so Android installed it without complaint. Silent, within minutes.
  It cost the #234 reporter an evening of testing code that had already been replaced under them.

  Narrow where it should be: it holds only a prerelease of the core already installed. A plain
  release, a `-patchN` build, an upgrade to a newer core, and a display ahead of the server all
  behave exactly as before, and a fleet that never sets the flag is unaffected. Wide where it must
  be: an opted-in display is exempt from the `superseded-prerelease` guard, which would otherwise
  pin a tester on an old build permanently — opting in must never mean never updating again.

  Note this is an opt-out of being reverted, **not** a second distribution channel: the server still
  serves one APK, so a beta build is still installed by hand.

### Documentation
- The published API reference had drifted to **1.9.0** while 1.9.25 shipped, because
  `bump-version.sh` updated every other version source and not `docs/openapi.yaml`. It now does, and
  a contract test fails if the two diverge.
- A device's two network addresses are documented and told apart — `ip_address` is the public/WAN
  address the server observed on connect, `local_ip` is the display's own LAN address as reported by
  the player — along with the rest of the telemetry block, none of which was in the spec despite
  being returned. `wifi_ssid`'s `"permission"` value is documented as a sentinel, not a network name.
- README catch-up: the public API, why a display might not self-update, what a delete-and-re-pair
  restores (including that a block deliberately survives it), hidden plans, and the optional location
  permission behind the Wi-Fi network name.
- **CHANGELOG backfilled for 1.9.3 through 1.9.25**, which had no entries at all. `bump-version.sh`
  now warns when a release is cut without one.

## 1.9.25

**Android playback and account-admin fixes.** Closes #234 — a playlist that only ever showed its
first item — plus the registration loop feeding it, and three issues found by the same reporter in
an afternoon of testing.

### Fixed — Android playback (#234)
- **A playlist no longer restarts at item one every time the Activity is rebuilt.** `PlaylistController`
  is owned by `MainActivity`, so each rebuild handed it a fresh, empty instance; the playlist then
  arrived, looked like a first load, and playback began from index 0. On a device rebuilding at every
  item boundary the second item held the screen for ~135ms — invisible, which is why it read as "only
  one item plays" rather than "it glitches". Playback position now lives outside the object being
  rebuilt and resumes if the save is recent (cold starts, stale saves and shrunk playlists all still
  begin at item one).
- **A player no longer re-registers itself once per playlist item.** Every advance asked for a
  playlist refresh, and a refresh emits a full `device:register` — so a 10-second image re-registered
  six times a minute, per device, forever, each one running the whole identity path and pushing a
  playlist back down. The heartbeat already refreshes every 60s, so the per-item call was duplicating
  a pull that happens anyway. Measured on the reproduction: 9 registrations for 9 plays → 3.
- **A leaked callback no longer relaunches the app in a loop.** `ProvisioningActivity` left its
  service callbacks attached after pairing, so later events re-entered a finished activity and
  restarted it — a white flash on every cycle, since Android 12+ draws a splash screen on each
  relaunch. Measured: 240 activity starts in 180s → 0.

### Fixed — account administration
- **Unblock now sticks.** Per-device settings are keyed to the hardware and deliberately restore
  `blocked` across a delete + re-pair, so a block cannot be shrugged off by deleting the display.
  Unblock only ever cleared the live copy, so the saved copy put the block straight back on the next
  re-pair and there was no way out from the dashboard. Unblock now clears both; blocking still
  survives a re-pair, which is the property that made this worth getting right.
- **A refused device says why.** A blocked panel sat on "Connecting to server" with nothing surfacing
  the server's rejection.

### Added
- **Every plan is visible to platform admins,** with how many accounts, organizations and displays
  are on each, plus a flag for accounts pointing at a plan that no longer exists. A plan hidden from
  the public pricing page was previously invisible to the operator too.
- **Player permissions can be reviewed and revoked from the setup screen.** Each row stays visible
  once granted and becomes *Manage*, instead of disappearing and leaving no way back.

## 1.9.24

**OTA control for managed panels.** Everything here is about not stranding a display: an operator
override, a retry budget that reflects what a retry actually costs, and a stand-down that only fires
when it should.

### Fixed — OTA
- **Self-OTA now stands down only for a genuine foreign device owner.** The check was broad enough
  that a stock Android panel with no MDM at all logged "self-OTA stands down" and stopped updating.
- **`OTA_ALLOW_MANAGED_DEVICES`** lets an operator override the stand-down when they run an MDM that
  does not distribute the player. Off by default. See the README before enabling — it does not grant
  the ability to install silently.
- **The install retry budget went from 3 attempts to 40, and flagging moved to 3.** Telling an
  operator a panel needs a human and giving up on that panel are separate decisions, and they were
  wired to the same number. A retry is nearly free — the APK is downloaded and signature-checked once
  and reused from cache, so later attempts pull no bytes. Past the budget it settles to about one
  attempt a day, indefinitely; a new version clears the count.
- **"Force update" is now actually forceful, and reports back.** It ignores the back-off, the attempt
  count and the MDM stand-down, and says what happened — including "already up to date", which used
  to return in silence and made a working button look broken.

### Fixed — playback
- **A wipe hands the frame back cleanly at the end,** instead of briefly revealing the outgoing image
  through the transition surface.
- **Turning off follower mode re-arms self-advance,** so a display taken out of a synchronized group
  no longer freezes on whatever was on screen.

## 1.9.23

**Scheduling on a touchscreen, and internationalization.** The weekly calendar becomes directly
manipulable, and a large batch of user-facing strings that were never translated go through `t()`.

### Added
- **The week calendar is directly manipulable** — drag and resize blocks, with grab targets big
  enough for a finger, gestures that work on a touchscreen, pointer handlers bound once rather than
  per render, and a single-day view for when a week will not fit.
- **Schedules that run past midnight draw correctly.**
- **The calendar opens on the working day** and explains what it is for.
- **Empty states tell you what to do next,** based on what the account actually contains.
- **`MAX_FILE_SIZE` is parsed properly** (bytes or a `2GB` / `1500MB` suffix), and the README
  documents the reverse proxy and CDN limits that cap an upload independently — raising the app limit
  alone often changes nothing (#233).
- **An opt-in browser smoke test,** deliberately kept out of `npm test`.

### Fixed
- **Untranslated keys are no longer shipped as user-facing text.**
- **Teams says it is switched off** rather than showing an empty list.
- **Proof-of-play attributes a widget play to the widget that played.**
- **Members is in the nav,** titles reveal on touch, and a stale heartbeat no longer kills a live
  socket.
- **A player re-establishes a socket the server closed.**

## 1.9.22

**Player identity.** Two panels running the same build could collapse into one dashboard row.

### Fixed
- **Each player install gets its own identity.** The web player's fingerprint was derived from
  hardware characteristics alone, so two identical panels produced the same value and merged into a
  single device row. Identity is now per install.
- **A screen-only panel can clear its identity from the URL,** giving a way to split a panel that had
  already merged.
- **Crash reports record where a player crashed,** not only what it said.

## 1.9.21

**Measurement fixes.** Small, all about not lying in the numbers.

### Fixed
- Event-loop lag reports zero for a window with no samples, instead of a stale figure.
- Auth rate-limit rejections are recorded, so they can be measured rather than inferred.
- A device fingerprint is only stored against a device that still exists.

## 1.9.20

**Scheduling across a fleet, and alerting that does not repeat itself.**

### Added
- **Every screen's schedule on one calendar,** rather than one screen at a time.
- **A schedule is stored in the timezone its screen runs in,** so a fleet spanning timezones behaves
  the way an operator means it to.
- **An unpaired player can be recovered without a keyboard** — relevant on signage hardware with a
  remote and no text input.
- **A BrightSign capability probe.**
- Italian translation updated (#232).

### Fixed
- **One alert per outage** instead of one per dedup window.
- Kiosk style values are validated as CSS rather than as HTML.
- A device's OTA rate state is cleared once it proves its identity.

## 1.9.19

### Fixed
- Proof-of-play resolves content references rather than trusting a reported id.
- `sharp` updated to 0.35.x, and the corrupt PNG fixture that update exposed was repaired.

## 1.9.18

### Fixed
- **Device serialization is scoped to what each endpoint actually needs,** rather than returning a
  whole device row everywhere.
- **A solo widget stays mounted** and sizes its keyboard to the viewport.
- Weather-radar example: the map stays centred and bounded, and counts only the warnings on screen.

## 1.9.17

### Added
- **Self-service password reset.**

### Fixed
- **A pairing code expires on device liveness, not row age,** so a slow setup no longer runs out of
  time while the panel is sitting on the code.

## 1.9.16

**Hardening pass.** Findings from an internal auth/authorization review, described here in the same
neutral terms as the commits: this is a public repository and detail that only helps an attacker adds
nothing for an operator deciding whether to upgrade. Upgrade.

### Security / hardening
- Break-glass admin recovery is backed by a revocable, auditable grant.
- Access-gating six-digit codes are generated with a CSPRNG.
- The screenshot route is authorized against the device's workspace.
- Password login is bounded per account, not only per IP.
- The unauthenticated telemetry store is bounded and no longer writes rows.
- `CF-Connecting-IP` is trusted only from a Cloudflare peer, not from any trusted proxy.
- An upload's stored type is derived from file content, and uploads are never served as documents.

### Fixed
- The release tarball keeps `.env.example`, and CI asserts it is there.

## 1.9.15

### Fixed
- **Webpage widgets carry an honest note:** sites that refuse embedding do not work on a device, and
  no client-side signal can reliably tell "blocked" from "loading" (#230).
- The "Reload now" update toast is actually clickable (#229).

### Changed
- Session token resolution centralised across the manual verify sites; the unused `optionalAuth`
  middleware dropped.

## 1.9.14

### Fixed
- **Trial expiry auto-downgrade actually fires** (#228).
- 11 of 13 npm advisories resolved (lockfile only) (#225).

### Added
- Stripe checkout accepts promotion codes (#227).

## 1.9.13

**Content library.** A batch of workflow features for libraries bigger than a handful of files.

### Added
- Multi-file upload (#222), multi-select with batch delete and batch move (#224).
- Server-side search, type filter and sort (#221).
- Subtitle / caption support as a content property (#223).
- **Unstable-connection mode** — caps YouTube at 720p for weak Wi-Fi (#220).
- The Add Display modal shows the server URL, and `/download/apk` links GitHub Releases (#210).

### Fixed
- YouTube ENDED safety net for Shorts and flaky Android TV (#219).
- Visible D-pad focus stroke on the Android setup buttons (#218).
- Uploads respect the current folder (#211).

## 1.9.12

### Added
- **TOTP two-factor authentication** and **email verification on signup**.
- **Proof-of-play on Android and Tizen,** closing the Tizen parity gaps.
- Tizen SSSP install.
- Designer-made widgets can be edited in the designer again, including reconstruction of legacy ones
  (#207).

### Fixed
- Web player cold-start crash from a hoisting error in `renderSeq`.
- The advance timer is reconciled on group/wall mode transitions (#200, #208).
- Weather elements can switch to metric (#206).

## 1.9.11

### Added
- **Transition engine** — GL wipes across the web, Tizen and Android players, including image↔video
  transitions (#204).

### Fixed
- Android supersede wedge and leak, plus a stale-video guard on web and Tizen (#205).

## 1.9.10

**Directory board and widget stability.**

### Added
- Directory board: panel-ring scroll, in-place refresh, per-device frame diagnostic (#203), and
  JSON/CSV import with logo-replaces-title (#195).

### Fixed
- **A zero-duration widget no longer self-loops.** It pegged the Android main thread (#198), and the
  server now floors `duration_sec` so no player can be handed the condition (#199).
- Buffered widget swap and schedule-aware solo-board hold, killing the directory-board black flicker
  (#202); decode-gated image double-buffer does the same for Tizen stills (#193, #187).
- Directory board scroll stutter from a seamless-loop gap mismatch (#197).
- The cross-origin header is set on the route that actually serves content (#196).
- Modals scroll instead of overflowing the viewport (#194).

## 1.9.9

### Fixed
- **Pairing:** closed a deferred-offline reclaim race and made same-code adopt idempotent (#192).

## 1.9.8

### Added
- **Directory-search widget** — interactive search of a directory board, live-synced (#188).
- Dashboard version loading indicator with an immediate first poll (#181).

### Fixed
- YouTube Shorts render 9:16 instead of in a landscape frame (#184, #189).
- A stuck download back-off resets on content change and network reconnect (#170, #190).
- The soft keyboard appears for PIN/URL dialogs over immersive fullscreen (#191).
- Thumbnail images use `data-auth-src` in modals and views (#182), with hydration lazy by default
  (#185).
- Raspberry Pi setup handles both `chromium-browser` and `chromium` package names (#183).

## 1.9.7

### Added
- **SMTP transport** as an alternative to Microsoft Graph for email (#173, #179).

### Fixed
- **A reinstalled panel reclaims its device row** instead of being blocked (#180).

## 1.9.6

### Added
- **Device incident log** — offline cause, network-vs-reboot, display-sleep (#175).
- IndexNow and landing-page optimization (#177); integrations internal linking (#178).

### Fixed
- **Tizen portrait and flipped video via AVPlay hardware-plane rotation** — CSS rotation cannot touch
  the hardware video plane and produced a black screen (#170, #174).
- `/integrations/` is served explicitly so the nav link is not the login page.
- CI uses OS-assigned ephemeral ports for subprocess suites, ending a port-collision flake (#176).

## 1.9.5

**Group sync, device-owner foundation, and agency folders.** The largest release in the 1.9.x line.

### Added
- **Per-group synchronized playback** — every member of a group derives the same (index, position)
  from a server-disciplined clock and a deterministic schedule, so displays start and end each item
  together. Offline-native (no server needed at play time) and split-brain-proof (no leader role).
  Includes snap-on-load, a warm next-clip double buffer, and in-place duration edits (#167).
- **Device-owner tier foundation** — QR provisioning, content expiry, and the tier substrate the
  Tier-2 controls build on (#168).
- **Tier 0/1 system controls with no device-owner dependency** — volume, brightness and screen
  timeout on ordinary panels (#160, #169).
- **Per-token upload folder for agency tokens** — auto-created and subtree-confined (#158, #171).
- **OTA self-update kill switch** — global, per-device, and MDM auto-detect (#166).
- Dashboard version indicator with a GHCR update check (#165).

### Fixed
- **Rotation-aware media** — a portrait photo is upright on both the dashboard and the player
  (#170, #172).
- `bump-version.sh` handles the env-overridable Android version (#168).

## 1.9.4

### Added
- **Hidden settings menu on Android,** opened by a multi-tap BACK/ESC sequence and gated by a PIN;
  the PIN is server-provisioned per device and surfaced on the dashboard, replacing a hardcoded
  `0000` (#152).

### Fixed
- **A player sends its device id and token on reconnect before pairing** (#164).
- Android provisioning and playback robustness.
- Playlist `GET /:id` returns item schedules, so the editor shows them (#156).
- Draft preview runs in a server-side session to bypass CSP (#151).
- Tizen player wedge on a shared `#stage` (same class as #162).

## 1.9.3

**Liveness contract v4 and per-device settings that survive a re-pair.** Follows 1.9.2-patch3.

### Added
- **Exit-signal contract v1** — a player tells the server it is going away, across the server, the
  APK, the `.wgt` and the browser player, so "offline" can distinguish a clean exit from a
  disappearance. Surfaced in the dashboard as an offline annotation with a tooltip, a filter drill-in
  and a list label.
- **Liveness contract v4** — uniform heartbeat acknowledgement, ack-gap tracking, a throttle-aware
  client watchdog, browser lifecycle triggers, and an identity block, implemented across the server,
  the APK, the `.wgt` and the browser player. Includes a three-state dashboard liveness badge.
- **Per-device settings survive delete + re-pair,** keyed to the hardware fingerprint: a re-paired
  panel comes back with its name, orientation, timezone, notes and playlist already set (#150).

### Fixed
- **Legacy `-patchN` builds are treated as released versions,** so the existing fleet is offered
  updates.
- Tizen: watchdog config-proofing, teardown hygiene, dead-screen self-heal, offline snapshot,
  keep-awake re-assert and a suspend/resume handler.
- Dashboard: `device-detail.js` parse and runtime errors that took out the whole view; liveness badge
  filter regression; list-view legibility.
- CSP allows the Cloudflare Web Analytics beacon to load *and* report.

## 1.9.2-patch2

**Server/CMS-only field-safe net for #148 — NO Android APK, players unchanged.** Makes the
server absorb a device that opens duplicate/rapid sockets, so a thrashing PAIRED device
converges to ONE stable connection and stays online. It does **NOT** fix the client opening
duplicate sockets (the APK duplicate-socket root cause — separate track); **#148 is not closed
on this alone.**

### Fixed / hardened — eviction storm (#148)
- **Per-device session-settle debounce.** When a device_id with a LIVE incumbent socket opens
  another socket within a short window (`SESSION_SETTLE_WINDOW_MS`, default 2500ms), the
  duplicate is **soft-refused and the incumbent kept** — so a duplicate burst converges on one
  connection and the device stays online, instead of churning through evictions. This closes
  the gap the reconnect-throttle's **30s post-restart warm-up** leaves open (during warm-up only
  the hard ceiling applies, so a burst passed undamped and each new socket evicted the prior).
  The debounce is **warm-up-independent**.
- **Liveness safeguard:** the incumbent is only kept if its socket is genuinely live; a
  dead/half-open incumbent is replaced — the device is **never stranded offline**.
- **Soft refusal, never a quarantine** (paired-safe); single-session enforcement intact for a
  legitimate move; unpaired/abusive flapping still caught by the existing limiters.

Operational note: a chunk of the observed churn was the warm-up window **re-opening on every
rapid patch redeploy** — the debounce closes that in code, but reducing redeploy frequency
independently reduces warm-up-window exposure.

Server/CMS only; ships no APK (versionCode still increments so a future player build is
OTA-recognized). Docker: `ghcr.io/screentinker/screentinker:1.9.2-patch2` (pre-release —
`:latest` stays at 1.9.2).

## 1.9.2-patch1

**Server/CMS-only connection-lifecycle hardening for #148 — NO Android APK, players stay on
their current builds.** This strictly HELPS and de-risks, but is **NOT a guaranteed #148 fix**:
the MAXHUB client-side reconnect failure and the disconnect synchronizer (edge conntrack /
reporting) are separate, unproven-here tracks that may still require a client update / a Bold
Sophos-edge review — **do not consider #148 fully closed on this patch alone.**

### Fixed / hardened — connection lifecycle (#148)
- **The flap-limiter no longer quarantines legitimate PAIRED devices on reconnect churn.** A
  paired + authenticated device reconnecting is exempt from the 30-min quarantine escalation
  (a brief soft cooldown at most), so a repeated edge/NAT flush behind one SNAT IP can no
  longer be amplified into a self-inflicted fleet-wide lockout. Unpaired/abusive flapping is
  still quarantined (the attacker / unprovisioned-hammering case is unchanged).
- **Marking a device offline now also closes its socket**, so DB-offline can't diverge from
  socket-state into a silent half-open the client is never told about.
- **Faster half-open detection:** ping interval 30s → 15s (the pong TIMEOUT is kept at 30s so
  decode-loaded TV WebKits aren't falsely dropped) → dead-peer detection 60s → 45s on BOTH the
  server AND the client (the client inherits these via the handshake — **no APK needed**).
- **TCP SO_KEEPALIVE** on every connection so a half-open TCP can't persist indefinitely at the
  OS layer.

Server/CMS version only; ships no APK (versionCode still increments so a future player build is
OTA-recognized). Docker: `ghcr.io/screentinker/screentinker:1.9.2-patch1` (pre-release —
`:latest` stays at 1.9.2).

## 1.9.2

**⚠ Major internal hardening release (the "#146" rewrite) — large blast radius.** 1.9.2
rewrites the connection / maintenance / OTA hot paths to kill an event-loop death spiral,
plus adds usage-metering (billing) and web-player fixes. If you bisect a regression to the
1.9.x line, 1.9.2 is the big one. Core invariant introduced: **no synchronous op may block
the event loop for more than ~50ms**, ever. Every new subsystem has an env kill-switch.

### Fixed — maintenance / prune (the death-spiral root cause)
- **Non-blocking, chunked, per-device `device_status_log` prune.** The old whole-table
  `ROW_NUMBER` sort froze boot for 40–48s at ~1M rows → healthcheck fail → restart loop that
  wiped in-memory throttle state → the spiral. Prune is now per-device, indexed, batched with
  `setImmediate` yields (`lib/chunked-prune.js`), async, re-entrant, and band-gated on the
  interval run (the startup prune is intentionally un-gated so a bloated table self-heals on
  first boot without freezing it). All table-growth sweeps (status-log, play-logs,
  provisioning, telemetry, lag) route through the chunked helper. New index
  `idx_devices_provisioning`. **Measured worst-case event-loop gap under the storm harness:
  <300ms across 300k rows (was 40–48s).**

### Fixed — reconnect / flap
- **Per-device flap-rate limiter** (`lib/flap-limiter.js`): a device reconnecting faster than
  `CONNECT_RATE_MAX` (20) per `CONNECT_RATE_WINDOW_MS` (5min) is refused at the register gate,
  keyed via a **SNAT-safe identity chain** (device_id → fingerprint → token → one bounded
  global anon bucket) — **never by IP** (the whole fleet egresses one IP). After repeated
  trips a hard flapper is **quarantined IN-MEMORY for 30min and auto-clears** — it is NOT a
  durable DB block.
- **Operator block kill-switch:** `POST /api/devices/:id/{block,unblock}` + a dashboard
  button; the block check resolves the effective device_id via the identity chain so a
  device_id-less reconnect of a blocked device is still caught. Takes effect on next register,
  no restart.
- Also folded in: false-offline fixes (live-socket liveness beats a lagged heartbeat clock;
  evicted-socket re-arm race) and per-connection fail-fast so one device's handler throw can
  never exit the process.

### Fixed — OTA (SNAT-safe)
- `/api/update/check` early-returns before any filesystem call when there's no offer; APK
  metadata is cached. `/download/apk` gains a **band-aware** global concurrency + rate guard
  that sheds with **503 Retry-After only under elevated/critical loop-lag** — under normal
  band, downloads serve freely (a coordinated fleet rollout is never staggered when healthy).
  All limiting is global/aggregate — **no per-IP limiting** (SNAT).

### Added — telemetry / logging / observability
- Batched `event_loop_lag` inserts (buffered, flushed every 10s) and coalesced high-frequency
  logging (one summarized line per key per 30s; band *changes* stay immediate).
- **Throughput counters** (running total + last-completed-window) in the `/api/status` debug
  block so a flapper/flood shows on the server itself (`flap.refusedLastWindow` climbing while
  `band=normal` = the limiter absorbing it cheaply). The debug block is now **admin-toggleable**
  (Admin tab, persisted, no restart; default follows `STATUS_DEBUG_ENABLED`).
- **`devices_connected`** on `/api/status` (always-on): the live WS-socket count from the
  heartbeat connection map (NOT the lagging `devices.status='online'` column).

### Added — billing (usage metering)
- **Billable Screens** metering per the ByteTinker–Bold agreement — the contractual
  system-of-record. A durable daily rollup (`device_usage_daily`) is accumulated incrementally
  off the heartbeat tick from live presence (retention-independent), pruned chunked. Exposed on
  a **dedicated, admin-gated `GET /api/billing/usage`** route (NOT on `/api/status`; billing is
  revenue data). Readable via an **owner-minted, revocable `billing:read` scoped token**
  (`scripts/mint-billing-token.js`) that authorizes billing-read and nothing else, OR a
  platform-admin session. See [`docs/billing.md`](docs/billing.md).

### Fixed — web player
- **"Unchanged" refresh no longer drops the video.** On a reconnect the server re-emits
  `device:paired` while content is already playing; the player showed the idle "Waiting for
  content…" overlay unconditionally (covering live video; audio kept playing underneath) and
  the following "Playlist unchanged" left it up. Idle now shows only when genuinely idle, and
  an unchanged refresh is a strict no-op that leaves playback exactly as-is.
- Hardened `PlayerMediaHealth` call sites to guard by **method** (not object) so a stale-cached
  player module can't throw `shouldShowIdle is not a function` and abort a socket handler.

### Added — translations
- Italian (`it`) locale updated (#145).


## 1.9.2-beta1 — unreleased

### Fixed — server resilience (#142)
- **A single flapping device can no longer saturate the event loop.** A new
  load-aware, per-device reconnect throttle (`lib/reconnect-throttle.js`) gates
  genuine reconnects *before* the heavy register work (DB writes + playlist build).
  The verdict is per-device; global event-loop lag only multiplies an
  already-flagged device's backoff and never throttles a healthy one. Hard ceiling
  + cold-start warm-up so a full-fleet reconnect after a deploy is never throttled.
- **`device_status_log` growth is bounded.** Added
  `idx_device_status_log_device_ts`, a global retention sweep (`pruneStatusLog`,
  `STATUS_LOG_RETENTION_DAYS` default 3) covering removed/idle devices and the
  `offline_timeout` path, and de-duplicated the table's `CREATE TABLE`.
- **`content-ack` spam de-duplicated.** Repeated identical
  `(device_id, content_id, status)` reports are suppressed within
  `CONTENT_ACK_DEDUP_MS` (default 10s).
- **Provisioning cleanup window corrected.** Unclaimed provisioning devices are now
  swept after 24h (the code used `365 * 86400` — a year — contradicting its own
  comment).

### Added — observability (#142)
- **Event-loop lag telemetry** via `perf_hooks.monitorEventLoopDelay()`. Sampled to
  a bounded `event_loop_lag` table (indexed + pruned, `LAG_TELEMETRY_RETENTION_DAYS`)
  and surfaced on `/api/status` as `loop_lag` (mean/p50/p99/max + band).

### Maintenance
- Operators whose `device_status_log` is already bloated from a pre-1.9.2 deployment
  should reclaim disk with a **one-time manual `VACUUM`** in a maintenance window;
  retention now bounds further growth. Auto-VACUUM is intentionally not enabled.
  See [`docs/maintenance-device-status-log.md`](docs/maintenance-device-status-log.md).

## 1.9.1-beta3 — unreleased

### Fixed — Tizen player
- **#118 Sticky "Not authenticated" banner.** On TV sleep/wake the socket reconnects and
  a heartbeat could fire on the fresh, not-yet-registered socket; the server rejected it
  with `device:auth-error`, which the player showed as a *sticky* toast over still-playing
  content (and, worse, dropped its saved credentials and re-paired). Heartbeats are now
  gated on a per-connection `authenticated` flag (set only between `device:registered` and
  `disconnect`/`auth-error`), the heartbeat timer is stopped on `connect`/`disconnect`/
  `auth-error`, the stale banner is cleared on `device:registered`, and the `auth-error`
  toast is non-sticky so any transient case self-clears.
- **#119 `app_version` stuck at `1.0.0`.** The hardcoded constant made every Tizen device
  report `1.0.0` regardless of the installed `.wgt`. The version now resolves at runtime
  from `config.xml` via the Tizen application API, with a fallback constant that
  `build-wgt.sh` stamps from `config.xml`'s `version=""`.

### Added — Tizen player
- **Video walls (`wall:sync`).** The Tizen player now supports wall membership: when the
  payload carries `wall_config`, a new `WallController` positions the stage (vw/vh) as this
  screen's slice of the wall and drives the single-zone player as leader or follower. The
  leader broadcasts `wall:sync` at 4Hz; followers align their index and keep their video
  locked to the leader's clock with a latency-compensated drift controller (hard-seek past
  0.3s, gentle ±3% playbackRate nudge past 0.05s), and request an immediate position on
  (re)connect via `wall:sync-request`. Mirrors the web player (the Android player has no
  wall support). Per-tile `rotation` is not applied yet (web-player parity). Wall emits are
  gated on auth + connection so a pre-register tick can't trip `device:auth-error`.
- **Multi-zone layouts (Android parity).** The Tizen player now renders assigned layouts,
  not just fullscreen single-zone. A new `ZoneRenderer` (ports the Android `ZoneManager`)
  positions zones by percent geometry with `z_index`/`fit_mode`/background, groups
  assignments by `zone_id` (unassigned content goes to the first zone), and rotates each
  zone independently with the same per-item schedule gating (#74/#75). `app.js` selects the
  renderer from `payload.layout`; single-zone playback is unchanged. (Video walls
  `wall:sync` are still Android-only.)
- **#121 Remote commands.** Added a `device:command` handler (`refresh`, `launch`,
  `screen_on`, `screen_off`, plus honest no-op toasts for `update`/`reboot`/`shutdown`,
  which need B2B/MDM privileges a sideloaded app lacks). Removed the dead `device:reload`
  listener (the server never emitted it) in favour of `device:command` `refresh`.
- **#120 Dashboard preview.** Added `device:screenshot-request` / `device:remote-start` /
  `device:remote-stop`. Images capture for real; `<video>`/YouTube fall back to a status
  card because the TV's hardware video plane and cross-origin iframes can't be read into a
  `<canvas>`. See `tizen/README.md` for the support matrix.
- **#122 Updates / boot.** Documented the supported paths — `.wgt` re-sideload or URL
  Launcher/MDM refresh for updates, and display-level kiosk/URL-Launcher settings for
  auto-launch on boot (there is no in-app OTA or `config.xml` autostart for a sideloaded
  consumer TV web app).

## 1.9.0 — 2026-06-11

### Added
- **Per-playlist-item schedules.** Each playlist item can carry one or more schedule
  blocks — active days, a start/end time-of-day, and optional start/end dates. An item
  plays when the screen's local "now" matches at least one block; an item with no
  blocks always plays. Edit per item via the clock icon in the playlist editor (a badge
  summarises the schedule on each row).
  - **#74 dayparting:** time-of-day + day-of-week windows, including overnight windows
    that cross midnight (a Fri 22:00–02:00 block is active Sat 01:00).
  - **#75 auto-expire:** inclusive start/end dates; an item past its end date stops
    showing automatically — even on offline screens, because evaluation is on-device.
- All three players (web, Android, Tizen) evaluate schedules client-side against their
  own clock, so dayparting and expiry work offline. They share one evaluator contract,
  `shared/schedule-vectors.json` — 39 conformance vectors covering DST (US + AU),
  overnight-wrap day anchoring, timezone correctness, and date boundaries. CI runs the
  vectors against the JS evaluator (node) and the Kotlin port (Gradle/JUnit); the Tizen
  copy is byte-identical to the JS source and checked under node.
- Device detail now shows the screen's reported timezone and clock, with a **clock-skew
  warning** when the device clock differs from the server by more than 2 minutes (a bad
  device clock makes schedules fire at the wrong local time).

### Changed — device-level schedule timezone (behaviour change)
- Device/group **schedule overrides** (the existing calendar feature) are now evaluated
  in each device's effective timezone instead of the server's local time. Previously the
  `schedules.timezone` field was never applied and "07:00" meant the *server's* 07:00.
  Now "07:00" means the *screen's* 07:00 — which is what was intended.
  - **Who is affected:** self-hosters whose server timezone differs from their screens'
    timezone — their existing device schedules will shift to fire at the screens' local
    time. Single-timezone deployments (server and screens in the same zone) are
    unaffected. A device with no timezone set and not reporting one falls back to the
    server clock (unchanged from before).

### Fixed
- **#81 — release APK is now v1 + v2 + v3 signed.** With `minSdk 26`, the Android Gradle
  Plugin defaulted the v1 (JAR) signature *off*, producing a v2-only APK that some
  MDM-managed commercial signage (e.g. MAXHUB via the Pivot MDM) silently removes on the
  next reboot — so screens that power-cycle nightly lost the app and fell back to the
  setup screen. Setting `enableV1Signing = true` had no effect at minSdk ≥ 24; the release
  build now re-signs with `apksigner` and a low `--min-sdk-version` to emit the JAR
  signature alongside v2/v3. Verified to install and run on Android 14+/API 36 as well.

### Notes
- **Scheduling fails open.** If the on-device evaluator ever errors (bad timezone id,
  malformed block), the item **plays** rather than being hidden. A blank screen is worse
  than an over-running promo — this is a guarantee, enforced in all three players.
- Windows are enforced at **item boundaries**: a long item finishes before the schedule
  is re-checked, so it can overshoot its window by up to its own duration.
- **A single video *with a schedule* now re-renders at each loop boundary** so its window
  can be re-evaluated; seamless native looping still applies to unscheduled single videos.
  Deliberate tradeoff — a brief seam each loop for a scheduled lone video, in exchange for
  its daypart/expiry actually being honoured.
- **Re-publish required:** editing a schedule puts the playlist into draft; publish to
  push schedules to devices. Existing published playlists keep playing unchanged until
  re-published.
- Players that predate this release ignore the new fields and keep playing everything
  (graceful degradation) — update players to honour schedules.
