'use strict'; /* * What a player can actually do. * * The dashboard offered every control to every display. A browser tab cannot reboot its host, a * Tizen TV has no device-owner concept, a BrightSign has no per-window brightness — so those * buttons did nothing, silently, and looked like bugs. "UI that reports success and changes * nothing" is a recurring shape in this codebase and this module exists to end it. * * The player DECLARES its capabilities at registration, because only the player knows at runtime: * an Android device gains real screenshots when accessibility is switched on, and loses Tier-2 * commands when it is not device owner. A static per-platform table could never know that. * * ⚠️ Legacy displays declare nothing. A fleet of several hundred is not going to update before the * next dashboard deploy, so an absent declaration falls back to a per-platform baseline rather * than to "supports nothing" — which would strip the UI for every existing display at once. The * baseline is deliberately optimistic for things that always worked, and pessimistic for anything * that depends on runtime state. */ /* * The vocabulary. Stable strings, because they are persisted per device and sent over the wire — * renaming one silently disables a control on every display that still reports the old name. * Grouped by what the operator is trying to do, not by how it is implemented. */ const CAPABILITIES = [ // playback surface 'playback.video', 'playback.image', 'playback.widget', 'playback.youtube', 'playback.zones', 'playback.transitions', 'playback.pip', // audio 'audio.mute', 'audio.volume', // display 'display.rotation', 'display.power', 'display.resolution', 'display.brightness', // remote view / control 'remote.screenshot', 'remote.stream', 'remote.input', // lifecycle 'system.reboot', 'system.restart_player', 'system.self_update', // device management (Android device-owner territory) 'system.kiosk', 'system.brightness', 'system.screen_timeout', 'system.install_apk', 'system.shell', 'system.time', // The rest of the Tier-2 surface: lock the screen now, show the power menu, hide the status // bar, block uninstall. Separate from 'system.kiosk' because kiosk means lock-task specifically // and a panel can hold one without the other — and separate from the individual names above // because these four are only ever available together, gated by the same device-owner check. // Runtime state, not a platform fact: a panel that loses device owner loses all of them. 'system.device_owner', // synchronisation 'sync.clock', 'sync.native', // resilience 'offline.cache', ]; const CAP_SET = new Set(CAPABILITIES); /* * Baselines for displays that declare nothing. * * 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:`, 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.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. `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: 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', // 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', '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', '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. ], }; /* * Which baseline a device falls back to. Keyed off the same `platform` field the sync resolver * uses, so a device is classified one way across the whole product. */ function platformFamily(device) { const platform = String((device && device.platform) || '').toLowerCase(); const android = String((device && device.android_version) || ''); const clientType = (device && device.client_type) || ''; if (platform.includes('brightsign')) return 'brightsign'; if (platform.includes('tizen')) return 'tizen'; // Second, independent signal for a Tizen TV: the .wgt player sends client_type 'wgt' (see // tizen/js/app.js). `platform` is the primary key, but it lives in a column that a register from // a client not sending it used to overwrite — and misreading a Tizen panel as a browser tab // hands it a volume slider with no handler behind it. Two signals, one conclusion. if (clientType === 'wgt') return 'tizen'; // client_type 'apk' is the Android player; android_version that is NOT the web player's // "Web/..." shape is the older signal for the same thing. if ((device && device.client_type === 'apk') || (android && !android.startsWith('Web/'))) return 'android'; return 'web'; } /** * The capability set for a device, as an array of known capability strings. * * @param {object} device a device row; may carry `capabilities` (JSON array or string) * @returns {string[]} */ function capabilitiesFor(device) { const declared = parseDeclared(device && device.capabilities); if (declared) return declared; return (BASELINE[platformFamily(device)] || BASELINE.web).slice(); } /** * True when the device supports `cap`. Unknown capability names are always false. * * A missing device supports nothing. It would otherwise fall through to the web baseline and * claim video playback for a row that does not exist — a caller rendering controls from a failed * lookup should get an empty panel, not a plausible-looking one. */ function supports(device, cap) { if (!device) return false; if (!CAP_SET.has(cap)) return false; return capabilitiesFor(device).includes(cap); } /* * Parse whatever the device sent. Returns null when there is no usable declaration, which is the * signal to fall back to the baseline — distinct from an EMPTY declaration, which is a player * genuinely saying "I can do nothing" and must be honoured. */ function parseDeclared(raw) { if (raw === null || raw === undefined) return null; let list = raw; if (typeof raw === 'string') { const trimmed = raw.trim(); if (!trimmed) return null; try { list = JSON.parse(trimmed); } catch (e) { return null; } } if (!Array.isArray(list)) return null; // Unknown strings are dropped rather than rejected wholesale: a newer player declaring a // capability this server has never heard of must not lose the ones it does understand. return list.filter((c) => CAP_SET.has(c)); } /* * Which capability a fleet command needs. * * The dashboard and the socket layer both dispatch commands by string name, so the check has to * happen against that name or it does not happen at all. Kept here rather than in the socket * handler because two call sites dispatch commands — dashboardSocket for a single device and the * group route for many — and a map that lives in one of them protects only that one. * * 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 reboot: 'system.reboot', // Power-off shares the reboot capability: it is the same "device power lifecycle" privilege, and // no platform we ship implements one without the other. Split it if that ever stops being true. shutdown: 'system.reboot', launch: 'system.restart_player', refresh: 'system.restart_player', update: 'system.self_update', // display screen_on: 'display.power', screen_off: 'display.power', // audio set_volume: 'audio.volume', // system control (#160 Track-A) set_brightness: 'display.brightness', // per-window overlay dim (Tier 0) set_system_brightness: 'system.brightness', set_screen_timeout: 'system.screen_timeout', // device-owner surface (#161 Tier-2) kiosk_lock: 'system.kiosk', kiosk_unlock: 'system.kiosk', /* * ⚠️ 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. 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. set_debug: null, }; /** * 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) { const list = capabilitiesForCommand(type); return list.length ? list[0] : null; } /** * Can this device be sent this command? * @returns {{ok: true} | {ok: false, capability: string}} */ function commandAllowed(device, type) { 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, capabilitiesForCommand, commandAllowed, };