mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Merge QA: make the parity matrix and the capability baselines true
# Conflicts: # server/lib/player-capabilities.js
This commit is contained in:
commit
ac2389716c
|
|
@ -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,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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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