From 6bc709d2f7e2fd5dda4959ed5552e7149aee5224 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 5 Aug 2026 13:59:04 -0500 Subject: [PATCH] The capability contract: what each player can actually do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for platform-native parity. 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 read as bugs. "UI that reports success and changes nothing" is a recurring shape here; this ends it by letting the frontend hide what a display cannot do. The player DECLARES its capabilities at registration rather than the server inferring them from a table, 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. The trap this had to avoid is the opposite failure. Several hundred displays are in the field declaring nothing, and none will update before the next dashboard deploy — treating absence as "supports nothing" would strip the UI for the entire fleet at once. 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 trivial to conflate and the difference is a dark dashboard. Baselines carry only what has always worked on that platform. Anything conditional — screenshots needing accessibility, kiosk needing device owner, native sync needing one L2 network — is omitted, so a legacy display shows those controls only once it declares them. A control that appears late beats one that lies now. Capability names are stable strings because they are persisted per device and sent over the wire; renaming one silently disables a control on every display still reporting the old name. An unknown name from a NEWER player is dropped rather than invalidating the whole declaration. 1094 pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- server/lib/player-capabilities.js | 158 ++++++++++++++++++++++++ server/test/player-capabilities.test.js | 91 ++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 server/lib/player-capabilities.js create mode 100644 server/test/player-capabilities.test.js 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); +});