Commit graph

199 commits

Author SHA1 Message Date
ScreenTinker e5583e529e The Tizen baseline describes a fielded panel, not the one we are shipping
Two more corrections from the cross-player audit, both mine.

audio.volume removed: a fielded Tizen panel has NO set_volume handler — the
command falls through to "unknown command" and the dashboard slider does
nothing. One of the platform branches adds a handler, and those panels will
declare the capability for themselves once they run it; the baseline exists to
describe an un-updated display, so it must not borrow credit from a build that
has not shipped.

remote.screenshot and remote.stream added: both really are implemented in the
shipped player (captureAndSend, startStreaming). Omitting them would have hidden
working controls on every legacy Tizen display the moment gating went live —
the opposite failure, and the more damaging one.

That asymmetry is the thing to hold on to: over-claiming shows a dead button,
under-claiming removes a working one, and only reading the shipped code tells
you which you are doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:21:19 -05:00
ScreenTinker a08e6c3e06 Tizen does not have offline caching — correct the baseline
The platform audit caught my own contract lying. I gave the Tizen baseline
offline.cache; Tizen caches only the playlist JSON (st_payload_cache, in
localStorage) and has no service worker and no media cache, so the bytes still
come from the network and an outage leaves a panel holding a playlist it cannot
play.

That is exactly the claim this model exists to prevent, made by the model itself,
and it would have applied to every legacy Tizen panel — the ones that declare
nothing and depend entirely on the baseline being honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:15:34 -05:00
ScreenTinker 6bc709d2f7 The capability contract: what each player can actually do
Foundation for platform-native parity. The dashboard offered every control to
every display — a browser tab cannot reboot its host, a Tizen TV has no
device-owner concept, a BrightSign has no per-window brightness — so those
buttons did nothing, silently, and read as bugs. "UI that reports success and
changes nothing" is a recurring shape here; this ends it by letting the frontend
hide what a display cannot do.

The player DECLARES its capabilities at registration rather than the server
inferring them from a table, because only the player knows at runtime: an Android
device gains real screenshots when accessibility is switched on and loses Tier-2
commands when it is not device owner.

The trap this had to avoid is the opposite failure. Several hundred displays are
in the field declaring nothing, and none will update before the next dashboard
deploy — treating absence as "supports nothing" would strip the UI for the entire
fleet at once. So an ABSENT declaration falls back to a per-platform baseline,
while an EMPTY one is honoured as a player genuinely saying it can do nothing.
Those two cases are trivial to conflate and the difference is a dark dashboard.

Baselines carry only what has always worked on that platform. Anything
conditional — screenshots needing accessibility, kiosk needing device owner,
native sync needing one L2 network — is omitted, so a legacy display shows those
controls only once it declares them. A control that appears late beats one that
lies now.

Capability names are stable strings because they are persisted per device and
sent over the wire; renaming one silently disables a control on every display
still reporting the old name. An unknown name from a NEWER player is dropped
rather than invalidating the whole declaration.

1094 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 13:59:04 -05:00
ScreenTinker d205a49dfa The settings PIN can be rotated and set from the dashboard
It was generated once at pairing and never changed. On a fleet that makes it a
shared secret with no expiry: anyone who watches it typed once — an installer, a
contractor, someone filming a screen — keeps it for the life of the panel, and
the only way to take it back was to unpair and re-pair every affected display. A
customer asked whether it rotates, which was the right question.

POST /api/devices/:id/settings-pin takes { rotate: true } or { pin: "123456" },
and pushes the result to the panel over its socket immediately. The live push is
the part that matters: without it a new PIN would only take effect at the next
pairing, so an operator revoking a leaked PIN would believe access was closed
while the old one still opened the menu. The response reports whether the panel
actually took it, so an offline display is stated rather than assumed.

Validation is the security-relevant half and is pure and tested: six digits,
digits only, and a blocklist of the PINs people actually pick (repeats and
sequences) refused on explicit set and never produced by the generator. A PIN
that can be set to "0000" or left empty is a gate that is not there.

Generation uses crypto.randomInt rather than Math.random — this is a credential,
and a rotation requested BECAUSE a PIN leaked must not be predictable from
anything else. Leading zeros are padded, or roughly one PIN in ten would be five
digits and rejected by the on-device prompt.

Android applies it live via device:settings-pin instead of only at pairing. The
PIN is never written to a log on either side, and it stays out of device list
responses as before.

1084 pass; Android compiles.

Asked for by chris@chris-pc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 13:44:15 -05:00
ScreenTinker 604c390a55 Portrait on the web player was 420px off-screen — rotating a box does not move it
Reported as "rotation doesn't work correctly". It is a geometry bug, not a
rendering one, which is why it reads as mysterious.

#playerContainer is pinned `inset: 0`. Rotation set width:100vh, height:100vw and
rotate(90deg) — leaving the box in the TOP-LEFT corner and spinning it about its
own centre rather than the viewport's. On a 1920x1080 panel the content landed at
x -420..1500, y 420..1500 against a viewport of 0..1920, 0..1080: correctly
rotated, wrongly placed, cropped on two edges.

Tizen already did this correctly — top/left 50% plus translate(-50%,-50%) — and
Android does the equivalent with translationX/Y of (w-h)/2. The web player was
the odd one out, and BrightSign inherited it on top of its own hardware-plane
problem.

The rule now lives in server/lib/orientation-style.js, served to the player from
its single source, with the arithmetic pinned by tests that compute where the
rotated box actually lands on 16:9 and 5:4 panels. Three things those tests hold
that are easy to get wrong: the translate must come BEFORE the rotate (transforms
apply right-to-left, so reversing them rotates the correction too), 180 must NOT
swap dimensions (the box already fits; swapping letterboxes it), and landscape
must clear EVERY property the rotated state set (a half-reset leaves the
container stuck at 100vh wide, so rotation appears to persist after switching
back).

1074 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:58:50 -05:00
ScreenTinker 1c7d5f1359 Rotation on BrightSign must rotate the output, not the DOM
Audited rotation across all four players after reports it misbehaves.

  Android    native rootView.rotation + layout swap — the ExoPlayer surface is
             inside the rotated view, so video turns with it.
  Tizen      CSS for graphics AND AVPlay hardware-plane rotation for video. The
             code says why: a CSS-rotated <video> "blacks out" on Tizen.
  Web        CSS transform. Correct — a browser composites video in the DOM.
  BrightSign CSS transform only, inherited from the web player. BROKEN: with hwz
             enabled the video decodes onto a hardware plane the DOM cannot
             transform, so the images and widgets rotate and the video does not.
             A portrait panel plays sideways video.

BrightSign is the platform that does not rotate correctly, and Tizen had already
found the same wall from the other side — any platform compositing video below
the DOM needs rotation done at the output.

roVideoMode takes a transform (normal/90/180/270) and rotating the screen rotates
EVERY layer, because it happens below the compositor rather than above it. The
player now asks the host first and, when the host succeeds, clears its own CSS
transform — otherwise the graphics rotate twice while the video rotates once.

The host reports success rather than assuming it: if it cannot rotate, the CSS
path stands, which turns most of the content instead of none of it, and the
promise resolves false rather than never settling. A portrait panel showing
landscape content with no clue why is the outcome worth avoiding.

1066 pass. The BrightScript needs hardware to verify; the decision path does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:49:26 -05:00
ScreenTinker df3a2879fa Remote screenshots use the framebuffer, and an opted-in tester can move forward
Two things reviewed against the hardware.

REMOTE CAPTURE. An in-page canvas cannot read the hardware plane, so a
screenshot from a BrightSign is a composite with the video missing. The player
now asks the HOST, which uses the unit's own Diagnostic Web Server to capture the
real framebuffer, video included.

It has to run in BrightScript rather than the page for two reasons: the DWS is
http on localhost while the player is served over https, so the page would be
blocked as mixed content; and BrightScript is subject to neither CORS nor
mixed-content rules. Credentials are the documented default — user "admin",
password = the unit serial — which the host reads directly.

It requires PRIMARY STORAGE: the endpoint writes the full-size capture to disk
before returning a thumbnail, so a unit with no card or SSD answers "No primary
storage found." That message is passed through verbatim rather than swallowed,
and the canvas path still runs as a fallback, so a player with no disk keeps
producing the partial screenshot it can rather than nothing at all. Verified
against the real unit: the endpoint is reachable and blocked solely on storage.

THE STUCK TESTER. An opted-in player on 1.9.29-rc1 was told "holding prerelease
of the same core" when offered rc3 — so it would never move forward through
rc1 -> rc2 -> rc3, which is the opposite of what opting in is for, and would have
stopped our own XT245 ever receiving the next candidate.

The hold rule exists to stop a test build being dragged BACK to its release. It
now applies only when the advertised version IS that release: a newer prerelease
of the same core is offered normally, the release still cannot claw a tester
back, a newer core still lands, and a player that never opted in is still refused
a prerelease.

Also verified end to end on alpha: the advertised sha256 matches the served bytes
exactly, size matches, and every member of the package is stored.

1063 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:33:34 -05:00
ScreenTinker 30a71c1319 autorun.zip must be STORED and opened with roBrightPackage
A BrightSign consultant ran our v1.9.29-rc2 autorun.zip through BSN.cloud's
automated deployment. The archive reached the player and then could not be
opened — reported as invalid. Two causes, both ours.

1. COMPRESSION. We built with default deflate. 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: scripts/build-autorun-zip.sh passes -0, and the
   server-side package builder used archiver level 9 — maximum deflate — so
   EVERY self-update package it produced would have failed the same way, silently
   and in the field.

2. THE UNPACK API. We used roUnzip; BrightSign's own tooling uses
   roBrightPackage. Converted in autozip.brs and in the self-update path.

This is the failure mode worth naming: a compressed package uploads, downloads
and deploys perfectly, then fails to open on the player. It reads as a broken
deployment rather than a broken zip, so it gets debugged everywhere except where
the bug is. Both builders now ASSERT the property rather than trusting the flag —
the build script walks `unzip -v` and refuses a compressed member, and a test
walks the local file headers of the server-built package checking method 0.
Verified by negative control: re-enabling compression fails the test.

Also adopted the shipped volume-discovery pattern in autozip.brs — probe
USB1:/SD:/SSD:/FLASH: for the archive instead of guessing two volumes. The unit
that drove this port boots from FLASH because its card interface is dead, and
extracting to a volume that does not exist fails silently.

1056 pass.

Reported by giyokun, who was right about both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 11:36:18 -05:00
ScreenTinker cf1124d687 Mute reaches YouTube items — it never did, and failed opposite ways per player
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Muting was implemented three times and agreed nowhere. A YouTube item is a
cross-origin iframe, so `el.muted` reaches nothing; only the IFrame API can
touch it. Both browser-family players got this wrong, in opposite directions:

  web    playerVars.mute was `userHasInteracted ? 0 : 1` — autoplay policy and
         NOTHING else. An item an operator muted in the admin console played
         WITH SOUND, a wall follower blared alongside its leader, and the
         real-time device:mute-changed toggle only ever touched `<video>`.
         onReady then unmuted unconditionally, and the click-to-unmute overlay
         appeared on deliberately-muted items and undid the operator's setting.

  tizen  the embed URL hardcoded `mute=1`, so YouTube there was PERMANENTLY
         silent: the per-item flag was never read and nothing could unmute it.
         device:mute-changed did nothing at all, because it dereferenced a
         <video> that is null for a YouTube item.

Android was already correct and is unchanged — it is the reference here.

The rule now lives once, in server/lib/media-mute.js, served to the web player
from its single source the same way schedule-eval.js is, and mirrored in Tizen
(which ships inside the .wgt and cannot import it). The ORDER is the substance:
a wall follower is always silent (one wall, one audio source) > autoplay policy,
which is a hard constraint rather than a preference because unmuted playback
without a gesture is refused outright and costs the VIDEO > a live operator
toggle, who is looking at the screen > the item's stored flag.

shouldOfferUnmute() exists so the prompt only appears when a gesture is the ONLY
thing in the way. Prompting on a muted item trains viewers to click a button
that undoes an operator's decision.

Tizen gains enablejsapi + a postMessage bridge so a live toggle flips the embed
without reloading it — reloading would restart the video from zero every time
someone touched the control.

11 new tests pinning each precedence step separately; 1055 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:50:59 -05:00
ScreenTinker 4ed7954f84 Drop the user-agent fallback — it could never fire
isBrightSignDevice() fell back to device.user_agent to catch panels paired
before this port existed, which registered as "Chrome 120" with a BrightSign
user agent. `devices` has no user_agent column, so the field is always undefined
on a row read from the database. The branch was unreachable in production and
passed only in a test that fabricated the field — which is precisely how dead
code survives review.

Two agents flagged it independently while working on unrelated areas, and the
schema confirms it: zero matches for user_agent in the devices table.

Those pre-port panels are recognised the moment they re-register on a build
carrying the host, which every one of them gets on its next update. Identifying
them sooner would mean persisting the user agent, and a column added solely to
track a population that disappears on its own is not worth carrying.

The test now asserts the honest behaviour: a fabricated user_agent does NOT
create a match, and a group containing such a panel reads as mixed until it
re-registers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:30:10 -05:00
ScreenTinker 039511b988 Merge: prove screenshot pixels arrived instead of assuming the draw worked 2026-08-05 10:23:42 -05:00
ScreenTinker 9c04e2c113 Merge: BrightSign offline content caching and package self-update 2026-08-05 10:20:50 -05:00
ScreenTinker 16b3dd949c Merge: BrightSign real telemetry and hardware identity 2026-08-05 10:18:06 -05:00
ScreenTinker 8fd6eb75d5 BrightSign: cache content for offline, and let the package update itself
Two gaps that both end the same way — a panel nobody can fix without a van.

OFFLINE. Content bytes were never persistently cached. The service worker
skipped /uploads/content/ and leaned on the browser's HTTP cache, which is
reasonable on a desktop and is not a documented-persistent store here:
BrightSign guarantees survival across reboots for IndexedDB, localStorage and
SQLite, and their own answer for offline video is to cache the bytes explicitly.
A panel could come back from a power cut with its playlist intact — that lives in
localStorage — and no media to play it with.

The reason content was skipped is real, and player-cache-policy.js is what makes
intercepting it safe. Seeking video issues range requests, and naive caching is
worse than none: storing a 206 as the whole file means every later full request
gets a fragment, and answering a range request with a 200 makes some media stacks
fail outright. So only complete 200s are stored, and ranges are served by slicing
the stored body into a correct 206. The content cache survives shell
re-versioning, or every deploy would re-download the playlist over a link that may
be exactly what is broken.

SELF-UPDATE. The package can replace autorun.brs, so a truncated file is a dark
panel with no app underneath. The safety is the ordering: download to .part,
verify sha256 AND size, then delete the .done marker, rename, reboot. Marker
first is not stylistic — leaving it makes the next boot skip the archive and the
update silently never happens. A failed extract parks the zip as .bad instead of
retrying every boot, which would be a loop indistinguishable from a hardware
fault. sha256 because that is what roMessageDigest can compute; a checksum the
player cannot verify is an unverifiable package.

The decision lives on the server and is unit-tested, and the host only executes
it — re-implementing the version comparison in BrightScript would put the
prerelease trap somewhere untestable. That trap is honoured directly: a player on
1.9.29-rc1 is running something semver-OLDER than 1.9.29, so an opted-in player
HOLDS a prerelease of its own core rather than being pulled off the build it was
given to test. Narrowly — a newer core still lands, so opting in never means
never updating again.

Both loop conditions are closed by construction. The manifest and the download
come from one buffer hashed once, so a checksum cannot describe bytes we are not
serving. And the version is stamped into autorun.brs at build time by both
builders, so the script reports the version it actually is — otherwise the player
applies the update, still reports the old version, and is offered the same
package forever.

Failure always degrades to "keep running the old version": an unreachable
manifest, a missing checksum, a failed verification, a full attempt counter and
an unbuildable package all resolve to skip.

998 tests pass (was 954).
2026-08-05 10:09:00 -05:00
ScreenTinker 5a7277523a Wire BrightSign native sync end to end, chosen per group
st-sync.js wrapped SyncManager but nothing drove it. The player now does: the
leader opens a new sync session on each advance, and every member — the leader
included — binds the video with attachVideo() on a NEW id only.

The leader binds from its own broadcast rather than at announce() time on
purpose. Starting when it announces would put it ahead of its followers by the
width of the network, which is the one desync nobody would think to look for
because the leader always looks correct.

Item selection stays clock-derived under both backends. Native sync replaces
only the seek/nudge drift correction, because setSyncParams has the element hold
its own alignment and correcting it ourselves would fight the platform — every
frame we moved is one it then has to undo. Keeping selection on the shared clock
is also what keeps images and widgets, which have no setSyncParams, advancing
with the videos instead of drifting off alone.

LEADER RULE: reuse the existing election (resolveGroupLeader) rather than adding
a column. It already resolves pinned-if-online, else first online member on the
shared playlist, else first by id — deterministic, stable, and already what the
group-sync payload reports. A second mechanism could only disagree with it.

Added on top: a group whose elected leader is OFFLINE falls back to our
protocol. Ours is leaderless and carries on; native sync has exactly one
broadcaster, so those members would sit waiting for an announcement that never
comes, with the dashboard showing a healthy group throughout.

device_groups.sync_backend ('auto'|'screentinker'|'brightsign') is the operator's
REQUEST; the answer comes from the existing pure resolveSyncBackend() so the
players, the dashboard and the stored setting cannot disagree. The resolved
backend, reason and downgraded flag ride in the group_sync payload and in the
group API, and the dashboard shows the refusal reason instead of a setting that
quietly isn't in force. An unrecognised value is rejected rather than stored,
because the resolver reads anything unknown as 'auto' — a typo would otherwise
return 200 and run a different protocol than the UI displayed.

FIXED WHILE HERE: the player re-entered group sync only when the group ID
changed, with a comment noting the clock protocol has no leader role. Native
sync has one, and neither a protocol switch nor leadership moving alters the
group id — so a player promoted to leader kept behaving as a follower, nobody
announced, and the group sat unsynchronised. The re-enter key now includes the
backend and the leader flag.

971 pass (+17).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:06:18 -05:00
ScreenTinker 46b2227dfd BrightSign: real telemetry and hardware identity, not a block of nulls
The web player sent battery/storage/RAM/CPU as nulls and 'Web Player' in the
wifi_ssid column, and the bs_model / bs_os_version / bs_serial / bs_screen fields
the player already reported at registration were consumed by nothing. A browser
tab genuinely has none of that. A BrightSign has some of it, and was reporting
none.

Telemetry comes from a CACHE the heartbeat reads synchronously. The beat builds
its payload every 15s without awaiting, but the one real sensor here —
deviceInfo.getTemperature() — returns a promise; awaiting inside the beat would
either block it or serialise a pending Promise into the payload, which is exactly
how device_id once became "[object Promise]". The cache starts EMPTY rather than
null-filled and is spread last, so off-platform nothing changes and a null here
can never clobber a value another player family legitimately supplied.

wifi_ssid was actively false on a PoE Ethernet appliance — an operator reading
that column was told an SSID that does not exist. It is null there now, and the
device view shows a real hardware block instead. Android's WiFi display is
untouched.

Hardware identity is a SEPARATE writer from applyDeviceInfo, deliberately. That
function is a blind full-row overwrite, and an empty device_info once nulled
seventeen columns every five minutes because {} is truthy. These fields arrive
only on a full register, so the same shape would wipe them on every lightweight
refresh in between; COALESCE makes "no news" mean "unchanged".

The OS build gets its own column rather than reusing android_version, which is
load-bearing as a TYPE discriminator: device-detail chooses between the Android
and browser layouts with android_version.startsWith('Web/'), so writing
"BrightSign OS 9.0.189" there would have rendered a BrightSign with battery and
WiFi cards — and applyDeviceInfo would have clobbered it on the next refresh.

Storage is labelled "Player Storage", not "Storage": on this family the number is
the widget's cache quota, not the device filesystem, and it lands in the same
column as Android's real disk figures.

Schema: device_telemetry.temperature_c REAL; devices.hardware_model,
hardware_serial, hardware_os_version, output_index. All nullable, all idempotent
in the existing migration array.

973 pass (+19). The temperature tests drive a real socket into a real server,
because changing the arity of the telemetry INSERT would break every player's
heartbeat, not just BrightSign's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:03:33 -05:00
ScreenTinker e606cc83d1 Screenshots: prove pixels arrived instead of assuming the draw worked
A BrightSign emitted BLANK screenshots and logged "Screenshot sent". With hwz
enabled the video decodes onto a hardware plane outside the browser compositor
— BrightSign's docs say the HTML/JS layer "doesn't see the pixels" — so
drawImage(video) produces a fully TRANSPARENT image and throws nothing.
Chromium 87, which this XT245 reports, fails the same way.

Both capture paths set captured/drawn = true purely because drawMediaFit() had
not thrown. So the dashboard showed a dead screen while the panel played
perfectly, and the zone path painted a black rectangle in place of the labelled
placeholder drawZonePlaceholder() exists to guarantee ("never a transparent
hole"). Success reported, nothing done.

isMediaReadable() does not catch this. It answers "am I ALLOWED to read this"
(same-origin / CORS), which is a different question from "did any pixels
arrive".

videoFrameIsCapturable() probes a 16x16 scratch canvas before committing to a
full-size draw. ALPHA is the discriminator, not colour: a scratch canvas starts
transparent and a real decoded frame writes alpha=255 even when the frame is
pure black, so a legitimate fade-to-black still reads as captured while
"nothing arrived" does not. A tainted canvas counts as captured, because
tainting only happens once cross-origin pixels have actually been drawn.

Probing BEFORE the draw matters twice: it avoids a wasted full-size drawImage on
every frame of a 1fps stream, and in the zone path it stops a black rectangle
being painted underneath the placeholder.

When a video is on screen but unreadable the status card now says so, because
that card is also what shows for "no content" — without the line an operator
would reasonably conclude the screen was blank.

Not gated on BrightSign: the same silent failure exists for any stalled decoder
or engine that declines to hand back frames.

10 tests, 964 pass.
2026-08-05 10:01:03 -05:00
ScreenTinker c743aa4b81 BrightSign: real display power, reboot and volume — command parity
The web player handles four of the ~20 fleet commands, because a browser tab
genuinely cannot do more. A BrightSign can, and was inheriting the browser's
limits for no reason.

screen_on/screen_off now send CEC Image View On (0x0D) / Standby (0x36) so the
display actually sleeps. The overlay only painted the screen black: the panel
stayed lit, drawing power and at risk of burn-in. Best effort by design — some
displays ignore broadcast CEC and need direct addressing — so displayPower()
returns false when unavailable and the overlay is applied either way, meaning
something visible always happens.

reboot was silently ignored: the dashboard button did nothing on a web player.
It now goes through the host to RebootSystem, and still logs a clear "not
supported" off-platform rather than failing quietly.

set_volume applies to whatever is playing AND is re-applied on every subsequent
'play' event, caught in the capture phase because media events do not bubble.
Media elements are created per item across fullscreen, zone and preload paths,
so setting volume once would otherwise last only until the playlist advanced.
Wall followers stay silent throughout — that is deliberate, not an oversight.

A dual-output player addresses HDMI-N for the screen it actually paints, so
output 2 sleeps its own display rather than output 1's.

954 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 09:26:23 -05:00
ScreenTinker 58641e7bbe Persist the device token, not just the device id
The bridge stored device_id in the registry and the display still came back as a
NEW device on the next boot. The id is not an identity on its own: 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 row.

device_token now sits alongside device_id in the registry, getConfig adopts both,
and clearIdentity forgets both — a stale token must not outlive the identity it
belongs to.

Found on an XT245, not in a test, which is why the three new cases name the
symptom rather than the mechanism. 951 pass.

Also worth recording from the same session: the duplicate rows had a second
cause. The widget's storage_path was pointing nowhere useful, so localStorage
had no persistent home and the per-install fingerprint salt was regenerated on
every boot. With storage_path set correctly the cache directory now exists on
the player and the fingerprint is stable, which is what stopped the churn; the
registry identity is the belt to that pair of braces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 23:57:04 -05:00
ScreenTinker 5901067d8a Finish the BrightSign port: native sync, offline fallback, multicast guard
st-sync.js wraps SyncManager, the native protocol. Three properties drove the
shape of it. It repeats the sync broadcast at 1Hz so a player powered on late
still joins, which means acting on every repeat would reload the video once a
second forever — on screen that reads as a stutter, not as a sync fault, so the
id dedupe is mandatory rather than an optimisation. The leader starts from its
OWN broadcast rather than at announce() time, or it runs ahead of the group by
the width of the network. And attachVideo refuses an element with no
setSyncParams instead of half-syncing it.

offline.html is the local fallback the host falls back to after three failed
loads. It names the server, keeps probing with capped backoff so a site full of
panels cannot storm a server that is coming back, and asks the HOST to restart
the player when it answers — never navigating itself, for the same reason the
player never reloads itself here.

The resolver now models multicast reach. All-BrightSign groups spread across
subnets no longer get native sync: each subnet would sync neatly within itself
while drifting from the others, and the dashboard would show a healthy group
throughout. The IP comparison is a heuristic so it is used in one direction
only — differing networks are evidence against, matching ones are never proof
for, and unknown addresses block nothing.

st-sync.js is served from its single source like the bridge, and the SD card
deliberately carries neither: the player pulls both from the server so a stale
copy on a card can never skew from the player using it.

948 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:43:42 -05:00
ScreenTinker 7fb94fbf70 Correct the BrightSign port against the dev-cookbook examples
Reviewed autorun.brs and st-bridge.js line-by-line against the real examples
instead of the prose docs. Five defects, three of which would have been silent.

The registry API is asynchronous and section-oriented: read(section, key)
returns a Promise and writes take an object, write(section, {k: v}). The bridge
treated both as synchronous, so deviceId() returned a Promise object — truthy
and non-empty — and a panel would have registered as "[object Promise]" while
its real row sat unclaimed. It now prefetches into a cache behind onReady(), and
connect() waits for that before registering.

brightsign_js_objects_enabled: true is required alongside nodejs_enabled for
require("@brightsign/*"). Without it the bridge degrades to no-ops and the
player loses identity and restart delegation — which would have read as
"BrightSign doesn't work" rather than as one missing flag.

storage_path is a directory name, not a volume, and storage_quota is a string;
the local fallback URL needs its volume (file:/SD:/offline.html). Added
security_params and hwz_default to match the examples.

SyncManager does not work unless networking/ptp_domain is "0", which needs a
reboot to apply. Done only when this player is configured for native sync, and
read-before-write so it reboots once rather than on every boot.

Confirmed correct as written: messageport, the roHtmlWidgetEvent loop, and
RebootSystem(). The notes also state a widget URL may be an externally hosted
page with the same JS API access — the favourable answer to the question the
original probe was built to ask.

Bridge tests now model the async section-oriented registry, so a synchronous
stand-in can never hide this class of bug again. 931 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:27:53 -05:00
ScreenTinker ce854ff2d8 Wire the BrightSign bridge into the web player
The bridge and the host existed but nothing loaded them. Now the player does.

restartPlayer() replaces every location.reload() call site. On BrightSign a
page-initiated reload does not reliably bring the roHtmlWidget back, so the page
asks the host to rebuild it and only falls back to reload() when no host is
there to take the request. That covers the deploy path, the operator refresh,
the service-worker activation and the manual reset.

Identity now round-trips through the registry, which outlives localStorage on
this platform: getConfig() adopts a registry identity when local storage comes
back empty, instead of re-pairing and spawning a second row for a panel that is
already provisioned. The operator reset clears the registry too — otherwise it
would clear localStorage, get the same identity straight back on the next boot,
and reset nothing.

Registration reports platform 'brightsign' rather than "Chrome 120", which is
what sync-backend.js resolves native-vs-ours from, plus model, OS, serial and
which output this widget paints.

Dual output needed a collision fix: autorun.brs gives the second HDMI output its
own widget, and both widgets share an origin, a registry and one SD
storage_path. Un-namespaced, output 2 would read output 1's config, install salt
and device id and the two would collapse into a single device row. Storage keys
and registry keys are now suffixed per output; screen 1 keeps the bare names so
nothing existing moves.

The bridge is served from its single source so the copy the player loads can
never skew from the one on the SD card next to autorun.brs, and it is served to
every player rather than gated on a user agent — a panel reporting an unexpected
UA would otherwise silently lose restart-instead-of-reload.

Two test harnesses extract player functions and run them in an isolated scope,
so they now supply SCREEN_SUFFIX; one gained a case proving two outputs of one
player get distinct identities. 927 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 21:07:47 -05:00
ScreenTinker 6f5907a1d4 BrightSign: supervised player host, JS bridge, and per-group sync backend
The player is the unmodified web player in an roHtmlWidget — that already runs
on real hardware. What was missing is everything a page cannot do for itself.

autorun.brs becomes a host rather than 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 the case load-error never reports — a page that loaded fine and then
wedged on a dead socket or a stalled decoder.

st-bridge.js is the page's half over @brightsign/messageport: registry-backed
identity (localStorage is origin- and quota-bound, the registry is not),
restart-instead-of-reload, heartbeat, and sync-backend reporting. Every method
degrades to a no-op off-platform, so it is safe to load unconditionally.

sync-backend.js decides whose synchronisation a group runs. Ours is
clock-derived and spans any mix of Android, web, Tizen and BrightSign; BrightWall
is frame-accurate and BrightSign-only. auto picks native when every member is a
BrightSign. The refusal that matters: native sync selected for a mixed group
downgrades and says why, because a half-synced group would look perfectly
synchronised on the dashboard while one panel drifted alone.

Dual output via output_mode single|dual|clone — a second widget loads the same
player with &screen=2 so the server can give it its own playlist.

Written against the BrightDeveloper docs; not yet run on hardware. The README
lists what is unimplemented, including the BrightWall runtime API, which that
doc set does not cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-04 20:26:12 -05:00
Claude a310d7d5b6 Stop the onboarding checklist counting a field no player reads
"Default Content" is persisted by the device route, snapshotted and restored by the settings layer,
offered in the device form in five languages — and read by nothing. Grep the whole tree and it
appears only in those places, the schema, and this checklist. It is absent from assemblePayload,
from every socket payload, and from all four players.

Counting it as "content assigned" therefore told the operator their screen was set up while the
screen itself went on showing "waiting for content" — the checklist confirming the one thing it
exists to confirm, incorrectly. It now counts only a playlist or a layout, both of which really do
put something on a display.

An existing test asserted the opposite ("any of the three ways of assigning counts"). It encoded the
same false premise, so it is replaced by one that pins the corrected behaviour along with the
evidence for it. The column and the form field are left alone — whether to implement or remove the
feature is a product decision, and this change only stops the checklist making a claim on its
behalf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:41:06 -05:00
Claude e3b01a5c7f Make a content-only schedule actually put that content on the screen
The schedule dialog offers "Content (single item, optional)". The value was cross-tenancy validated
and stored faithfully, and then read by nothing. services/scheduler.js acts on exactly two columns,
layout_id and playlist_id; content_id is consulted nowhere in the codebase. So picking a file and
saving produced a schedule that fired and changed nothing — while the calendar drew a block labelled
with that filename, as confirmation that it would.

Rather than thread a third override type through the engine and every player, the schedule now gets
a playlist containing that one item. That is the shape the entire pipeline already understands:
publish, assign, push, snapshot, offline cache and all four players work on it unchanged.

It is published through the shared publishPlaylist path rather than by hand-rolling the snapshot,
because players read denormalized fields out of published_snapshot (filename, mime_type, filepath,
remote_url, per-item schedules) and a second copy of that shape here would rot the first time it
changed.

An explicit playlist override still wins and no throwaway playlist is created; a schedule with
neither content nor playlist is untouched.

5 tests covering all of those, including that the generated playlist lands in the right workspace and
that its snapshot carries the fields the players need rather than just the id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:26:58 -05:00
Claude 3c1b2f62ee Make a recurring schedule respect its start and end dates
A recurring schedule ran forever. The engine compared weekday and HH:MM and dropped the date
component entirely, so recurrence_end was never read: a campaign set to finish on the 1st was still
switching screens weeks later. The same omission made a recurring schedule live before its start
date.

The calendar does read recurrence_end, so it drew the campaign as finished while the screens kept
obeying it — the two views disagreeing is what made this hard to see from the dashboard. The end
date is offered on the form, so it has to mean something.

The date window is inclusive at both ends: an end date of the 5th means the 5th runs to its normal
end time, which is what someone choosing that date means. An open-ended recurring schedule is
untouched and still runs indefinitely.

NOTE, because this one really does change live screens: any recurring schedule that has been running
past its end date will now stop. That is the intended behaviour and was confirmed before making the
change, but it is the difference between this commit and the calendar fix alongside it, which
changes only what is drawn.

6 tests: stops after the end date, the final day still runs in full, does not run before the start
date, unchanged inside the window, open-ended schedules unaffected, one-offs unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:22:59 -05:00
Claude 416984d56b Draw a recurring schedule on every day it actually fires
The calendar is the operator's only view of what is scheduled, and it disagreed with the engine in
both directions for the two most-used repeat presets.

The expansion stepped by the recurrence unit from the schedule's original start:

  - WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a
    FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule could only ever match its start day. Created on a Monday
    it drew one event a week; created on a Saturday it drew nothing at all.
  - The walk began at the original start under a 366-iteration cap, so a schedule begun more than a
    year ago never reached the current week and drew nothing.

The engine evaluates day-of-week directly, so those schedules were running Mon-Fri the whole time.
Screens switched content the calendar said was not scheduled.

The expansion now walks the visible range day by day and applies the same rule the engine does, so
the drawing follows what actually happens. Cost is bounded by the window being displayed rather than
by how long ago the schedule was created, and the loop re-anchors the time of day on each step so a
DST boundary does not drift the instances.

Overlap is left to resolve as it already does: a shorter, higher-priority schedule takes over while
it is active and the recurring one resumes underneath when it ends. Nothing here changes what fires
— only what is shown — so this cannot alter live screens.

8 tests: five events for a Mon-Fri rule whichever day it was created on, a two-year-old daily
schedule drawing again, WEEKLY-without-byDay still meaning the start's weekday, INTERVAL honoured,
recurrence_end stopping the drawing, one-offs unaffected, and durations preserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 22:18:29 -05:00
Claude a3b668d32f Treat an empty device_info as "nothing new", not as "forget what you know"
Every web and BrightSign player nulled seventeen of its own device columns every five minutes.

The browser player's refresh-register sends `device_info: {}` on a 300-second timer — it has nothing
new to report, it just wants a fresh playlist. But `{}` is truthy, and applyDeviceInfo is a blind
full-row overwrite with no per-field presence check, so it bound undefined for every column.
better-sqlite3 stores undefined as NULL rather than throwing, so the write succeeded and the row was
quietly emptied: android_version, app_version, screen_width/height, render_*, ota_status and
attempts, tier, the four capability flags and the four volume/brightness columns.

Android never hit it, because it always sends the full object. So this degraded exactly the client
family that cannot be inspected any other way — a browser player has no adb, and the dashboard row
is all there is. Fleet view, resolution diagnostics and any version-based logic read blank for them,
which also makes evaluating a browser-based platform look worse than it is.

The surrounding code already anticipates the refresh shape: recordReconnect and persistIdentity are
both gated behind `if (!isPlaylistRefresh)`. This call was the one that was not.

5 tests, including one pinning the driver behaviour the bug depended on — undefined binds as NULL
rather than throwing, which is why this was a silent five-minutely wipe instead of a loud error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 21:09:11 -05:00
Claude 9c6b80c411 Apply a saved device snapshot only inside the workspace it was taken in
Per-device settings are saved against the hardware fingerprint so a panel that is deleted and paired
again comes back configured — name, orientation, playlist, blocked flag — without anyone visiting
it. That is deliberate and worth keeping.

A fingerprint is hardware-derived, so the same physical panel presents the same one whoever pairs
it. applyToDevice looked the snapshot up on fingerprint alone with no workspace comparison, and its
per-field guards only check that the referenced row still EXISTS, never who it belongs to:

    if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id))

So a screen removed from one workspace and paired into another inherited the first workspace's
playlist and displayed its content, and `blocked` crossed the same way — a device arriving blocked
with nothing the new owner could see to explain it. The manual restore route already compares
workspaces before calling this, so the automatic re-pair path was the only place the check was
missing.

A mismatch is a quiet no-op rather than an error: re-pairing a second-hand panel into a different
workspace is a legitimate thing to do, it just must not carry the previous configuration along. A
snapshot with no workspace recorded still applies, so rows predating the column keep working.

5 tests: neither playlist nor block crosses, a mismatch does not throw, restore still works in full
inside the owning workspace (including a genuine block surviving a re-pair), and legacy rows are
unaffected. 882 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:56:59 -05:00
Claude 14367af5f1 Keep a workspace on schedules that outlive their device group
Deleting a device group converts its group schedules into per-device ones so the screens keep their
programming. That INSERT omitted workspace_id, which is nullable with no default, so every converted
row landed with workspace_id = NULL.

A null workspace does not merely look untidy — it makes the row unreachable in three directions at
once, and they compound into the worst possible combination:

  invisible   the schedule list and the all-screens calendar both filter on workspace_id
  undeletable PUT and DELETE refuse a row with no workspace (403)
  still live  services/scheduler.js has no workspace filter, so it keeps firing every 60 seconds

"I deleted the group but the screens still switch content at 9am, and there is nothing in the
calendar to remove." The only way out was direct database access.

The conversion now carries the workspace, preferring the schedule's own and falling back to the
group's so a legacy group schedule that itself predates workspace_id still converts into a reachable
row. A boot migration repairs rows already orphaned in the field by recovering the workspace from
the device each one targets; anything still unresolvable is left alone rather than guessed at.

4 tests: the converted row keeps its workspace, is visible to the query the list and calendar use,
preserves the actual programming rather than just the ownership, and the repair recovers a row
orphaned before this fix existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:53:21 -05:00
Claude 9958c7c7be Save a layout by diffing its zones, not by deleting and re-inserting them
Nudging one zone in the layout editor and pressing Save destroyed unrelated tenant data across the
whole workspace, and returned 200.

The handler deleted every zone and re-inserted the same ids. Its comment claimed that was safe —
"Reuse each zone's id when supplied so device->zone assignments survive an edit (a fresh uuid per
save would orphan them)" — but reusing the id does not help, because SQLite runs the referential
actions on the DELETE and re-inserting the same primary key afterwards resurrects nothing. Two
things point at those rows:

  playlist_items.zone_id  ON DELETE SET NULL  -> every multi-zone playlist item un-assigned, so
                                                 those playlists silently fell back to fullscreen
  schedules.zone_id       ON DELETE CASCADE   -> every zone-bound schedule permanently deleted

No warning, no undo, and nothing in the UI to suggest a geometry tweak had touched schedules at all.

Zones are now updated in place, inserted when new, and deleted only when the editor actually removed
them. An update touches no foreign key, so nothing pointing at a surviving zone is affected. The
cascades are left exactly as they are: on a genuinely removed zone they are the correct behaviour,
and the tests pin that too.

4 tests: a moved zone keeps item assignments and zone-bound schedules, the geometry change is really
applied, adding a zone disturbs nothing, and removing a zone still un-assigns its items and removes
its schedules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:51:28 -05:00
Claude c393cf8ab3 Hold overlay pushes to the same write check as every other fleet action
A PiP overlay renders across a live screen — an arbitrary web page, at full resolution, for as long
as the operator wants. That is a fleet-affecting write, but the three routes that perform it carried
only requireScope('full'), which gates API tokens and is a deliberate pass-through for dashboard
sessions. The file's own comment says so ("No-op for JWT sessions"), on the assumption that
something else covered that case. Nothing did.

Every sibling route pairs the two checks — device-groups.js gates POST /:id/command with
`requireScope('full'), requireGroupWrite`. These had only the half that does nothing for a logged-in
user, so a member who is refused on every other device mutation was accepted here.

requireFleetWrite restores the pairing on POST /, POST /clear and DELETE /, resolving the caller's
context against the workspace the same way the rest of the codebase does.

5 tests pin both directions: refused for a read-only member on all three routes and for an
unauthenticated caller, still allowed for a workspace_editor and for an org owner acting into the
workspace (actingAs, whose workspaceRole is null and must not read as a viewer).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:46:03 -05:00
Claude 81f5d4f9f3 Stop shrinking hand-written text widgets into illegibility
A person typing font-size:16px into the Text/HTML widget got 0.15vw — 2.8px on a 1080p screen,
1.9px at 1280 wide, smaller again on anything narrower. Not clipped, not hidden: rendered at a size
nobody can read, in the one widget whose entire purpose is hand-written HTML.

renderText converted every px font size to vw (px/108). That conversion exists to rescue LEGACY
Content Designer output, which used to publish absolute sizes as fontSize*10.8 px — dividing by 108
recovers the author's intended size and lets those widgets scale to any screen. Today's designer
emits cqw and no px at all (frontend/js/views/designer.js), so the conversion only ever needed to
apply to that legacy output. It was applied to everything.

Now it runs only on designer-authored markup, identified by its absolutely-positioned elements —
the same signal the dashboard already uses to decide whether a text widget can be reopened in the
designer. Hand-written markup keeps its px exactly as typed, and legacy designer widgets are
unchanged.

Found by looking at the screen. The rendered HTML and the widget URL both looked correct in every
check I ran; only a screenshot showed the text was microscopic.

5 tests covering both directions, including that a hand-written absolutely-positioned element
without the designer's left-first shape keeps its px. Verified on an Android screen: a 60px heading
and 24px body now render at their authored sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 20:28:28 -05:00
Claude b44f9d4f03 Serve a beta APK alongside the stable one, and let a display move between them
1.9.26's opt-in was passive: it stopped a sideloaded build being reverted, but there was still one
APK slot and latest_version was the server's own VERSION, so a beta had to be installed by hand on
every display. This makes it a real channel.

- apk-cache tracks two slots. ScreenTinker-beta.apk is optional and reaches only displays with
  ota_beta = 1.
- A beta must DECLARE its version in a sidecar ScreenTinker-beta.apk.version. 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. If the
  sidecar is missing or unparseable the channel does not activate at all and opted-in displays keep
  getting stable. Failing closed matters: advertising a version that does not match the bytes served
  is the OTA-loop condition this fleet has been bitten by before.
- The check and the download resolve the channel identically and fall back to stable identically, so
  apk_size always describes the bytes actually delivered. No APK change was needed — the client
  already fetches whatever download_url it is handed, so displays in the field can be moved between
  channels from the dashboard today.

Switching back needed care. Stable is semver-OLDER than the beta it replaces, so the ordinary
"never offer a downgrade" rule stranded the display and unticking the box would have been another
silent no-op. The first attempt returned any non-opted-in display running a pre-release — which
broke a #144 test, correctly: that would have dragged every existing pre-release tester back to
stable the moment their server upgraded, the exact harm the opt-in exists to prevent. So the return
now requires evidence we actually served that display the beta channel (devices.ota_channel_served,
written once on change, not per check). A tester ahead of the server on their own build is left
alone exactly as before.

Documented in the README, including the constraint that makes the switch-back physically possible:
beta builds must carry a versionCode no higher than the stable they branch from, because Android
refuses to install a lower one. Equal numbers install in both directions.

Verified end to end against a live server with two real signed APKs: stable serves 1.9.26, beta
serves 1.9.27-rc1, an unknown channel falls back to stable, removing the version file deactivates
the channel, and the full opt-in -> serve -> switch-back lifecycle produces offer / up-to-date /
channel-return in order. 859 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 19:12:46 -05:00
ScreenTinker 234bff795d Merge docs/api-device-network-fields: document device network fields, pin the spec version 2026-07-30 18:38:06 -05:00
Claude 301c76c3f7 Let a display opt in to pre-release builds, so a test build is not reverted under the tester
Handing someone a test build was a trap. A prerelease sorts BELOW its own release — 1.9.25-fix234d
is semver-older than 1.9.25 — so a sideloaded display asked "anything newer?", was correctly told
yes, and updated itself straight back off the build we had asked someone to test. Same versionCode,
so Android installed it without complaint. Silent, and within minutes.

That is what happened on #234: the reporter installed the fix, tested for an evening, and reported
nothing had changed. They were right. Their tablet was running the old code again by then, and I had
told them it was fixed without ever checking what the device reported.

Adds a per-display opt-in (devices.ota_beta, default 0, checkbox next to the OTA toggle). When set,
the display keeps a prerelease of the CURRENT core instead of being pulled back to its release.

Deliberately narrow in one direction and deliberately wide in the other:

- Narrow: it only holds 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 the flag defaults off so a fleet that never sets it is unaffected.
- Wide: an opted-in display is exempted from the superseded-prerelease guard. That guard would
  otherwise pin a tester on an old test build permanently — an older-core prerelease is never
  offered anything, so they would have to notice and sideload their way out. Writing the test is
  what surfaced that; opting in must never mean never updating again.

9 tests covering both directions, including that shipping a newer release pulls a beta display back
onto the release line. 845 server tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 18:35:31 -05:00
Claude 5297f091af Let a display's playlist actually be cleared
"No playlist" was an option you could select that did nothing. The picker offered it, and the change
handler opened with `if (!newPlaylistId) return; // Don't allow deselecting for now` — so choosing it
sent no request, changed nothing, and said nothing. The guard was honest about why: there was no way
to do it. PUT /devices/:id has never read playlist_id (200, ignored), and POST /playlists/:id/assign
can only ever set one.

Reported on #234 as "I also selected No playlist ... it still showed the same video". It did, and my
first explanation blamed the playlist-swap deferral. The deferral would have stranded it too — that
is fixed separately and tested — but on this path nothing was ever sent, so the deferral never got
the chance.

DELETE /api/devices/:id/playlist, device-scoped rather than playlist-scoped because there is no
playlist to authorize against when clearing. Ownership goes through checkDeviceOwnership like every
other device mutation, so a viewer and a stranger are refused. Clearing an already-clear display is
a no-op success, since it lives in a dropdown someone can pick twice. The now-empty playlist is
pushed to the device so the screen stops, rather than leaving the old content up until something
else happens to refresh it.

Validated on an Android 12 emulator against the reporter's shape: cleared while a YouTube item was
on screen, zero plays afterwards, device row cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-30 18:29:18 -05:00
Claude c483ef34dd docs(api): document a device's WAN/LAN addresses and SSID sentinel, and stop the spec version drifting
The published API reference (frontend/api-docs.html renders docs/openapi.yaml through Redoc) said
version 1.9.0 while 1.9.25 was shipping. bump-version.sh updates VERSION, server/package.json,
android versionName/versionCode and tizen/config.xml — the spec was simply never added to it, so it
had been frozen since the public API landed and integrators were reading a version identity that no
longer existed.

Spec changes:

- info.version -> 1.9.25.
- Device gains its two network addresses, which are easy to confuse and are now described so they
  cannot be: ip_address is the PUBLIC/WAN address the server observed on connect (X-Forwarded-For
  aware, normally shared by every device at a site), local_ip is the device's OWN LAN address as
  reported by the player, which is the one that reaches a panel on site. local_ip is new; both were
  returned by GET /devices and neither was documented.
- Device gains its flattened latest-telemetry block (wifi_ssid, wifi_rssi, battery, storage, ram,
  cpu_usage, uptime_seconds) — all returned already, none documented, all nullable because a web
  player does not report what Android does.
- wifi_ssid's "permission" value is called out as a sentinel, not a network name: Android 10+
  withholds the SSID without a location permission ScreenTinker only requests if an operator opts
  in. An integrator who does not know that renders "permission" to an end user as their Wi-Fi name.

Drift prevention, because a wrong version number is silent and nobody re-reads one they trust:

- bump-version.sh now writes the spec version too, anchored to info.version (operation- and
  schema-level version keys are indented deeper and untouched; openapi: 3.1.0 is unaffected).
- Three contract tests: the spec version tracks package.json, the two addresses stay documented
  and distinct, and the SSID sentinel stays explained.

No new endpoints — audited every public router's routes against the spec and all are documented.
830 server tests + the 5 contract tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-29 22:26:47 -05:00
ScreenTinker a25c6827a7 Show every plan on the admin tab, with who is on each
The admin plan table read /api/subscription/plans, which filters `active = 1` because
that endpoint feeds the public pricing page. So the one screen meant to show the
operator what plans exist could not show a hidden one — a comped or beta tier was
invisible to us as well as to customers, with no way to see it existed or who was on it.
Found immediately after creating exactly such a plan.

GET /api/admin/plans (platform-admin only) returns every plan plus, per plan, the number
of accounts, organisations and screens on it. Visible plans sort first so the list still
reads like the pricing ladder, with hidden ones after and badged.

The public endpoint is deliberately untouched: hiding a plan has to keep working, and
the test pins BOTH directions because they pull against each other — the admin list must
include an inactive plan, and the public list must never leak one.

Counts are the point, not decoration: "how many people are on what plan" is the question
you actually ask of this screen, and it was answerable only by hand in SQLite.

Also carries a warning for accounts whose plan no longer resolves. Both users.plan_id and
organizations.plan_id are FK-enforced to plans.id and there is no delete-plan route, so
this should be unreachable — but migrations here do rebuild tables with foreign keys off
(the tenant-cascade one rebuilt thirteen), and that is exactly how a row would be
orphaned. Six lines for a state that would otherwise be silent.

Strings added to en/de/es/fr/it/pt. Not hi: it has no admin translations at all, lookup
falls back to English, and four Hindi strings among forty English ones would read worse
than consistent English.
2026-07-29 19:36:10 -05:00
ScreenTinker 3159f94107 Make unblock stick, and say so when a device is refused
A customer blocked a screen once to see what the button did, then spent an evening
unable to get it back. Three separate faults stacked up.

1. Unblock did not stick. applyToDevice() restores `blocked` on re-pair — deliberately,
so a block cannot be shrugged off by deleting the device — which makes the SAVED copy
the real authority. Unblock only ever wrote `devices`, so the saved row stayed 1 and the
next delete + re-pair silently re-blocked. There was no way out from the dashboard at
all: unblock, re-pair, refused, repeat. Block and unblock now both mirror to the saved
copy, so the survives-a-re-pair property is deliberate rather than a leftover.

2. The refusal was invisible. handleServerRejection() clears credentials and calls
onUnpaired, but only ProvisioningActivity ever assigned that callback — and it is long
gone by the time playback is running. So the screen sat on "Connecting to server" and
the player eventually blamed the URL, sending the operator off checking their network
while the server had already said exactly what was wrong. MainActivity now handles it.

(This half was mine: clearing those leaked callbacks to stop the relaunch loop removed
the only thing that surfaced a rejection. It was a broken path — it fired into a
destroyed Activity — but it was the only one, and MainActivity should have owned it.)

3. The reason was thrown away. The server sends device:auth-error {error: "Device
blocked"} and the client discarded it. It is kept now, and a blocked screen says so
instead of implying a network fault. Localised in all six languages, matching the other
on-screen status strings.

Also ran on prod: one stale saved block cleared (fingerprint ef6540376599, the reporter's
tablet), DB backed up first. It was the only such row.

Tests pin both directions, because the two are easy to confuse: unblock must clear the
saved copy, AND a genuine block must still survive a delete + re-pair.
2026-07-29 18:44:11 -05:00
ScreenTinker c779d62d63 Add an operator override for self-update on MDM-managed panels
A player stands down from self-updating when another device owner manages the panel,
on the assumption that the MDM distributes packages instead. That assumption does not
always hold: an operator may run an MDM for policy alone and still want ScreenTinker's
OTA to own the player. Until now there was no way to say so — the stand-down was a
client-side decision with no operator input.

OTA_ALLOW_MANAGED_DEVICES=1 makes the server advertise `allow_managed: true` in
/api/update/check, and players skip the stand-down. Default off: the safe behaviour
stays the default, and only an explicit opt-in changes it.

Absence is not consent. The client parses the field with a false default, so a newer
player against an older server that has never heard of it still stands down; and the
server always emits the key, so a player can tell "the operator said no" from "this
server has no opinion". Config parsing is strict for the same reason — only 1/true
enable it, and anything else, including a plausible typo like "ture" or "yes", lands
on the safe side rather than riding JavaScript truthiness.

This deliberately does NOT grant silent install. Off device-owner, and without
DELEGATION_PACKAGE_INSTALLATION delegated by the MDM, Android still raises a confirm
dialog somebody has to accept, so the override alone will not fix a fleet whose
installs are failing at that dialog — delegating the scope is the real fix there. The
README says so at the point of use, because reaching for this flag is the natural
mistake.

Only reachable because the stand-down now runs after the version check rather than
before it; it needs the server's answer in hand to consult.
2026-07-28 23:07:30 -05:00
ScreenTinker 0df7f58b26 Parse MAX_FILE_SIZE, and document what else caps an upload
Follow-up to #233, which made the upload ceiling configurable — the right call,
500MB is genuinely too low for video.

An environment variable is a string, so the value reached multer's
limits.fileSize as text where a number is expected. That survives some
comparisons through coercion and misbehaves in others, which is the worst kind
of bug to find later; the line directly above it already used parseInt for the
same reason. It is parsed properly now, and a suffix is accepted — someone
raising a limit for video is choosing "about 2GB", and 2147483648 is easy to
mistype by a factor of ten.

An unparseable value falls back to the default rather than becoming NaN or
zero. Either would reject every upload on the instance, from a typo in an env
file, with nothing on screen to explain it.

The documentation matters as much as the code here. MAX_FILE_SIZE is the LAST
limit in the chain: nginx caps the request body with client_max_body_size and
returns 413 before the app is reached — our own deployment sets 500M — and
Cloudflare caps uploads per plan at the edge. Raising the variable alone often
changes nothing, so the README now says so, with the nginx directive and a note
that an upload failing with nothing in the server log never reached the server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:40:14 -05:00
ScreenTinker 3e3d0081fe Keep the smoke test out of npm test, and update the lockfile
Two mistakes in the previous commit, both of which broke CI.

The lockfile was not regenerated after adding puppeteer-core to
devDependencies, and `npm ci` requires the two to agree — so every job that
installs dependencies failed before running anything.

The smoke test was also placed in test/, which I described as keeping it out of
`npm test`. It does not: `node --test` globs that directory, so the runner
picked it up regardless of intent, tried to drive a browser as a unit test, and
failed. It now lives beside the server as smoke-ui.js, with a note saying why,
so the next person does not put it back.

Verified the way it should have been the first time: npm ci succeeds, native
modules still load, npm test is 807/807 with no browser involved, and
`npm run smoke` is 32/32 on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:34:34 -05:00
ScreenTinker 29ae184b14 Add an opt-in browser smoke test
A whole class of defect found today was invisible to the unit suite, to a
syntax check and to review, and appeared only in front of a browser: a context
menu whose only item read "schedule.ctx_new", pointer handlers stacking on every
calendar render so one drop fired five PUTs, and a week grid that scrolled
sideways on a phone. Nothing in the repo could have caught any of them.

This keeps the checks that earned their place and throws away the scratch
scripts around them. It boots a server, drives every view, and asserts each view
renders, none raises an uncaught error, no untranslated key reaches the screen,
the calendar binds its handlers once however many times it re-renders, and
nothing overflows horizontally at phone width.

Deliberately NOT part of `npm test`. It needs a real browser, which CI does not
have, so it is `npm run smoke` and exits 0 with an explanation when puppeteer or
Chrome is missing — a test that fails for want of tooling teaches people to
ignore failures. puppeteer-core rather than puppeteer, so installing it does not
pull down a private copy of Chrome; it drives whichever one is already there.

Verified both ways: 32/32 against current main, and it fails on the listener
stacking when that fix is reverted. The missing-key case is covered by the unit
guard instead, since a context menu only exists once it has been opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:17:59 -05:00
ScreenTinker 7747d7e051 Put Members in the nav, reveal titles on touch, and stop a stale heartbeat killing a socket
Three loose ends from the interface review.

Inviting a colleague is a core action and had no entry in the navigation at
all. The only route was an unlabelled icon beside the workspace name, or typing
the URL. There is now a Members item, translated, which resolves to the active
workspace so the static link needs no id. The Teams entry it sits near stays
hidden, since that feature is still switched off.

A native title= is hover-only, so the icon-only buttons — rename a wall, remove
a device from one, manage members — explained themselves on a desktop and said
nothing on a touchscreen. Long-pressing one now shows its label. The text was
already there and already translated; it simply had no way to reach a finger.

The last one is the bug that took a real screen dark. A device row can vanish
while its socket is still heartbeating, and the telemetry insert then failed a
foreign key. That throw was fatal in a way that is hard to guess: the
safe-socket wrapper reads a throwing handler as a broken one and disconnects
the socket server-side, and socket.io deliberately does not retry that kind of
disconnect — so the player sat doing nothing until a person reloaded it. A
heartbeat for a device that no longer exists is an ordinary race, not a fault
worth ending a connection over; the write is skipped and the register path
answers unpaired, which is the reply that actually helps the client recover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:00:35 -05:00
ScreenTinker 618af0811a Translate the labels that never went through t()
A title= is a tooltip the user reads and an aria-label is what a screen reader
says, but fourteen of them were hardcoded English. They were invisible to the
key checks added earlier precisely because they never call t() — so a French
user hovering the only route to workspace members read "Manage members", and a
German screen reader announced every modal's close button as "Close".

The user-visible ones matter most: the workspace switcher's Manage members and
Rename, the video wall's rename and remove, and the dashboard's select-for-wall.
All are translated into every active locale, along with the close buttons.

A test now rejects a capitalised literal in a title or aria-label, since that is
the shape this takes and nothing else catches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:52:52 -05:00
ScreenTinker 0a9a749475 Make help tips reachable, and explain the pages that had none
An audit of every view turned up two problems with the in-product help.

The tips only appeared on :hover. On a tablet or a phone there is no hover, so
the entire explanation layer was invisible to touch users — a large share of
the people administering signage — and unreachable from a keyboard. Tapping a
marker now opens it, Escape or a tap elsewhere closes it, and the marker is
focusable so Tab reaches it and a screen reader announces it. Bound once at the
document level and applied by observing the DOM, because views render from
about twenty call sites and modals appear later still; hooking each one would
have left the next new route silently unreachable again.

Four views had no tip at all. Playlists is the important one: a playlist is the
concept the reported confusion was actually about, and the page said nothing
about what one is or how it reaches a screen. Activity and Settings now have
one too. Help does not, because it is the help.

The schedule tip described a product that no longer exists — it said to click
Add Schedule, predating the drag, resize and right-click gestures. Rewritten.

All four are translated into every active locale rather than left to fall back
to English, since a tip falling back is a non-English user being handed an
English paragraph at the moment they are confused. hi.js stays deliberately
empty per the note in that file. Tests now check that every tip is translated
everywhere, and that a tip marker never names a string that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:37:49 -05:00
ScreenTinker 832a9c9bb2 Draw a schedule that runs past midnight
10pm to 4am is an ordinary signage schedule and the playback engine has always
understood it — schedule-eval treats an end before a start as a wrap. The
calendar did not. It computed four minus twenty-two, got negative eighteen
hours, and drew an eighteen-pixel sliver at 10pm with nothing at all after
midnight. The schedule played correctly while appearing broken.

An overnight window is now split into the pieces a week grid can draw: the part
before midnight on its own day, the part after it on the next, squared off
where they meet so they read as one window rather than two schedules. The
tooltip names the whole span, since neither half shows it alone. A Saturday
night spill is simply not drawn rather than wrapped round to Sunday, where it
would appear to have played six days early.

Dragging one is refused. A drag describes a window inside a single day, so
applying it to a wrap would clamp it into that day and silently destroy the
schedule — the same reason a recurring schedule's day cannot be dragged.

Verified in a browser against a real 22:00 to 04:00 schedule: 88px on Tuesday
night, 176px on Wednesday morning, alongside an ordinary daytime block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:28:04 -05:00
ScreenTinker a62396c2dd Attribute a widget play to the widget that played
A widget playlist item carries its id in widget_id and has no content_id at
all. The player sent only content_id, so a widget play arrived with nothing
identifiable and was written with both columns null — and play_end bound
content_id to BOTH columns, so that row could never match itself and was never
closed or given a duration.

Nothing looked broken: a row existed for every play. It just named neither what
had played nor which widget, and never ended. Reports read empty for any screen
showing a widget, which is most of the interesting ones. Seen on a live screen
playing a single widget: one open row, both columns null.

The player now sends widget_id alongside content_id, and a name falling back
through the fields a widget item actually has, so the event records what played
even when neither id resolves. The server prefers an explicit widget_id and
keeps the old content_id sniff as the fallback for players that predate this,
so an older client that puts a widget id in content_id still attributes
correctly.

Found by reading a real screen's proof-of-play rather than the code. The first
attempt at the fix broke the statement outright — the explanatory comment was
placed inside the SQL template literal, where a JS comment becomes SQL, and the
server logged `near "/": syntax error` on every play_end. Comments now sit
above db.prepare(), with a note saying why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 19:06:56 -05:00
ScreenTinker 268bd5e7fb Stop shipping untranslated keys as user-facing text
Driving the app in a real browser showed a context menu whose only item read
"schedule.ctx_new". t() returns the KEY when a string is missing — it never
returns undefined — so a missing key renders literally, and the common
`t('x') || 'A readable default'` guard is dead code: the key is truthy, the
default can never fire, and the pattern hides the problem instead of covering
it. Every occurrence of it in the app was doing exactly that.

Nineteen strings were affected, most of them predating this work: fifteen in
the self-hosted update panel and four in video walls, all of which have been
showing raw keys to users. The intended text was recovered from the dead
defaults, so the wording is the authors' own, and the defaults are removed
rather than left to imply a safety net that does not exist.

A test now walks the views for the keys they actually ask for and fails on any
that English does not define, and separately rejects the `|| default` pattern.
Neither problem is visible to a syntax check, a unit test, or review — only to
someone looking at the screen — so the guard is the only thing that keeps them
from coming back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 18:16:32 -05:00