screentinker/server/lib/player-capabilities.js
ScreenTinker 3e37d33b80 QA: close four ways a control or an asset lied about itself
Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.

1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
   miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
   15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
   sets before it knows the file exists. Every player downloader treats 200 as success,
   so a panel stores the HTML page AS the video and caches it for a month, rendering a
   black frame with nothing in any log. Reachable exactly when it hurts: a content
   replace writes a new random filename and unlinks the old one. The mount now
   terminates a miss with a 404 and drops the cache header.

2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
   has always refused a command the panel cannot honour, and the comment above it is right
   about why ("hiding the button is not enforcement — this socket is reachable directly").
   Every word applied to the four handlers immediately above it, which had none: a display
   declaring [] still received screenshot-request, remote-touch, remote-key and
   remote-start. Measured, not inferred. They now refuse on remote.screenshot /
   remote.input / remote.stream and name the capability in the ack; remote-stop stays
   ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
   absent declaration still resolves to its platform baseline and keeps everything.

   The wall panel list (#235) made this visible: it offered a Screenshot button for every
   panel, including a BrightSign, which has no screenshot capability at all, and popped a
   toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
   capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
   as "pre-capability server, show everything" — wrong in the one case that matters), so
   the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
   Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
   commands on display.power; both now agree.

3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
   field to the literal 'unknown' and persistIdentity wrote it straight over. That column
   is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
   a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
   for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
   power and reboot and gained screenshots it cannot take. platform and client_type are
   now preserved (physical facts); client_version and contract_version still decay, because
   there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
   second signal for a Tizen TV.

4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
   a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
   brand-new "default an item to the clip's own length" then handed out the wrong number
   for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
   image measured it with raw sharp metadata and thumbnailed without .rotate(),
   re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
   share lib/content-ingest.deriveMediaMetadata.

Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.

Full suite green (1319).

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

260 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', 'offline.cache',
],
// 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) || '');
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.
*/
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,
};