diff --git a/server/lib/player-capabilities.js b/server/lib/player-capabilities.js new file mode 100644 index 0000000..5bcfad8 --- /dev/null +++ b/server/lib/player-capabilities.js @@ -0,0 +1,158 @@ +'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', + // 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', + // 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', + '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', 'audio.volume', + 'display.rotation', + 'remote.input', + 'system.restart_player', + 'sync.clock', 'offline.cache', + ], + 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) || ''); + 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)); +} + +module.exports = { CAPABILITIES, CAP_SET, BASELINE, capabilitiesFor, supports, platformFamily, parseDeclared }; diff --git a/server/test/player-capabilities.test.js b/server/test/player-capabilities.test.js new file mode 100644 index 0000000..7f9c10d --- /dev/null +++ b/server/test/player-capabilities.test.js @@ -0,0 +1,91 @@ +'use strict'; + +// 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. "UI that reports success and changes nothing" is a recurring bug +// shape here, and hiding an unsupported control is the fix. +// +// The risk in doing that is the opposite failure: stripping controls from the several hundred +// displays already in the field, none of which declare anything. So an ABSENT declaration falls +// back to a per-platform baseline, while an EMPTY one is honoured as a player genuinely saying it +// can do nothing. Those two cases are easy to conflate and the difference is a dark dashboard. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +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, 'playback.video')); +}); + +test('THE DISTINCTION: an EMPTY declaration is honoured, not treated as missing', () => { + // A player saying "I can do nothing" is a real answer — a widget with no host bridge, say — and + // must not be quietly upgraded to the baseline. + const d = { client_type: 'apk', capabilities: '[]' }; + assert.deepEqual(caps.capabilitiesFor(d), []); + assert.equal(caps.supports(d, 'playback.video'), false); +}); + +test('a declaration overrides the baseline in BOTH directions', () => { + // Android gains real screenshots only when accessibility is on, and loses Tier-2 commands when + // it is not device owner. A static table could never know either. + const restricted = { client_type: 'apk', capabilities: JSON.stringify(['playback.video']) }; + assert.equal(caps.supports(restricted, 'system.reboot'), false, 'declared set is authoritative'); + + const enhanced = { platform: 'Chrome 120', capabilities: JSON.stringify(['playback.video', 'system.reboot']) }; + assert.ok(caps.supports(enhanced, 'system.reboot'), 'a web player behind a host CAN reboot'); +}); + +test('a browser tab does not claim what it cannot do', () => { + const web = { platform: 'Chrome 150', android_version: 'Web/Chrome' }; + assert.equal(caps.supports(web, 'system.reboot'), false); + assert.equal(caps.supports(web, 'system.kiosk'), false); + assert.equal(caps.supports(web, 'display.power'), false); + assert.ok(caps.supports(web, 'playback.video')); +}); + +test('platform families are recognised from the same fields the rest of the product uses', () => { + assert.equal(caps.platformFamily({ platform: 'brightsign' }), 'brightsign'); + assert.equal(caps.platformFamily({ platform: 'Tizen 6.0' }), 'tizen'); + assert.equal(caps.platformFamily({ client_type: 'apk' }), 'android'); + assert.equal(caps.platformFamily({ android_version: 'Android 12' }), 'android'); + assert.equal(caps.platformFamily({ android_version: 'Web/Safari' }), 'web'); + assert.equal(caps.platformFamily({}), 'web', 'unknown falls back to the most limited set'); +}); + +test('an unknown capability from a NEWER player does not discard the ones we understand', () => { + const d = { client_type: 'apk', capabilities: JSON.stringify(['playback.video', 'quantum.teleport']) }; + assert.deepEqual(caps.capabilitiesFor(d), ['playback.video']); +}); + +test('asking about a capability that does not exist is false, never a throw', () => { + assert.equal(caps.supports({ client_type: 'apk' }, 'nonsense.capability'), false); + assert.equal(caps.supports(null, 'playback.video'), false); +}); + +test('malformed declarations fall back rather than blanking the UI', () => { + for (const bad of ['not json', '{"a":1}', ' ', 42]) { + const d = { client_type: 'apk', capabilities: bad }; + assert.ok(caps.supports(d, 'playback.video'), `${JSON.stringify(bad)} must fall back to baseline`); + } +}); + +test('every baseline entry is a real capability name', () => { + // A typo here silently disables a control for a 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}`); + } +}); + +test('BrightSign claims display power and reboot; Tizen claims neither', () => { + // The concrete parity facts this whole model exists to express. + const bs = { platform: 'brightsign' }; + 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.equal(caps.supports(tizen, 'system.reboot'), false); +});