mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
screen_off blanks a fielded panel for real (owner/admin FORCE_LOCK, else the accessibility lock); screen_on is a logged no-op. One capability renders both dashboard buttons, so withholding the pair to hide the dead ON button also takes blank-at-night — the half that gets scheduled — away from every panel that has not updated. Panels that have updated declare for themselves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
407 lines
22 KiB
JavaScript
407 lines
22 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.
|
|
*
|
|
* 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:<player source>`, 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.power', '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',
|
|
// display.power is KEPT for the un-updated Android fleet, deliberately, with the trade-off
|
|
// recorded here because it is genuinely two-sided.
|
|
//
|
|
// 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 every fielded Android panel, while screen_off does work (device
|
|
// owner / device-admin FORCE_LOCK, else the accessibility lock). One capability renders BOTH
|
|
// dashboard buttons, so this baseline cannot offer the working half without the dead one.
|
|
//
|
|
// Withholding it takes away blank-at-night, which is the half signage actually schedules, from
|
|
// every panel that has not updated. Keeping it means an operator can sleep a screen and not
|
|
// wake it from the dashboard — mitigated by the fact that a schedule, a restart, or anyone
|
|
// standing at the panel will wake it, while nothing else can blank it.
|
|
//
|
|
// A panel that HAS updated declares for itself, and PlayerCapabilities.kt gates its own claim
|
|
// on both halves — so this governs the un-updated fleet only. If the dead ON button turns out
|
|
// to be the louder complaint, split it into display.power_off / display.power_on rather than
|
|
// dropping the pair.
|
|
//
|
|
// 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,
|
|
};
|