Commit graph

12 commits

Author SHA1 Message Date
ScreenTinker 5fa6b2d07d A baseline moves when the fix reaches SCREENS, which is not one rule
Two changes that are really one idea: the parity model treated all four
players as if they update the same way, and they do not.

WEB AND BRIGHTSIGN GET audio.volume BACK.

The audit removed it because v1.9.28's index.html contained the string
set_volume zero times, and because the handler read payload.value while the
dashboard sends { level }. The second reason 1.9.31 fixed. The first was
reasoning from the wrong artifact: this player is SERVED BY THE SERVER, so a
browser panel runs whatever build is answering it, not the release its row was
created under. There is no browser panel stuck on the v1.9.28 player once the
server moves — and prod moved tonight. The slider works on those displays right
now while the baseline says it does not, so the dashboard is hiding a working
control from every display that declares nothing.

BrightSign comes with it, on the same served player. The unit-specific doubt is
whether a hwz player's media element is reachable at all — and that is already
answered by audio.mute, which this baseline has always claimed: set_volume
reaches setMediaVolume() and device:mute-changed reaches currentVideoEl.muted,
same element, same path. If hwz swallowed one it would swallow both.

TIZEN DOES NOT COME WITH THEM, AND THE TEST NOW KNOWS WHY.

A .wgt sits on the panel until somebody updates it. Cutting 1.9.31 put nothing
on any screen, so an un-updated Tizen panel still has the broken handler and
moving its baseline would resurrect the dead slider on real hardware.

The test could not express that. It judged every family against "shipped
source", resolved as the newest tag — which is HEAD on a release commit, so
tagging 1.9.31 flipped all four biconditionals at once and demanded a baseline
change for displays that cannot have the fix yet. Green tree, red build, naming
a baseline, with nothing in the diff to explain it. main would have gone red on
the next commit whatever it contained; #242 just got there first.

So the two families are now modelled separately. Server-served: judged against
the working tree, both directions, because both are decidable from the build we
are about to serve. Device artifact: judged against the previous release, and
only in the over-claim direction — "the baseline claims it, so the shipped
player had better implement it" is always true and worth failing on, while
"HEAD gained the handler, so add it" is a guess about how many panels have
updated. The cost is that a stale entry can outlive the artifact reaching the
fleet; that is a judgement about screens, so a person makes it in
player-capabilities.js and records why.

player-capabilities.test.js carried the same stale reasoning hardcoded, and
docs/player-parity.md stated the old facts in four places — a parity matrix
that lies being the exact failure this whole model exists to stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-06 22:54:45 -05:00
ScreenTinker b4363d8d26 Keep display.power on the Android baseline
screen_off blanks a fielded panel for real (owner/admin FORCE_LOCK, else the
accessibility lock); screen_on is a logged no-op. One capability renders both
dashboard buttons, so withholding the pair to hide the dead ON button also takes
blank-at-night — the half that gets scheduled — away from every panel that has
not updated. Panels that have updated declare for themselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 18:10:52 -05:00
ScreenTinker ac2389716c Merge QA: make the parity matrix and the capability baselines true
# Conflicts:
#	server/lib/player-capabilities.js
2026-08-06 17:47:28 -05:00
ScreenTinker c778f050a9 Merge QA: gate the ungated device commands, and stop a register erasing a panel's platform
# Conflicts:
#	server/server.js
2026-08-06 17:46:46 -05:00
ScreenTinker 3e37d33b80 QA: close four ways a control or an asset lied about itself
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.

1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
   miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
   15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
   sets before it knows the file exists. Every player downloader treats 200 as success,
   so a panel stores the HTML page AS the video and caches it for a month, rendering a
   black frame with nothing in any log. Reachable exactly when it hurts: a content
   replace writes a new random filename and unlinks the old one. The mount now
   terminates a miss with a 404 and drops the cache header.

2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
   has always refused a command the panel cannot honour, and the comment above it is right
   about why ("hiding the button is not enforcement — this socket is reachable directly").
   Every word applied to the four handlers immediately above it, which had none: a display
   declaring [] still received screenshot-request, remote-touch, remote-key and
   remote-start. Measured, not inferred. They now refuse on remote.screenshot /
   remote.input / remote.stream and name the capability in the ack; remote-stop stays
   ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
   absent declaration still resolves to its platform baseline and keeps everything.

   The wall panel list (#235) made this visible: it offered a Screenshot button for every
   panel, including a BrightSign, which has no screenshot capability at all, and popped a
   toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
   capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
   as "pre-capability server, show everything" — wrong in the one case that matters), so
   the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
   Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
   commands on display.power; both now agree.

3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
   field to the literal 'unknown' and persistIdentity wrote it straight over. That column
   is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
   a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
   for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
   power and reboot and gained screenshots it cannot take. platform and client_type are
   now preserved (physical facts); client_version and contract_version still decay, because
   there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
   second signal for a Tizen TV.

4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
   a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
   brand-new "default an item to the clip's own length" then handed out the wrong number
   for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
   image measured it with raw sharp metadata and thumbnailed without .rotate(),
   re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
   share lib/content-ingest.deriveMediaMetadata.

Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.

Full suite green (1319).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 16:12:29 -05:00
ScreenTinker 4e1de8ec0e Make the Tizen and BrightSign players do what they say they do
Both players carried calls that compile, read correctly, and are documented to
do something else. Verified line by line against docs.brightsign.biz and
Samsung's Smart TV Filesystem reference; every fix below cites the doc that
proves it, and the linter has been extended so each one fails here next time.

TIZEN

The offline media cache could never have worked on a panel. Its adapter used
the deprecated Filesystem API in three ways the IDL rules out:
`tizen.filesystem.resolve()` is declared `void`, so `var dir = resolve(...)`
was always undefined and MediaCache.create() returned null on every panel in
the fleet; `openStream()` is asynchronous, so appendPart read `written` before
any callback could run and returned 0 forever; and `moveTo()` is asynchronous,
belongs on the parent directory, and takes (origin, destination) — it was
called on a file handle with the arguments transposed. Rewritten against the
5.0 synchronous FileSystemManager, which is genuinely synchronous and is what
the decision layer needs. A Tizen 4.0 panel now reports available() false
instead of being handed a cache that silently writes nothing.

Writes are now POSITIONED rather than appended at EOF. Power cut between a
write and the index save — the exact event this feature exists for — replayed
the last chunk, and an append landed it twice: a silently corrupt video that
promoted as complete. A positioned write makes the replay idempotent.

Three decision-layer bugs alongside it: a 206 with no readable Content-Range
fell back to Content-Length, which is the CHUNK length, so the first megabyte
of a 50MB video promoted as a complete 1MB asset; a 200 whose body was short of
its own Content-Length returned 'done'; and a server with no ETag or
Last-Modified was re-fetched from zero on every sweep, forever, on precisely
the marginal link this feature exists to be gentle on.

The volume slider was dead. The dashboard sends `{level: 0..1}`; this handler
read `value`/`volume` as a 0..100 percentage, so it matched nothing and logged
"no usable value in payload" on every slider move while the panel declared
audio.volume as working. Both halves had to move together — taking `level` as a
percentage turns 50% into 0.5%, which is inaudible and looks like a fix.
Verified by driving the real handler in headless Chrome, before and after.

BRIGHTSIGN

FindMemberFunction is documented as available only when
roDeviceInfo.HasFeature("FindMemberFunction") is true. It was called
unguarded from the capability probe and from host telemetry — both on the event
loop — so a player without the feature would have died within a minute of boot
and taken the display with it. The guard needed guarding.

The boot report never arrived. The host flushed its buffer straight after
Show(), before the page had been fetched, while the player correctly waits for
its socket before subscribing. Between two correct decisions every boot line
fell on the floor. The host now waits for the page's `probe`, and the bridge
buffers until a consumer registers.

offline.cache was claimed on `navigator.serviceWorker` being present. It is
present on a BrightSign widget and will not run a worker — our XT245 passes the
check and never fetches sw.js. Now requires a controller, matching the web
player. Removed from the brightsign baseline for the same reason.

display.resolution was claimed on @brightsign/videooutput, which has no
setMode at all; mode setting lives on @brightsign/videomodeconfiguration.

roStorageHotplug.GetStorages() answers "USB1:/" while GetStorageStatus() is
documented as unreliable for "USBn:" — feeding one to the other re-created the
bug the static fallback list exists to avoid, and only on the OS versions that
have the enumerator.

dual/clone output mode put two full-screen widgets on output ONE, on top of
each other, while output two stayed dark: roHtmlWidget has no output selector,
and a second output is addressed by its display_x/display_y within the
SetScreenModes canvas. Now positioned properly, or refused with a reason.

Also: a manifest missing sha256/size passed `invalid` into typed parameters, a
runtime error at the call the comment already described and did not prevent;
storage_quota was a string where the docs say use a double; and the comment
crediting brightsign_js_objects_enabled with gating require("@brightsign/*")
named the wrong flag — it is nodejs_enabled.

TESTS

The two suites that mattered most were the ones that passed while the code was
broken, because they asserted on source text or against a fake more correct
than the platform. The host-diagnostics regexes now execute the bridge; the
media-cache suite now drives the shipped adapter against a fake tizen.filesystem
written from Samsung's IDL. Ten new rules in the BrightScript linter, each
verified to fail against the source it was written to reject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 10:51:15 -05:00
ScreenTinker c1270599c3 Make the parity matrix true, and stop three controls that do nothing
The parity doc and the capability model had drifted from the players in both
directions, and nothing failed when they did. Auditing all four players against
their shipped sources turned up three controls a customer can press today that
change nothing, and a set of baselines that were partly too generous and partly
too stingy.

The three dead controls:

  - The volume slider works on Android only. The dashboard sends set_volume as
    { level: 0..1 }; the web player reads payload.value and Tizen reads
    payload.value ?? payload.volume, so on both the number is undefined and the
    handler quietly declines. Three complete, working volume implementations
    that cannot be driven. The fix is one line in each player and belongs to
    those files; audio.volume is out of the web and brightsign baselines until
    it lands, held there by a biconditional test that fails the moment a player
    starts reading `level`.

  - Every #161 Tier-2 command was refused for the entire fleet. lock_now,
    power_menu, status_bar, block_uninstall and unblock_uninstall were gated on
    system.device_owner, which no player declares and no baseline grants, so
    supports() was false everywhere -- including on the device-owner panels the
    feature was built for. The dashboard still drew the buttons because it also
    gates on device.tier === 2. Fixed here: those five now accept
    system.device_owner OR system.kiosk, which PlayerCapabilities.kt declares
    under `if (isOwner)` and nothing else, and which no non-Android player
    declares. Android should declare system.device_owner and retire the
    stand-in.

  - enable_system_capture required the capability it creates. It raises the
    MediaProjection consent dialog -- the way a panel GAINS capture -- and was
    gated on remote.screenshot, so the only panel that needs it was the one
    panel that could not be sent it. Now ungated. The dashboard still hides the
    button behind the same check; that half is a frontend change.

The baselines describe what an un-updated fielded display can do, and since
v1.9.29 is the first build in which any player declares anything, that means
v1.9.28. Every entry is now justified against `git show v1.9.28:<source>`:

  - android loses display.power (v1.9.28 answers screen_on with a logged no-op,
    so the ON half is dead on every fielded panel and one capability renders
    both buttons) and system.reboot (owner-only; off-owner it paints an
    accessibility power dialog over the signage). Scheduled reboots now skip
    undeclared Android panels rather than logging a reboot that never happened,
    which is the reason that gate exists.
  - tizen gains display.power: v1.9.28 implements both halves with no signing
    and no panel API, so withholding it hid a working control.
  - brightsign loses audio.volume, display.power, system.reboot,
    system.restart_player and offline.cache. All need a host bridge the unit is
    not known to have, and restart_player without one is the page reload that
    darkened a panel on 2026-07-28.

Also found, not fixed here because the files belong to others:
st-bridge.js computeCapabilities() is dead code -- nothing calls BS.capabilities()
-- and its 199 lines of passing tests constrain nothing a BrightSign actually
declares; the two disagree on six capabilities and the bridge is right about
most of them. BrightSign's "Force update" button is dead. PlayerCapabilities.kt
under-declares display.brightness.

The new test reads the player sources rather than the table: a dead-button rule
(every gated command has a branch somewhere), an unreachable-capability rule
(which would have caught system.device_owner), and biconditionals so a fix in a
player fails the test until the baseline follows. Claims that need hardware --
CEC reaching a display, a widget being allowed a service worker, SyncManager
holding frame lock -- are marked unverifiable in the document instead of
asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 10:24:10 -05:00
ScreenTinker 4153448c55 Merge: capability persistence, dashboard gating, server-side refusal 2026-08-05 14:29:09 -05:00
ScreenTinker 0082191f9b Show only the controls a display can actually honour
Every device control was offered to every display. A browser tab was shown
"Reboot device", a Tizen TV was shown screen power, a player with no
framebuffer read was shown a live view that stayed black. They all looked
like working buttons and did nothing — the "reports success and changes
nothing" shape that keeps costing people days.

Players now declare what they can do at registration, because only the
player knows at runtime: an Android panel gains real screenshots when
accessibility is switched on and loses Tier-2 when device owner is revoked.
The dashboard hides what is not supported rather than disabling it, and the
Info tab lists the capability set so a missing control is explainable.

The declaration is three-state and the middle state is load bearing: NULL
means "has never told us anything" and falls back to a per-platform
baseline, because several hundred displays in the field will not update
before this deploys and blanking their controls would be a far worse bug.
An empty array means "I genuinely can do nothing" and is honoured.

Hiding a button is not enforcement, so unsupported commands are also
refused server-side — the socket is reachable directly and a stale tab
still renders the old controls. Group sends report skipped devices
separately from sent ones; counting an unreachable member as "sent" is how
an operator walks away believing the whole group rebooted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 14:24:52 -05:00
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