screentinker/server/lib/player-capabilities.js
ScreenTinker 4e1de8ec0e Make the Tizen and BrightSign players do what they say they do
Both players carried calls that compile, read correctly, and are documented to
do something else. Verified line by line against docs.brightsign.biz and
Samsung's Smart TV Filesystem reference; every fix below cites the doc that
proves it, and the linter has been extended so each one fails here next time.

TIZEN

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

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

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

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

BRIGHTSIGN

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

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

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

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

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

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

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

TESTS

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 10:51:15 -05:00

259 lines
11 KiB
JavaScript

'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.
*
* 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.
*/
const BASELINE = {
android: [
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
'audio.mute', 'audio.volume',
'display.rotation', 'display.power', 'display.brightness',
'remote.screenshot', 'remote.stream', 'remote.input',
'system.reboot', 'system.restart_player', 'system.self_update',
'sync.clock', 'offline.cache',
],
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',
'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',
'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.
],
brightsign: [
'playback.video', 'playback.image', 'playback.widget', 'playback.youtube',
'playback.zones', 'playback.transitions', 'playback.pip',
'audio.mute', 'audio.volume',
'display.rotation', 'display.power',
'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.
],
// 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',
'display.rotation',
'remote.screenshot', 'remote.stream', 'remote.input',
'system.restart_player',
'sync.clock', 'offline.cache',
],
};
/*
* 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) || '');
if (platform.includes('brightsign')) return 'brightsign';
if (platform.includes('tizen')) 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.
*/
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',
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',
set_time: 'system.time',
set_timezone: 'system.time',
shell: 'system.shell',
install_apk: 'system.install_apk',
// remote view
enable_system_capture: 'remote.screenshot',
// 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,
};
/**
* 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
* 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.
*/
function capabilityForCommand(type) {
return Object.prototype.hasOwnProperty.call(COMMAND_CAPABILITY, type) ? COMMAND_CAPABILITY[type] : null;
}
/**
* Can this device be sent this command?
* @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 };
}
module.exports = {
CAPABILITIES, CAP_SET, BASELINE, capabilitiesFor, supports, platformFamily, parseDeclared,
COMMAND_CAPABILITY, capabilityForCommand, commandAllowed,
};