Merge QA: make the parity matrix and the capability baselines true

# Conflicts:
#	server/lib/player-capabilities.js
This commit is contained in:
ScreenTinker 2026-08-06 17:47:28 -05:00
commit ac2389716c
8 changed files with 726 additions and 123 deletions

View file

@ -7,129 +7,303 @@ it puts a control on the dashboard that cannot work.
Capability names come from `server/lib/player-capabilities.js`. Players declare their own set at
registration; a player that declares nothing falls back to the per-platform baseline in that file.
**Legend** — ✅ supported · ⚠️ partial/conditional (reason given) · ❌ not supported (reason given)
**Legend** — ✅ verified in source · ⚠️ partial/conditional (reason given) · ❌ not supported (reason
given) · 💀 **dead**: the capability is declared or baselined but the control cannot work · ❓
**unverifiable from source** — needs hardware, and is marked as such rather than asserted.
BrightSign runs the *same* `server/player/index.html` as the browser, so it differs only where the
`autorun.brs` host bridge adds something the browser cannot reach.
`autorun.brs` host bridge adds something the browser cannot reach. The bridge has two halves: the
JS (`brightsign/st-bridge.js`, served by us at `/player/st-bridge.js`, always current) and the
on-device BrightScript that must create the widget with `nodejs_enabled:true`. `BS.hasHost()` is
false unless BOTH are present, and everything host-backed hangs off it.
Verified at `2237eda`. Where a row cites "the fielded build" it means `v1.9.28` — the last release
before any player declared anything, and therefore the build every baseline is describing.
---
## 🔴 Read this first: three dead controls found by this audit
These are not gaps in coverage. They are controls a customer can press today that do nothing.
### 1. The volume slider works on Android only
`frontend/js/views/device-detail.js` sends `set_volume` as **`{ level: 0..1 }`**:
```js
el?.addEventListener('change', () => sendCommand(device.id, cmd, { level: parseInt(el.value, 10) / 100 }));
```
| player | what the handler reads | result |
|---|---|---|
| Android | `payload.optDouble("level", -1.0)` | ✅ works |
| web | `data.payload?.value ?? data.value` | 💀 `undefined``isFinite` fails → silent no-op |
| Tizen | `payload.value ?? payload.volume` | 💀 `undefined` → logs `no usable value in payload` |
| BrightSign | (the web player) | 💀 as web |
Three of the four players have a complete, working volume implementation that cannot be driven,
because nobody checked the payload key against the sender. **Fix: one line in
`server/player/index.html` and one in `tizen/js/app.js` — accept `level` (0..1) as well.** Until
then `audio.volume` has been removed from the `web` and `brightsign` baselines, and
`test/player-parity-baselines.test.js` holds that as a **biconditional**: fix the player and the
test fails, telling you to put the baseline entry back.
### 2. Every #161 Tier-2 command was refused for the entire fleet — FIXED here
`lock_now`, `power_menu`, `status_bar`, `block_uninstall` and `unblock_uninstall` were gated on
`system.device_owner`. **No player declares that name** — not `PlayerCapabilities.kt`, not
`tizen/js/capabilities.js`, not `declaredCapabilities()`, not `st-bridge.js` — and no baseline
granted it. So `supports()` returned false for every device on every platform and all five commands
were refused, *including on the device-owner panels the whole feature was built for*. The dashboard
still drew the buttons, because `device-detail.js` gates that block on `device.tier === 2 ||` too,
so an operator on a real owner panel pressed "Lock now" and got a silent server-side refusal.
Fixed in `player-capabilities.js`: those five now accept `system.device_owner` **or**
`system.kiosk`. That is an exact stand-in, not a loose one — `PlayerCapabilities.kt` declares
`system.kiosk` under `if (isOwner)` and nothing else, which is precisely when `STPolicy`'s `owned()`
actions do anything, and no non-Android player declares it.
**Follow-up owned by Android:** `PlayerCapabilities.kt` should declare `system.device_owner` under
`if (isOwner)`, at which point the stand-in becomes redundant.
### 3. The capture bootstrap required the capability it creates — half FIXED here
`enable_system_capture` raises Android's MediaProjection consent dialog: it is how a panel *gains*
full-screen capture. It was gated on `remote.screenshot`, so the only panel that needs it — no
accessibility, no projection grant, therefore no declared `remote.screenshot` — was the one panel
that could not be sent it. The command is now ungated.
**Still broken, and it is a frontend change:** `device-detail.js` renders the button behind
`can('remote.screenshot')`, so it is still hidden on exactly those panels.
---
## Playback
No command routes to any `playback.*` capability and no dashboard control is gated on one, so these
describe content rendering. They are informational, and shown to the operator in the Info tab.
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `playback.video` | ✅ ExoPlayer | ✅ `<video>` | ✅ AVPlay | ✅ hardware plane |
| `playback.image` | ✅ | ✅ | ✅ | ✅ |
| `playback.video` | ✅ ExoPlayer (`MediaPlayerManager`) | ✅ `<video>` | ✅ AVPlay | ✅ hardware plane |
| `playback.image` | ✅ `ImageLoader` | ✅ | ✅ | ✅ |
| `playback.widget` | ✅ WebView | ✅ iframe | ✅ iframe | ✅ iframe |
| `playback.youtube` | ✅ WebView embed | ✅ IFrame API | ✅ iframe embed | ✅ IFrame API |
| `playback.zones` | ✅ | ✅ | ✅ | ✅ |
| `playback.transitions` | ✅ GL wipes (#204) | ⚠️ declared only when the bundle loads — a failed load hard-cuts rather than breaking playback | ✅ | ⚠️ as web |
| `playback.pip` | ✅ `PipOverlay` | ✅ `#pipContainer` | ✅ | ✅ |
| `playback.zones` | ✅ `ZoneManager` | ✅ | ✅ | ✅ |
| `playback.transitions` | ✅ `TransitionCompositor` | ⚠️ declared only when the bundle loads (`transitionRuntimeReady()`) — a failed load hard-cuts rather than breaking playback | ✅ `transitions.js` | ⚠️ composites DOM over video; with hwz it may be **invisible over video** and degrade to a hard cut |
| `playback.pip` | ✅ `PipOverlay` | ✅ `#pipContainer` | ✅ `pip-overlay.js` | ⚠️ same hwz caveat as transitions |
## Audio
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `audio.mute` | ✅ incl. YouTube via IFrame bridge | ✅ | ✅ incl. YouTube via `postMessage` | ✅ as web |
| `audio.volume` | ✅ `set_volume` | ✅ `set_volume` | ❌ **no `set_volume` handler exists** — the dashboard slider does nothing today | ✅ as web |
| `audio.mute` | ✅ `device:mute-changed``setVideoMuted`, incl. YouTube via the IFrame bridge | ✅ | ✅ incl. YouTube via `postMessage` | ✅ as web |
| `audio.volume` | ✅ `set_volume` reads `payload.level` | 💀 reads `payload.value`; dashboard sends `level` | 💀 handler exists (`applyVolume`, incl. `tizen.tvaudiocontrol`) and reads `payload.value` | 💀 as web |
## Display
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `display.rotation` | ✅ native `rootView.rotation` | ✅ CSS transform | ✅ CSS + AVPlay for video | ⚠️ host rotates the output via `roVideoMode`; CSS alone cannot turn the hardware video plane |
| `display.power` | ✅ `screen_off` / `lock_now` | ❌ a browser tab cannot power a panel — the overlay only paints black | ❌ `screen_off` draws a black overlay, deliberately, "so the command still does something visible" | ⚠️ media teardown always works; CEC is best-effort and absent on some units |
| `display.resolution` | ❌ no video-mode control in the app | ❌ not addressable from a browser | ❌ | ✅ `roVideoMode` via the host |
| `display.rotation` | ✅ native `rootView.rotation` — the ExoPlayer surface rotates with it | ✅ CSS transform | ✅ CSS + AVPlay `setDisplayRotation` for video | ⚠️ CSS cannot turn the hardware video plane; the host would have to (`roVideoMode`), and the page never calls `BS.setVideoMode` |
| `display.power` | ⚠️ conditional. `screen_off` needs owner / device-admin FORCE_LOCK / accessibility; `screen_on` is a **wake lock**, which works anywhere — but only since `812e89f`. On the fielded build `screen_on` is a logged no-op, which is why the Android baseline no longer claims this | ❌ a browser tab cannot power a panel — the overlay only paints black | ✅ both halves on every build, no signing needed: `showScreenOff()` / `clearScreenOff()`, plus the real panel API where `STDeviceControl` finds one | ⚠️ needs `hasHost()`. Media teardown always blanks; ❓ **CEC is unverified** — our XT245 resolves `@brightsign/cec` while the kernel logs `failed to get cec clock` and the display never responds |
| `display.resolution` | ❌ needs system/root | ❌ not addressable from a browser | ❌ no web-accessible mode setting on the TV profile | ⚠️ **declared but unreachable**`st-bridge.js` exposes `setVideoMode`, the page never calls it, and no command maps to this capability |
| `display.brightness` (per-window dim, Tier 0) | ✅ `set_brightness``setWindowBrightness`, no privilege needed | ❌ | ❌ | ❌ |
⚠️ `PlayerCapabilities.kt` **does not declare `display.brightness`**, though `MainActivity` handles
`set_brightness` unconditionally. So an *updated* Android panel loses the per-window dim slider that
an un-updated one keeps via the baseline. See gap 2.
## Remote view and control
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `remote.screenshot` | ⚠️ view capture always; full-screen only with accessibility or MediaProjection | ⚠️ canvas only — same-origin content, and the alpha probe rejects frames where no pixels arrived | ✅ `captureAndSend` | ⚠️ host framebuffer capture **requires primary storage**; falls back to canvas, which cannot read the video plane |
| `remote.stream` | ✅ | ✅ 1fps | ✅ | ⚠️ as web |
| `remote.input` | ✅ | ✅ | ✅ | ✅ |
| `remote.screenshot` | ⚠️ `captureView` always (a real frame of the player's own view); full-screen only with accessibility or MediaProjection. Declared **only** for the full-screen path | ⚠️ canvas only — same-origin content, and the alpha probe rejects frames where no pixels arrived | ⚠️ `captureAndSend` captures **images only**; video and YouTube get an honest status card reading "Live preview unavailable for video / YouTube on Tizen" | ⚠️ `st-bridge.js` gates host framebuffer capture on **primary storage**; without a disk it falls back to canvas, which cannot read the video plane |
| `remote.stream` | ✅ | ✅ 1fps | ✅ 1s interval over `captureAndSend`, so the same image-only limit | ⚠️ as web |
| `remote.input` | ✅ `TouchInjector` — plain `dispatchTouchEvent`, no privilege | ✅ | ✅ `elementFromPoint().click()` + D-pad/volume keys | ✅ synthesised DOM events, needs no host |
## Lifecycle
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `system.restart_player` | ✅ | ✅ `location.reload()` | ✅ | ✅ host rebuilds the widget — a page reload does not reliably return |
| `system.reboot` | ✅ device owner | ❌ a browser tab cannot reboot its host | ❌ no Tizen API exposed to the app | ✅ `RebootSystem()` via the host |
| `system.self_update` | ✅ APK OTA (`UpdateChecker`) | ❌ the server deploys the player; there is nothing for it to update | ❌ `.wgt` updates go through Tizen's own store/CLI | ✅ `autorun.zip` package update |
| `system.restart_player` | ✅ `launch` / `refresh` | ✅ `location.reload()` | ✅ `location.reload()` via `STDeviceControl` | ⚠️ needs `hasHost()` so the host rebuilds the widget. **A page-initiated reload does not reliably bring an roHtmlWidget back** — that darkened a customer's panel on 2026-07-28, which is why neither `st-bridge.js` nor the baseline offers this without a host |
| `system.reboot` | ⚠️ **device owner only** (`STPolicy.reboot()`). Off-owner it degrades to an accessibility power *dialog*, which needs someone at the screen | ❌ a browser tab cannot reboot its host | ⚠️ only on a **partner-signed** panel where `STDeviceControl.capabilities().reboot` is true | ⚠️ `RebootSystem()` via the host |
| `system.self_update` | ✅ APK OTA (`UpdateChecker`), and `update` forces a check | ❌ the server deploys the player; there is nothing for it to update | ❌ a `.wgt` is installed by the panel, not the app | 💀 **for the dashboard button.** The host really does self-update — `autorun.brs` polls `CheckPackageUpdate` every `PKG_CHECK_MS` — but that is a host-side poll on a socket it is not listening to. The page declares `system.self_update` behind `hasHost()`, the dashboard renders "Force update", and `index.html` has **no `update` branch at all**. See gap 3 |
## Device management
Android device-owner territory. Everything here is ❌ elsewhere for the same reason — no equivalent
privilege model exists on those platforms — so the column is collapsed.
privilege model exists on those platforms — so the column is collapsed. Tizen and BrightSign both
decline these explicitly and in writing in their own capability modules.
| capability | Android | Web / Tizen / BrightSign |
|---|---|---|
| `system.kiosk` | ✅ lock-task, now persisted across reboot | ❌ no device-owner concept |
| `system.brightness` | ✅ Tier 0/1 | ❌ |
| `system.screen_timeout` | ✅ Tier 1 | ❌ |
| `system.install_apk` | ✅ Tier 2 | ❌ not an APK platform |
| `system.shell` | ✅ Tier 2, handled in `WebSocketService` | ❌ |
| `system.time` | ✅ Tier 2 | ❌ |
| `system.kiosk` | ⚠️ owner-only. Off-owner `startLockTask()` is screen pinning, which prompts — unusable on a panel with no input | ❌ no device-owner concept |
| `system.brightness` | ⚠️ `WRITE_SETTINGS` **or** owner (`setSystemSetting`) | ❌ |
| `system.screen_timeout` | ⚠️ same gate as above | ❌ |
| `system.install_apk` | ⚠️ owner **or** a foreign DPC that delegated the install scope | ❌ not an APK platform |
| `system.shell` | ✅ declared unconditionally — it is an **app-UID** `sh -c`, not root, so it works at any tier. Handled in `WebSocketService` | ❌ |
| `system.time` | ⚠️ owner-only | ❌ |
| `system.device_owner` | 💀 **declared by nobody.** See the red section above | ❌ |
## Synchronisation and resilience
| capability | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| `sync.clock` | ✅ | ✅ | ✅ | ✅ |
| `sync.native` | ❌ no native protocol | ❌ | ❌ | ⚠️ SyncManager, BOS 8.2.10+; multicast so all members must share one L2 network |
| `offline.cache` | ✅ content downloaded to disk, **resumable** (Range + If-Range), revision-keyed | ✅ service worker, **resumable chunked prefetch**, revision-keyed | ✅ **media cached to `wgt-private`** (`js/media-cache.js`), resumable, revision-keyed — declared at runtime | ⚠️ **depends on the host widget's storage config — see below** |
| `sync.clock` | ✅ `GroupScheduleController` | ✅ | ✅ `syncedNow()` + `schedule-eval.js` | ✅ as web |
| `sync.native` | ❌ no native protocol | ❌ | ❌ | ⚠️ `st-sync.js` / SyncManager, gated on module presence **and** BOS 8.2.10+ (below the floor the module can resolve and silently do nothing, which on a wall means every panel reports healthy while drifting). ❓ **unverified on hardware** |
| `offline.cache` | ✅ `ContentCache` + `DownloadCoordinator`, resumable (Range/If-Range), revision-keyed | ✅ service worker, resumable chunked prefetch, revision-keyed; declared only when a worker is genuinely **controlling** the page | ⚠️ `js/media-cache.js` caches media to `wgt-private`**new at HEAD**, absent from the fielded build, and declared at runtime only where the platform grants storage | ❓ **unverified.** See gap 4 |
---
## Where the four declaration sites disagree with each other
| | Android | Web | Tizen | BrightSign |
|---|---|---|---|---|
| declaration site | `telemetry/PlayerCapabilities.kt` | `declaredCapabilities()` in `index.html` | `js/capabilities.js` | **`index.html` again** |
⚠️ **`brightsign/st-bridge.js` `computeCapabilities()` IS DEAD CODE.** It is exported as
`BS.capabilities`, and nothing calls it: `grep -n "BS\.[a-zA-Z]*(" server/player/index.html` lists
23 bridge calls and `capabilities` is not among them. The BrightSign declaration actually comes
from the web player's `declaredCapabilities()`, and the two disagree substantially:
| capability | `st-bridge.js` says | `index.html` actually declares | which is right |
|---|---|---|---|
| `offline.cache` | `navigator.serviceWorker` **exists** | a worker is **controlling** the page | index.html. The bridge's version is the exact lie that shipped on the XT245 |
| `remote.screenshot` | needs `probe.storage_present` | any 2d canvas | the bridge. A canvas cannot read the video plane |
| `remote.stream` | needs `probe.storage_present` | unconditional | the bridge |
| `system.self_update` | needs `probe.storage_present` | needs `hasHost()` | the bridge — staging `autorun.zip` needs a volume |
| `display.rotation` | needs a host (`roVideoMode`) | unconditional (CSS) | the bridge, for video |
| `display.power` | needs `CecClass` | needs `hasHost()` | roughly equivalent |
| `display.resolution` | host **or** `VideoOutputClass` | needs `hasHost()` | the bridge |
| `system.restart_player` | needs a host | unconditional | the bridge — see the 2026-07-28 incident |
| `sync.native` | module **and** OS ≥ 8.2.10 | `ScreenTinkerBSSync.available()`, which is **module presence only** | the bridge. `index.html` skips the firmware floor |
**`server/test/brightsign-capabilities.test.js` is 199 lines of thorough tests for this dead
function.** Every one passes, and none of them constrains what a BrightSign actually declares. That
is worse than no coverage: it reads as proof.
The fix is small and belongs to whoever owns those files — have `declaredCapabilities()` return
`BS.capabilities()` when `BS.isBrightSign()`, and the storage/firmware gating that was already
written and tested starts being true.
---
## Real gaps worth closing
Ordered by how visible the failure is to an operator.
Prioritised by how visible the failure is to an operator. **None of these are implemented here**
other agents own the player files.
1. **Tizen `audio.volume` — dead control.** `set_volume` has no handler in `tizen/js/app.js`; the
only volume path is the on-device `KEYCODE_VOLUME_*` keys. The dashboard slider silently does
nothing. Either implement the handler or let the capability hide the control.
2. ~~**Tizen `offline.cache` is partial.**~~ **Closed.** `tizen/js/media-cache.js` caches the
media itself to `wgt-private` — resumable, so a panel on a bad link accumulates an asset
across attempts instead of restarting from zero, and revision-keyed, so a replaced asset is
still a miss. The capability is declared at runtime rather than assumed: a build that cannot
write to private storage keeps quiet about it.
3. **BrightSign offline caching is NOT automatic — it depends on who created the widget.** A real
XT245 on alpha exposes `navigator.serviceWorker`, and then never even fetches `sw.js`:
registration is refused, so there is no worker, no content cache and no offline playback. That
unit is running **BSN's Supervisor** (`autorun.createdby = Supervisor 2.1.18.3`) rather than our
`brightsign/autorun.brs`, and Supervisor's widget has no `storage_path` — the setting our own
host script does set (`storage_path: "/cache"`, `storage_quota: "1073741824"`), and the
precondition for a widget having persistent storage at all. So this is very likely a widget
CONFIG issue rather than a platform limit, but **it is unverified on hardware**: nobody has yet
watched a player running our package register a worker.
| # | gap | difficulty | why it matters |
|---|---|---|---|
| 1 | **`set_volume` payload mismatch** (`server/player/index.html`, `tizen/js/app.js`) — accept `level` (0..1) alongside `value` (0..100). | **trivial** — one line each | The volume slider is dead on 3 of 4 players. Highest visibility, lowest cost in the list. |
| 2 | **`PlayerCapabilities.kt` under-declares.** Add `display.brightness` (Tier 0, `setWindowBrightness`, always available) and `system.device_owner` under `if (isOwner)`. | **trivial** | Updating an Android panel currently *loses* it the per-window dim slider, and keeps the Tier-2 stand-in in `player-capabilities.js` necessary. |
| 3 | **BrightSign "Force update" is a dead button.** `index.html` has no `update` branch; the host self-updates on its own poll. Either handle `update` by posting a bridge message that triggers `CheckPackageUpdate` now, or stop declaring `system.self_update`. | **small** (a bridge message + a BrightScript branch) | The button is rendered on every host-bridged BrightSign and does nothing. |
| 4 | **`declaredCapabilities()` should defer to `BS.capabilities()` on BrightSign.** | **small** | Turns 199 lines of already-written, already-passing tests from decoration into enforcement, and fixes six wrong declarations at once — including `offline.cache`, the one that shipped a lie to a real customer panel. |
| 5 | **The capture-bootstrap button is hidden where it is needed** (`device-detail.js`, `can('remote.screenshot')`). Render it whenever the device is Android and lacks `remote.screenshot`. | **small** | Server-side gating is fixed; the UI half is not. |
| 6 | **Tizen `remote.screenshot` is images-only.** Video and YouTube return a status card. AVPlay has no readable surface for a canvas. | **hard**, possibly impossible | Honest today, but an operator checking a video panel gets a card instead of a picture. |
| 7 | **BrightSign transitions/PiP over video.** DOM composited over a hwz hardware plane may be invisible. The likely fix is `roVideoMode.SetGraphicsZOrder("front")`, deliberately not applied blind. | **medium**, ❓ **needs hardware** | Changing z-order blind risks hiding video entirely on a player that currently works. |
| 8 | **BrightSign offline caching is unproven either way.** See below. | ❓ **needs hardware** | |
The player no longer lies about it either way — `offline.cache` is declared only when a worker
is genuinely in control, and a refused registration reports `app_error/sw_unavailable` to the
server instead of a `console.warn` on a display nobody has a console for.
### The BrightSign `offline.cache` question, stated honestly
3. **BrightSign `remote.screenshot` needs primary storage.** Reachable today only via the canvas
fallback, which cannot read the video plane, so screenshots show everything except the video.
Resolves itself when a card or SSD is fitted.
4. **`display.resolution` is BrightSign-only.** Fine, but the dashboard should not offer it
elsewhere.
A real XT245 on alpha exposes `navigator.serviceWorker`, and then never even fetches `sw.js`:
registration is refused, so there is no worker, no content cache and no offline playback. That unit
runs **BSN Supervisor** (`autorun.createdby = Supervisor 2.1.18.3`) rather than our
`brightsign/autorun.brs`, and Supervisor's widget has no `storage_path` — the setting our own host
script does set (`storage_path: "/cache"`, `storage_quota: "1073741824"`) and the precondition for a
widget having persistent storage at all.
So this is *very likely* a widget CONFIG issue rather than a platform limit. **It is unverified: no
one has yet watched a player running our package register a worker.** Until someone has, this
document does not claim it, the `brightsign` baseline does not grant it, and the player declares it
only when a worker is genuinely in control — a refused registration reports
`app_error/sw_unavailable` to the server rather than a `console.warn` on a display nobody has a
console for.
## Correctly impossible — do not "fix" these
- **`system.reboot` on web/Tizen.** No API exists. A browser tab rebooting its host would be a
browser vulnerability.
- **`system.reboot` on web.** No API exists. A browser tab rebooting its host would be a browser
vulnerability.
- **`display.power` on web.** The overlay is the honest maximum; the panel stays lit.
- **All of device management off Android.** No equivalent privilege model exists on Tizen or
BrightSign, and a web player has no device to manage.
- **Device management off Android.** No equivalent privilege model exists on Tizen or BrightSign,
and a web player has no device to manage.
- **`system.self_update` on web.** The player *is* the deployment; there is nothing to update.
- **`sync.native` off BrightSign.** It is BrightSign's own protocol, and the clock-derived one is
the cross-platform answer that already works everywhere.
- **`display.resolution` off BrightSign.** No other platform exposes mode setting to an app.
## ⚠️ Corrections needed in `player-capabilities.js`
---
Found while verifying this table. The baselines only apply to displays that declare nothing, so
these are wrong for the existing fleet until each player ships its declaration:
## Baselines: what an un-updated display is assumed to be able to do
- **`tizen` claims `audio.volume`** — no handler exists (gap 1 above). Should be removed.
- **`tizen` omits `remote.screenshot` and `remote.stream`** — both are implemented
(`captureAndSend`, `startStreaming`). Should be added.
- **`tizen` declares `offline.cache` itself now** — the server baseline still omits it, which is
correct: a fielded panel that has not been updated genuinely cannot hold media, and the
baseline describes what an un-updated one can do.
~446 fielded displays declare nothing and fall back to `BASELINE` in
`server/lib/player-capabilities.js`. Because v1.9.29 is the first build in which *any* player
declares anything, every display reading a baseline is running **v1.9.28 or older by construction**
— so each entry below is justified against `git show v1.9.28:<player source>`, not against HEAD.
`server/test/player-parity-baselines.test.js` pins these to the player sources.
### Corrections made in this pass
| baseline | change | evidence |
|---|---|---|
| `android` | **removed `display.power`** | v1.9.28 `MainActivity`: `"screen_on" -> Log.w("no privileged wake path on a non-rooted panel — no-op")`. The ON half is dead on 100% of fielded panels, and one capability renders **both** buttons. |
| `android` | **removed `system.reboot`** | `STPolicy.reboot()` requires device owner; off-owner v1.9.28 shows the accessibility power *dialog* — which on the accessibility-enabled panels common in this fleet paints that dialog **over the signage**. Owner provisioning is unreleased (#161 / PR #168 still open), so "device owner AND pre-1.9.29" is effectively an empty set. |
| `tizen` | **added `display.power`** | v1.9.28 `app.js` implements both halves with no signing and no panel API: `showScreenOff()` / `clearScreenOff()` + `keepAwake()`. Unlike Android, neither half is privilege-gated. Withholding it hid a working control on every Tizen panel. |
| `web` | **removed `audio.volume`** | v1.9.28 `index.html` contains the string `set_volume` **zero** times, and HEAD's handler reads the wrong payload key. |
| `brightsign` | **removed `audio.volume`, `display.power`, `system.reboot`, `system.restart_player`, `offline.cache`** | All five need a host bridge (`hasHost()`) or a service worker that a Supervisor-built widget refuses. `system.restart_player` is the 2026-07-28 panel-blackout path. `offline.cache` is the documented lie this whole model exists to stop. |
### Consequence, deliberately accepted
`server/services/scheduler.js` gates the nightly scheduled reboot on `system.reboot`. Removing it
from the Android baseline means scheduled reboots now **no-op for undeclared Android panels**
instead of logging `scheduled reboot fired` for a panel that never rebooted. That log line is the
stated reason the gate exists; skipping is the honest answer, and an owner panel on v1.9.29+
declares `system.reboot` for itself and is unaffected.
### The resulting baselines
| capability | android | tizen | brightsign | web |
|---|---|---|---|---|
| `playback.*` (all 7) | ✅ | ✅ | ✅ | ✅ |
| `audio.mute` | ✅ | ✅ | ✅ | ✅ |
| `audio.volume` | ✅ | ❌ no handler in v1.9.28 | ❌ payload | ❌ no handler in v1.9.28 |
| `display.rotation` | ✅ | ✅ | ⚠️ graphics only | ✅ |
| `display.power` | ❌ `screen_on` is a no-op | ✅ | ❌ needs host | ❌ |
| `display.brightness` | ✅ Tier 0, since v1.9.10 | ❌ | ❌ | ❌ |
| `remote.screenshot` / `remote.stream` | ✅ view capture | ✅ images only | ❌ no video plane | ✅ |
| `remote.input` | ✅ | ✅ | ✅ | ✅ |
| `system.restart_player` | ✅ | ✅ | ❌ widget may not return | ✅ |
| `system.self_update` | ✅ | ❌ | ❌ needs host | ❌ |
| `system.reboot` | ❌ owner-only | ❌ | ❌ needs host | ❌ |
| `sync.clock` | ✅ | ✅ | ✅ | ✅ |
| `offline.cache` | ✅ | ❌ playlist JSON only | ❌ ❓ unverified | ✅ |
Everything conditional at runtime on every platform that has it at all — `system.kiosk`,
`system.brightness`, `system.screen_timeout`, `system.install_apk`, `system.shell`, `system.time`,
`system.device_owner`, `sync.native`, `display.resolution` — is absent from **every** baseline, and
a test enforces that.
## What is tested, and what cannot be
`server/test/player-parity-baselines.test.js` reads the player sources and fails when they and the
claims disagree:
- every baseline and command-map name is in the vocabulary, and no baseline has duplicates;
- **the dead-button rule** — every gated command has a branch in some player;
- **the unreachable-capability rule** — every gating capability is either declared by some player's
source or granted by some baseline. *This is the test that would have caught the
`system.device_owner` bug*;
- a device-owner Android panel can actually be sent all five Tier-2 commands, and an ordinary one is
still refused them **by name**;
- `audio.volume` and `offline.cache` are **biconditional** against the player sources, so a fix in a
player fails the test until the baseline is updated;
- no baseline claims a conditional capability, and the BrightSign baseline claims nothing behind
`hasHost()`;
- every capability-shaped string quoted in any player is one the server knows — the server's parser
*drops* unknown names, so a typo silently removes a control rather than raising anything.
**Not testable from source, and asserted nowhere:** whether CEC reaches a real display; whether a
widget built by our own `autorun.brs` is permitted to register a service worker; whether SyncManager
genuinely holds a wall in frame lock; whether transitions and PiP are visible over a hwz video
plane. Each is marked ❓ above and needs hardware.

View file

@ -56,65 +56,143 @@ const CAP_SET = new Set(CAPABILITIES);
/*
* Baselines for displays that declare nothing.
*
* Only things that have always worked on that platform. Anything conditional screenshots that
* need accessibility, kiosk that needs device owner, native sync that needs one L2 network is
* omitted, so a legacy display shows those controls only once it declares them. Better a control
* that appears late than one that lies today.
* THE RULE, and it is the only one that keeps this table honest: a baseline entry describes what
* the LAST RELEASED player for that platform does, unconditionally, with no privilege it might not
* have been granted. Not what HEAD does HEAD declares for itself. Not what the platform could do
* a capability nobody shipped is a button nobody can press.
*
* Every entry below was checked against `git show v1.9.28:<player source>`, the last release before
* capability declaration existed at all, because v1.9.29 is the first build in which any player
* declares anything. Every display that falls back to a baseline is therefore running v1.9.28 or
* older by construction, and that is the build the justifications cite.
*/
const BASELINE = {
android: [
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
// set_volume and set_brightness are #160 Track-A, released in v1.9.10 — long before anything
// still in the field. Both are Tier 0: MainActivity applies them with no owner, no admin and
// no WRITE_SETTINGS, so they are unconditional on any build a fielded panel could be running.
'audio.mute', 'audio.volume',
'display.rotation', 'display.power', 'display.brightness',
'remote.screenshot', 'remote.stream', 'remote.input',
'system.reboot', 'system.restart_player', 'system.self_update',
'display.rotation', 'display.brightness',
// Capture without accessibility falls back to ScreenshotCapture.captureView, which is a real
// frame of the player's own view — i.e. of the content. Narrower than the full-screen path,
// but the operator gets a picture, not a dead button.
'remote.screenshot', 'remote.stream',
'remote.input',
'system.restart_player', 'system.self_update',
'sync.clock', 'offline.cache',
// NOT display.power. v1.9.28 MainActivity answers screen_on with
// Log.w("screen_on: no privileged wake path on a non-rooted panel — no-op")
// so the ON half is dead on 100% of fielded Android panels, and screen_off only works with
// owner / device-admin / accessibility. The dashboard renders BOTH buttons off this one
// capability. A panel you can sleep and cannot wake is the worst possible version of this
// feature, which is exactly why PlayerCapabilities.kt gates its own claim on both halves.
//
// NOT system.reboot. STPolicy.reboot() requires device owner; off-owner v1.9.28 falls back to
// the accessibility power DIALOG, which needs a human standing at the screen — and on the
// accessibility-enabled panels that are common in this fleet it paints that dialog OVER the
// signage. Device-owner provisioning is not released (#161/PR #168 is still open), so the set
// of panels that are both device owner AND pre-1.9.29 is effectively empty.
// ⚠️ Consequence, deliberately accepted: services/scheduler.js gates the nightly scheduled
// reboot on this capability, so scheduled reboots now no-op for undeclared Android panels
// instead of logging "scheduled reboot fired" for a panel that never rebooted. That log line
// is the reason the gate is there; the honest answer is to skip, not to claim.
//
// NOT system.shell / system.kiosk / system.time / system.install_apk / system.brightness /
// system.screen_timeout: every one is device-owner or WRITE_SETTINGS conditional.
],
tizen: [
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
// audio.mute only. A FIELDED Tizen panel has no set_volume handler at all — the command falls
// through to "unknown command", so the dashboard slider does nothing. Updated panels declare
// audio.volume for themselves once they ship a handler; the baseline describes what an
// un-updated one can actually do, which is the whole reason it exists.
// audio.mute only. `git show v1.9.28:tizen/js/app.js` has NO set_volume handler — the command
// falls through STDeviceControl.run to "unknown command", so the dashboard slider does nothing
// on every fielded panel. (HEAD ships applyVolume, but see the note on BASELINE.web: it reads
// payload.value while the dashboard sends payload.level, so even HEAD's slider is dead. The
// baseline stays out until a released .wgt honours the payload the product actually sends.)
'audio.mute',
'display.rotation',
// Both really are implemented in the shipped player (captureAndSend / startStreaming), so
// omitting them would have hidden working controls on every legacy Tizen panel.
'remote.screenshot', 'remote.stream',
'remote.input',
// ADDED after audit. v1.9.28 app.js implements BOTH halves with no partner signing and no
// panel API: screen_off -> showScreenOff() paints the blanking overlay, screen_on ->
// clearScreenOff() + keepAwake(). Unlike Android above, neither half is privilege-gated, so
// the pair is honest. The panel backlight stays lit — the log line says which mechanism ran —
// but the screen genuinely goes dark, and HEAD's capabilities.js declares it for that reason.
'display.power',
'system.restart_player',
'sync.clock',
// NOT offline.cache: Tizen caches only the playlist JSON (st_payload_cache in localStorage).
// There is no service worker and no media cache, so the bytes still come from the network and
// content does NOT survive an outage. My first baseline claimed it — caught by the platform
// audit, and exactly the kind of optimistic claim this model exists to stop.
// NOT offline.cache: v1.9.28 has no tizen/js/media-cache.js at all (the file is new at HEAD).
// The fielded player caches only the playlist JSON (st_payload_cache in localStorage), so an
// outage leaves the panel knowing exactly what it cannot show. My first baseline claimed it —
// caught by the platform audit, and exactly the kind of optimistic claim this model exists to
// stop.
],
/*
* A BrightSign that declares nothing is a BrightSign we cannot prove has a host bridge, and that
* is the whole story of this baseline.
*
* The JS half of the bridge is served BY US (server.js routes /player/st-bridge.js at
* brightsign/st-bridge.js), so it is always current but it is only half. `port` exists only
* inside an roHtmlWidget created with nodejs_enabled:true, which is the on-device BrightScript's
* decision, and `git ls-tree v1.9.28 brightsign/` shows no st-bridge.js at all: no released
* package ever shipped the two halves as a pair. The one real BrightSign we have runs BSN
* Supervisor's widget rather than our autorun.brs, and BS.hasHost() is false on it.
*
* A unit that DOES have a bridge declares for itself and never reads this list the page
* computes hasHost() at registration. So this baseline only ever answers for a row that has not
* re-registered, and the right answer for a display we know nothing about is the floor:
* everything below is "the web player with no bridge", and nothing above that.
*/
brightsign: [
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
'audio.mute', 'audio.volume',
'display.rotation', 'display.power',
'audio.mute',
// CSS transform. Graphics rotate; with hwz the video sits on a hardware plane that ignores it,
// so this is partial — but nothing routes a COMMAND to display.rotation and no control is
// gated on it, so the entry describes content rendering rather than offering a button.
'display.rotation',
'remote.input',
'system.reboot', 'system.restart_player',
'sync.clock',
// NOT offline.cache. A BrightSign widget EXPOSES navigator.serviceWorker and will not run one:
// our XT245 on alpha passes every presence check and then never even fetches sw.js. There is no
// other caching mechanism in the widget either — content comes off the network every time — so
// a legacy BrightSign that declares nothing has no offline story at all, and claiming one told
// the dashboard a panel would survive an outage that will in fact go blank.
// NOT offline.cache. This is the documented case, not a hypothetical: the XT245 on alpha has
// navigator.serviceWorker, passes every presence check, and then never fetches sw.js because
// its widget refuses the registration. It advertised offline caching to the fleet and could
// not cache one byte. A widget with no storage_path has no persistent storage at all, and the
// baseline cannot know which kind of widget it is talking to.
//
// NOT system.restart_player. `refresh` reaches restartPlayer(), which without a host does
// location.reload() — and a page-initiated reload does not reliably bring an roHtmlWidget
// back. That is what darkened a customer's panel on 2026-07-28. st-bridge.js withholds this
// for the same reason; a baseline that hands it to every undeclared unit undoes that.
//
// NOT system.reboot / display.power / display.resolution / system.self_update: all four are
// BrightScript calls through a bridge this unit is not known to have.
//
// NOT audio.volume / remote.screenshot / remote.stream: see BASELINE.web — the volume payload
// never lands, and a canvas capture on a hwz player cannot read the video plane, so it returns
// a frame with a hole where the content is.
],
// A browser tab. Deliberately the smallest set: it cannot reboot its host, rotate a panel, or
// capture anything outside its own document.
web: [
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
'audio.mute', 'audio.volume',
'audio.mute',
'display.rotation',
'remote.screenshot', 'remote.stream', 'remote.input',
'system.restart_player',
'sync.clock', 'offline.cache',
// NOT audio.volume, removed after audit, and for two independent reasons:
// 1. `git show v1.9.28:server/player/index.html` has no set_volume handler at all — zero
// occurrences of the string. The fielded browser player ignores the command outright.
// 2. Even at HEAD the slider cannot work: index.html reads `data.payload?.value ?? data.value`
// and tizen/js/app.js reads `payload.value ?? payload.volume`, while the dashboard sends
// `{ level: 0..1 }` (frontend/js/views/device-detail.js bindLevel). Only the Android
// handler reads `level`. Fixing that is one line in each player, and
// test/player-parity-baselines.test.js is written as a BICONDITIONAL: the moment a player
// accepts `level`, the test fails and tells you to put the baseline entry back.
],
};
@ -193,6 +271,11 @@ function parseDeclared(raw) {
*
* A command mapped to null needs no capability: it is a diagnostic every player understands, and
* refusing it would remove the tool you use to work out why a panel is misbehaving.
*
* A command may map to a LIST, meaning any one of them is enough. That is not a convenience: it is
* how a capability name that no shipped player declares stays in the vocabulary without taking its
* commands down with it. The first name in the list is the canonical one and is what a refusal
* reports, so the operator is told what the panel is missing in the vocabulary they see elsewhere.
*/
const COMMAND_CAPABILITY = {
// lifecycle
@ -219,18 +302,49 @@ const COMMAND_CAPABILITY = {
// device-owner surface (#161 Tier-2)
kiosk_lock: 'system.kiosk',
kiosk_unlock: 'system.kiosk',
lock_now: 'system.device_owner',
power_menu: 'system.device_owner',
status_bar: 'system.device_owner',
block_uninstall: 'system.device_owner',
unblock_uninstall: 'system.device_owner',
/*
* These five were UNREACHABLE for the entire fleet until this audit, and nothing failed
* loudly enough to notice.
*
* 'system.device_owner' is declared by NO player. It is not in PlayerCapabilities.kt, not in
* tizen/js/capabilities.js, not in the web player's declaredCapabilities(), not in st-bridge.js,
* and not in any baseline. So `supports()` returned false for every device on every platform,
* and every one of these commands was refused including on the device-owner panels the whole
* #161 Tier-2 surface was built for. The dashboard still rendered the buttons, because
* device-detail.js gates that block on `device.tier === 2 ||` as well, so an operator on a real
* owner panel pressed "Lock now" and got a silent server-side refusal.
*
* Until a player declares 'system.device_owner' for itself, 'system.kiosk' stands in, and it is
* an exact stand-in rather than a loose one: PlayerCapabilities.kt declares system.kiosk under
* `if (isOwner)` and nothing else, which is precisely the condition under which STPolicy's
* owned() actions setStatusBarDisabled, setUninstallBlocked, lockNow, reboot do anything.
* No non-Android player declares system.kiosk; Tizen and BrightSign both refuse it explicitly
* and in writing, so this cannot leak the commands onto a platform that would swallow them.
*
* The canonical name stays first so a refusal still says 'system.device_owner'.
*/
lock_now: ['system.device_owner', 'system.kiosk'],
power_menu: ['system.device_owner', 'system.kiosk'],
status_bar: ['system.device_owner', 'system.kiosk'],
block_uninstall: ['system.device_owner', 'system.kiosk'],
unblock_uninstall: ['system.device_owner', 'system.kiosk'],
set_time: 'system.time',
set_timezone: 'system.time',
shell: 'system.shell',
install_apk: 'system.install_apk',
// remote view
enable_system_capture: 'remote.screenshot',
/*
* Remote view. Ungated, and the reason is a circle: enable_system_capture asks Android to raise
* the MediaProjection consent dialog, which is how a panel GAINS full-screen capture. Gating it
* on 'remote.screenshot' meant the only panel that needs it one with neither accessibility nor
* a projection grant, which therefore declares no remote.screenshot was the one panel that
* could not be sent it. A bootstrap cannot require the thing it bootstraps.
*
* The dashboard still has the other half of this bug: device-detail.js renders the "enable
* system view" button behind `can('remote.screenshot')`. Fixing that is a frontend change and is
* written up in docs/player-parity.md; ungating the command is the half that lives here.
*/
enable_system_capture: null,
// Diagnostics: deliberately unrestricted. set_debug turns on the log stream you need precisely
// when a panel is behaving in a way its capability declaration did not predict.
@ -238,13 +352,29 @@ const COMMAND_CAPABILITY = {
};
/**
* The capability a command requires, or null when it needs none.
* Unknown commands also return null this map gates, it does not authorise: the allow-list of
* Every capability that would satisfy `type`, as an array. Empty means the command is ungated.
* Unknown commands are ungated too this map gates, it does not authorise: the allow-list of
* valid command names lives with the routes, and duplicating it here would mean a new command
* silently stops working until someone remembers to add it in two places.
*
* @param {string} type
* @returns {string[]}
*/
function capabilitiesForCommand(type) {
if (!Object.prototype.hasOwnProperty.call(COMMAND_CAPABILITY, type)) return [];
const value = COMMAND_CAPABILITY[type];
if (value === null || value === undefined) return [];
return Array.isArray(value) ? value.slice() : [value];
}
/**
* The CANONICAL capability a command requires, or null when it needs none.
* Kept returning a single string because that is what a refusal reports and what the dashboard
* puts in front of an operator: "needs system.device_owner" is an answer, an array is a puzzle.
*/
function capabilityForCommand(type) {
return Object.prototype.hasOwnProperty.call(COMMAND_CAPABILITY, type) ? COMMAND_CAPABILITY[type] : null;
const list = capabilitiesForCommand(type);
return list.length ? list[0] : null;
}
/**
@ -252,13 +382,13 @@ function capabilityForCommand(type) {
* @returns {{ok: true} | {ok: false, capability: string}}
*/
function commandAllowed(device, type) {
const cap = capabilityForCommand(type);
if (!cap) return { ok: true };
if (supports(device, cap)) return { ok: true };
return { ok: false, capability: cap };
const needed = capabilitiesForCommand(type);
if (!needed.length) return { ok: true };
if (needed.some((cap) => supports(device, cap))) return { ok: true };
return { ok: false, capability: needed[0] };
}
module.exports = {
CAPABILITIES, CAP_SET, BASELINE, capabilitiesFor, supports, platformFamily, parseDeclared,
COMMAND_CAPABILITY, capabilityForCommand, commandAllowed,
COMMAND_CAPABILITY, capabilityForCommand, capabilitiesForCommand, commandAllowed,
};

View file

@ -167,6 +167,6 @@ test('the device API returns the RESOLVED list, so the dashboard never re-derive
assert.equal(b.status, 200);
assert.ok(Array.isArray(b.body.capabilities) && b.body.capabilities.length > 0,
'an undeclared Android panel must come back with its baseline, not an empty list');
assert.ok(b.body.capabilities.includes('system.reboot'),
assert.ok(b.body.capabilities.includes('system.restart_player'),
'and that baseline is what keeps the existing fleet\'s controls on screen');
});

View file

@ -24,7 +24,9 @@ test('THE FLEET CASE: an absent declaration is not persisted, so the baseline st
assert.equal(persistedValue(undefined), null);
assert.equal(persistedValue(null), null);
const legacy = { client_type: 'apk' }; // column stays NULL
assert.ok(caps.supports(legacy, 'system.reboot'), 'legacy Android keeps its controls');
// Was system.reboot until the parity audit: STPolicy.reboot() needs device owner, so the
// undeclared fleet never had that one. restart_player is a control it genuinely does have.
assert.ok(caps.supports(legacy, 'system.restart_player'), 'legacy Android keeps its controls');
});
test('an EMPTY declaration IS persisted and is honoured as "nothing"', () => {

View file

@ -27,9 +27,16 @@ test('the legacy fleet is not locked out of the commands it has always accepted'
// nothing, and a refusal keyed off "declared nothing => supports nothing" bricks every control
// in the product at once.
const legacy = { client_type: 'apk', android_version: '9' };
for (const cmd of ['reboot', 'launch', 'refresh', 'update', 'screen_on', 'screen_off', 'set_volume']) {
for (const cmd of ['launch', 'refresh', 'update', 'set_volume', 'set_brightness']) {
assert.equal(caps.commandAllowed(legacy, cmd).ok, true, `${cmd} must still reach a legacy Android panel`);
}
// reboot / screen_on / screen_off are NOT on that list any more, and that is the parity audit's
// finding rather than an oversight: v1.9.28 answers screen_on with a logged no-op on every
// panel, and STPolicy.reboot() needs device owner. Keeping them would have been the other half
// of the same bug — a control that appears to work and changes nothing.
for (const cmd of ['reboot', 'screen_on', 'screen_off']) {
assert.equal(caps.commandAllowed(legacy, cmd).ok, false, `${cmd} is privilege-gated on a fielded panel`);
}
});
test('a command with no capability requirement is never refused', () => {
@ -66,9 +73,11 @@ test('the per-window dim is NOT the backlight — conflating them hides a workin
test('every command in the map points at a capability that actually exists', () => {
// A typo here does not fail loudly: supports() returns false for an unknown name, so the command
// is refused for EVERY device on every platform, forever.
for (const [cmd, cap] of Object.entries(caps.COMMAND_CAPABILITY)) {
if (cap === null) continue;
assert.ok(caps.CAP_SET.has(cap), `${cmd} maps to unknown capability ${cap}`);
for (const cmd of Object.keys(caps.COMMAND_CAPABILITY)) {
// A command may name several capabilities, any of which is enough; all of them must be real.
for (const cap of caps.capabilitiesForCommand(cmd)) {
assert.ok(caps.CAP_SET.has(cap), `${cmd} maps to unknown capability ${cap}`);
}
}
});

View file

@ -62,8 +62,18 @@ before(async () => {
});
S.groupId = g.body.id;
// Two Android panels (declare nothing -> baseline, i.e. the legacy fleet) and two browser tabs.
S.android = [mkDevice({ client_type: 'apk', android_version: '12' }), mkDevice({ client_type: 'apk', android_version: '12' })];
// Two Android panels and two browser tabs.
//
// The panels DECLARE system.reboot (i.e. they are device owners) rather than relying on the
// baseline. The parity audit removed system.reboot from the Android baseline — STPolicy.reboot()
// needs device owner, so an undeclared panel cannot honour it either — and with all four members
// unable to reboot, this test would still pass while proving nothing. The point here is the
// MIXED case: some members can, some cannot, and the response must not blur them together.
const owner = JSON.stringify(['playback.video', 'system.reboot', 'system.restart_player']);
S.android = [
mkDevice({ client_type: 'apk', android_version: '12', capabilities: owner }),
mkDevice({ client_type: 'apk', android_version: '12', capabilities: owner }),
];
S.web = [mkDevice({ android_version: 'Web/Chrome' }), mkDevice({ android_version: 'Web/Chrome' })];
for (const id of [...S.android, ...S.web]) {
db.prepare('INSERT INTO device_group_members (group_id, device_id) VALUES (?, ?)').run(S.groupId, id);
@ -73,10 +83,10 @@ after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL
function mkDevice(cols) {
const id = crypto.randomUUID();
db.prepare(`INSERT INTO devices (id, name, status, workspace_id, device_token, client_type, android_version, created_at)
VALUES (?, ?, 'offline', ?, ?, ?, ?, strftime('%s','now'))`)
db.prepare(`INSERT INTO devices (id, name, status, workspace_id, device_token, client_type, android_version, capabilities, created_at)
VALUES (?, ?, 'offline', ?, ?, ?, ?, ?, strftime('%s','now'))`)
.run(id, 'panel-' + id.slice(0, 4), S.wsId, crypto.randomBytes(16).toString('hex'),
cols.client_type || null, cols.android_version || null);
cols.client_type || null, cols.android_version || null, cols.capabilities || null);
return id;
}

View file

@ -17,8 +17,9 @@ const caps = require('../lib/player-capabilities');
test('a legacy display with NO declaration keeps its platform baseline', () => {
// The several-hundred-device case: they will not update before the next dashboard deploy.
const android = { client_type: 'apk', android_version: '12' };
assert.ok(caps.supports(android, 'system.reboot'));
assert.ok(caps.supports(android, 'system.restart_player'));
assert.ok(caps.supports(android, 'playback.video'));
assert.ok(caps.supports(android, 'offline.cache'));
});
test('THE DISTINCTION: an EMPTY declaration is honoured, not treated as missing', () => {
@ -80,13 +81,30 @@ test('every baseline entry is a real capability name', () => {
}
});
test('BrightSign claims display power and reboot; Tizen claims neither', () => {
// The concrete parity facts this whole model exists to express.
test('an UNDECLARED BrightSign is a browser tab, because the host bridge is unreleased', () => {
/*
* This test used to assert the opposite that a BrightSign claims display power and reboot
* and it was wrong in the way that matters: the page reaches all of them only behind
* BS.hasHost(), which needs an roHtmlWidget the on-device BrightScript created with node
* integration. No released package shipped that, the one real XT245 we have runs BSN
* Supervisor's widget where hasHost() is false, and any unit that DOES have a bridge declares
* for itself and never reads this baseline.
*/
const bs = { platform: 'brightsign' };
assert.equal(caps.supports(bs, 'system.reboot'), false, 'RebootSystem() needs a host that is not there');
assert.equal(caps.supports(bs, 'display.power'), false, 'CEC needs the same host');
assert.equal(caps.supports(bs, 'system.restart_player'), false,
'a page-initiated reload does not reliably bring an roHtmlWidget back — this one darkened a panel');
assert.ok(caps.supports(bs, 'playback.video'), 'it is still a player');
// A BrightSign that DOES declare gets everything its bridge really provides.
const withHost = { platform: 'brightsign', capabilities: JSON.stringify(['system.reboot', 'display.power']) };
assert.ok(caps.supports(withHost, 'system.reboot'));
// Tizen has a real blanking path on every build (showScreenOff/clearScreenOff, no signing
// needed) but no reboot without a partner-signed B2B surface it cannot assume.
const tizen = { platform: 'Tizen 6.5' };
assert.ok(caps.supports(bs, 'display.power'));
assert.ok(caps.supports(bs, 'system.reboot'));
assert.equal(caps.supports(tizen, 'display.power'), false);
assert.ok(caps.supports(tizen, 'display.power'), 'both halves work on a fielded .wgt');
assert.equal(caps.supports(tizen, 'system.reboot'), false);
});
@ -105,6 +123,11 @@ test('the baseline describes a FIELDED player, not the one we are about to ship'
// working controls disappear from every legacy Tizen display.
const tizen = { platform: 'Tizen 6.5' };
assert.equal(caps.supports(tizen, 'audio.volume'), false, 'the slider is dead on a fielded panel');
// Same answer on web and BrightSign, for a second and separate reason: those players read
// payload.value while the dashboard sends payload.level, so even HEAD's handler never fires.
assert.equal(caps.supports({ android_version: 'Web/Chrome' }, 'audio.volume'), false);
assert.equal(caps.supports({ platform: 'brightsign' }, 'audio.volume'), false);
assert.ok(caps.supports({ client_type: 'apk' }, 'audio.volume'), 'Android reads the payload it is sent');
assert.ok(caps.supports(tizen, 'audio.mute'), 'mute does work');
assert.ok(caps.supports(tizen, 'remote.screenshot'), 'captureAndSend exists in the shipped player');
assert.ok(caps.supports(tizen, 'remote.stream'), 'startStreaming exists in the shipped player');

View file

@ -0,0 +1,255 @@
'use strict';
/*
* The parity matrix keeps drifting away from the players, because a capability list is prose that
* happens to be executable: nothing breaks when it starts lying. Tizen claimed audio.volume with
* no set_volume handler anywhere in the shipped player, and the dashboard drew a slider that did
* nothing. Tizen claimed offline.cache while caching only the PLAYLIST, so a panel survived an
* outage knowing exactly what it could not show. A real BrightSign advertised offline.cache while
* its widget refuses to register a service worker at all.
*
* None of those were caught by a test, because every test asserted the table against itself.
*
* So these tests read the PLAYER SOURCES. They are grep-shaped and that is deliberate: the point is
* to fail when the code and the claim disagree, not to re-implement the players. Where a claim
* genuinely cannot be settled from source CEC actually reaching a display, a widget actually
* being allowed to register a worker there is no test here and docs/player-parity.md says so in
* as many words.
*
* Several of these are BICONDITIONAL: they fail both when a baseline over-claims and when a player
* gains the handler and the baseline was not updated. A test that only fires in one direction is
* how "too stingy" survives for months after the bug it was working around is fixed.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const caps = require('../lib/player-capabilities');
const ROOT = path.join(__dirname, '..', '..');
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
const SRC = {
web: read('server/player/index.html'),
android: [
'android/app/src/main/java/com/remotedisplay/player/MainActivity.kt',
'android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt',
'android/app/src/main/java/com/remotedisplay/player/telemetry/PlayerCapabilities.kt',
].map(read).join('\n'),
tizen: ['tizen/js/app.js', 'tizen/js/device-control.js', 'tizen/js/capabilities.js'].map(read).join('\n'),
brightsign: ['brightsign/st-bridge.js', 'brightsign/autorun.brs'].map(read).join('\n'),
};
/*
* Which command names each player has a branch for. A command a player never names is a dead
* button by construction the socket delivers it and the handler falls off the end.
*/
function handles(player, command) {
const src = player === 'brightsign' ? SRC.brightsign + SRC.web : SRC[player];
// Kotlin `"reboot" ->`, JS `case 'reboot':`, JS `type === 'reboot'`, `data.type === 'reboot'`.
return new RegExp(`['"]${command}['"]`).test(src);
}
test('every capability name in every baseline exists in the vocabulary', () => {
// A typo does not fail loudly: supports() returns false for an unknown name, so the control just
// never appears for that whole platform.
for (const [family, list] of Object.entries(caps.BASELINE)) {
for (const c of list) assert.ok(caps.CAP_SET.has(c), `${family} baseline has unknown capability ${c}`);
assert.equal(new Set(list).size, list.length, `${family} baseline has a duplicate entry`);
}
});
test('every command in the routing map points at capabilities that exist', () => {
for (const [cmd, value] of Object.entries(caps.COMMAND_CAPABILITY)) {
for (const cap of caps.capabilitiesForCommand(cmd)) {
assert.ok(caps.CAP_SET.has(cap), `${cmd} maps to unknown capability ${cap}`);
}
if (Array.isArray(value)) {
assert.ok(value.length > 1, `${cmd} is a one-element list — write it as a plain string`);
}
}
});
test('THE DEAD-BUTTON RULE: every gated command is handled by some player', () => {
// Not "by every player" — display.power is Android/Tizen/BrightSign and that is correct. But a
// command NO player names cannot do anything for anyone, and the capability gating it is a lie
// wherever it is declared.
const players = ['android', 'web', 'tizen', 'brightsign'];
for (const cmd of Object.keys(caps.COMMAND_CAPABILITY)) {
const who = players.filter((p) => handles(p, cmd));
assert.ok(who.length > 0, `no player has a branch for command '${cmd}' — it is a dead button everywhere`);
}
});
test('THE UNREACHABLE-CAPABILITY RULE: a gating capability must be reachable', () => {
/*
* The bug this catches, found by this audit: all five #161 Tier-2 commands were gated on
* 'system.device_owner', which no player declares and no baseline grants. supports() therefore
* returned false for every device in the fleet and the commands were refused universally,
* including on the device-owner panels they were built for.
*
* A capability is reachable if some player's source names it (it can be declared at runtime) or
* some baseline grants it (an undeclared display gets it). If neither is true, every command
* behind it is refused for every device, forever, in silence.
*/
const declaredAnywhere = new Set();
for (const src of Object.values(SRC)) {
for (const c of caps.CAPABILITIES) if (src.includes(`'${c}'`) || src.includes(`"${c}"`)) declaredAnywhere.add(c);
}
const inSomeBaseline = new Set(Object.values(caps.BASELINE).flat());
for (const cmd of Object.keys(caps.COMMAND_CAPABILITY)) {
const needed = caps.capabilitiesForCommand(cmd);
if (!needed.length) continue;
const reachable = needed.some((c) => declaredAnywhere.has(c) || inSomeBaseline.has(c));
assert.ok(reachable, `command '${cmd}' needs ${needed.join(' or ')}, which no player declares and no baseline grants — it is refused for the entire fleet`);
}
});
test('a device-owner Android panel can actually be sent the Tier-2 commands', () => {
// The end-to-end shape of the bug above, as the operator meets it: a real owner panel declaring
// exactly what PlayerCapabilities.kt declares under `if (isOwner)`.
const owner = {
client_type: 'apk',
capabilities: JSON.stringify(['playback.video', 'system.kiosk', 'system.reboot', 'system.time']),
};
for (const cmd of ['lock_now', 'power_menu', 'status_bar', 'block_uninstall', 'unblock_uninstall', 'kiosk_lock']) {
assert.equal(caps.commandAllowed(owner, cmd).ok, true, `${cmd} must reach a device-owner panel`);
}
});
test('and an ordinary Android panel is still refused them, by name', () => {
const plain = { client_type: 'apk', capabilities: JSON.stringify(['playback.video', 'remote.input']) };
const verdict = caps.commandAllowed(plain, 'lock_now');
assert.equal(verdict.ok, false);
assert.equal(verdict.capability, 'system.device_owner', 'the refusal names the canonical capability');
});
test('the capture bootstrap is not gated on the capability it creates', () => {
// enable_system_capture raises the MediaProjection consent dialog. Gating it on remote.screenshot
// meant the only panel that needs it — no accessibility, no projection grant, therefore no
// declared remote.screenshot — was the one panel that could not be sent it.
const noCapture = { client_type: 'apk', capabilities: JSON.stringify(['playback.video']) };
assert.equal(caps.commandAllowed(noCapture, 'enable_system_capture').ok, true);
});
/* ---- baselines, pinned to the players ------------------------------------------------------- */
test('BICONDITIONAL: audio.volume in a baseline iff that player reads the payload the dashboard sends', () => {
/*
* frontend/js/views/device-detail.js sends set_volume as `{ level: 0..1 }`. Android reads
* payload.level. The web player reads `payload?.value ?? data.value` and Tizen reads
* `payload.value ?? payload.volume`, so on both the number is undefined, isFinite fails, and the
* slider does nothing a handler that exists and cannot be driven.
*
* This fires in BOTH directions on purpose. Fix the one-line payload bug in a player and this
* test tells you to put the baseline entry back, which is the half everyone forgets.
*/
const readsLevel = {
android: /optDouble\("level"/.test(SRC.android),
web: /set_volume[\s\S]{0,400}?\blevel\b/.test(SRC.web),
tizen: /applyVolume[\s\S]{0,400}?\blevel\b/.test(SRC.tizen),
};
readsLevel.brightsign = readsLevel.web; // BrightSign runs the web player
for (const family of ['android', 'web', 'tizen', 'brightsign']) {
const claimed = caps.BASELINE[family].includes('audio.volume');
assert.equal(claimed, readsLevel[family],
claimed
? `BASELINE.${family} claims audio.volume but that player never reads payload.level — the slider is dead`
: `the ${family} player now reads payload.level: restore 'audio.volume' to BASELINE.${family}`);
}
});
test('audio.mute is real on all four, unlike its neighbour', () => {
// The contrast that makes the volume result meaningful: mute is driven by device:mute-changed,
// not by a set_volume payload, and every player has that listener.
for (const p of ['android', 'web', 'tizen']) {
assert.ok(handles(p, 'device:mute-changed'), `${p} must handle device:mute-changed`);
}
for (const family of Object.keys(caps.BASELINE)) {
assert.ok(caps.BASELINE[family].includes('audio.mute'), `${family} baseline should keep audio.mute`);
}
});
test('BICONDITIONAL: offline.cache in a baseline iff that player has a media cache', () => {
// Tizen's media cache is a NEW file — it was not in v1.9.28 — so the baseline must not claim it
// even though HEAD's capabilities.js declares it at runtime. BrightSign's widget is not known to
// permit a service worker at all.
const hasMediaCache = {
android: fs.existsSync(path.join(ROOT, 'android/app/src/main/java/com/remotedisplay/player/data/ContentCache.kt')),
web: fs.existsSync(path.join(ROOT, 'server/player/sw.js')),
};
assert.ok(hasMediaCache.android && hasMediaCache.web, 'the two players that do cache must still have the code');
assert.ok(caps.BASELINE.android.includes('offline.cache'));
assert.ok(caps.BASELINE.web.includes('offline.cache'));
assert.equal(caps.BASELINE.tizen.includes('offline.cache'), false,
'the fielded .wgt caches the playlist JSON, not the media');
assert.equal(caps.BASELINE.brightsign.includes('offline.cache'), false,
'a widget that refuses to register a worker cannot cache one byte');
});
test('display.power is claimed only where BOTH halves work without privilege', () => {
// Android v1.9.28 answers screen_on with a logged no-op and gates screen_off on
// owner/admin/accessibility, so the pair is not honest for an undeclared panel. Tizen implements
// both with a plain overlay and no signing requirement.
assert.equal(caps.BASELINE.android.includes('display.power'), false);
assert.ok(caps.BASELINE.tizen.includes('display.power'));
assert.equal(caps.BASELINE.web.includes('display.power'), false, 'a browser tab cannot power a panel');
assert.equal(caps.BASELINE.brightsign.includes('display.power'), false, 'CEC needs the host bridge');
// And the fielded Tizen player really does implement both halves.
assert.ok(/showScreenOff/.test(SRC.tizen) && /clearScreenOff/.test(SRC.tizen));
});
test('no baseline claims anything that needs a host bridge or a privilege grant', () => {
// The one-line version of the whole audit. Each of these is conditional at runtime on every
// platform that has it at all, so no undeclared display may be assumed to have it.
const CONDITIONAL = [
'system.kiosk', 'system.brightness', 'system.screen_timeout', 'system.install_apk',
'system.shell', 'system.time', 'system.device_owner', 'sync.native', 'display.resolution',
];
for (const [family, list] of Object.entries(caps.BASELINE)) {
for (const c of CONDITIONAL) {
assert.equal(list.includes(c), false, `${family} baseline claims the conditional capability ${c}`);
}
}
});
test('the BrightSign baseline claims nothing that needs autorun.brs', () => {
// The bridge's JS half is served by us and is always current, but `port` needs an roHtmlWidget
// created with nodejs_enabled:true by the on-device BrightScript. No released package shipped
// both halves, and the one unit on alpha runs BSN Supervisor's widget, where hasHost() is false.
for (const c of ['system.reboot', 'display.power', 'display.resolution', 'system.self_update', 'system.restart_player']) {
assert.equal(caps.BASELINE.brightsign.includes(c), false,
`BASELINE.brightsign claims ${c}, which the web player only declares behind BS.hasHost()`);
}
// The web player really does gate all of them on the bridge — if that changes, this reasoning
// needs revisiting rather than silently going stale.
assert.ok(/BS && BS\.hasHost\(\)/.test(SRC.web), 'the web player still gates host capabilities on hasHost()');
});
test('the Android baseline claims only what a Tier-0 panel can do', () => {
// set_volume / set_brightness are #160 Track-A (released v1.9.10) and are applied with no owner,
// no admin and no WRITE_SETTINGS. Their Tier-1 siblings are not claimed.
assert.ok(handles('android', 'set_brightness') && handles('android', 'set_volume'));
assert.ok(caps.BASELINE.android.includes('display.brightness'), 'the per-window dim is Tier 0');
assert.equal(caps.BASELINE.android.includes('system.brightness'), false, 'the backlight needs WRITE_SETTINGS');
assert.equal(caps.BASELINE.android.includes('system.reboot'), false, 'STPolicy.reboot() needs device owner');
});
test('capability names are spelled the same in the server and in all four players', () => {
/*
* The failure this catches is silent by design: the server's parser DROPS a name it does not
* recognise, so a typo in a player removes a control rather than raising anything. Any
* capability-shaped string a player quotes must be one the server knows.
*/
const SHAPE = /['"]((?:playback|audio|display|remote|system|sync|offline)\.[a-z_]+)['"]/g;
for (const [player, src] of Object.entries(SRC)) {
for (const m of src.matchAll(SHAPE)) {
assert.ok(caps.CAP_SET.has(m[1]),
`${player} quotes '${m[1]}', which is not in CAPABILITIES — the server would drop it silently`);
}
}
});