mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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
This commit is contained in:
parent
2237edab12
commit
c1270599c3
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -56,60 +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', 'offline.cache',
|
||||
'sync.clock',
|
||||
// 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.
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -182,6 +265,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
|
||||
|
|
@ -208,18 +296,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.
|
||||
|
|
@ -227,13 +346,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -241,13 +376,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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"', () => {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
255
server/test/player-parity-baselines.test.js
Normal file
255
server/test/player-parity-baselines.test.js
Normal 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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue