mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-19 16:43:51 -06:00
* Make a BrightSign say what it is running, and what it is plugged into
A panel on a wall could not answer three questions an operator asks first:
which version am I, which page am I running, and which screen is that. All
three had answers already travelling over the socket; nothing was reading them.
VERSION. device_info.app_version was the literal '1.1.0-web' for every web
player, BrightSign included — the same string as PLAYER_VERSION, which already
travels separately as client_version. So the column carried no information at
all: a panel provisioned this morning and one running a year-old host reported
identically. app_version is now the ON-DEVICE host package, the artifact OTA
replaces and the only one here that can be stale, and PLAYER_VERSION is stamped
at serve time from VERSION rather than being a constant nobody bumped for the
whole 1.x line. No '-web' suffix: client_version is only compared for equality
today, but X.Y.Z-web is a semver PRERELEASE that sorts BELOW X.Y.Z, and this
project has been bitten by exactly that before.
The host version arrives asynchronously and can land after the page registers,
so register sends what it has and the heartbeat corrects the record — which also
catches the version changing under a live page, which is what a self-update is.
THE CARD SHOWED FOR NOBODY. The Info tab's version card sat inside the block
gated on android_version && !startsWith('Web/'). A BrightSign registers as
"Web/<ua>", so the panel that most needed a version never displayed one.
THE PAD THAT COULD NOT BE CLICKED. System View was gated on tier === 2. tier is
an Android device-owner concept, NOT NULL DEFAULT 0, written only by the APK —
so a BrightSign or Tizen panel sat at 0 forever and rendered HOME, BACK, POWER,
the D-pad and OK permanently pointer-events:none, for keys those players
genuinely handle. Greying an Android gate over a working control is the "button
that cannot work" the capability system exists to prevent, inverted. Only
Recents (KEYCODE_APP_SWITCH) and Settings are truly Android-only; those are now
the only things hidden.
THE PACKAGE POINTED AT THE WRONG SERVER. autorun.zip carried the committed
default, so a player self-updating from alpha or a self-hosted box was handed a
config pointing at screentinker.com — which surfaces as a pairing bug, miles
from the packaging code that caused it. It is now stamped with the URL it was
fetched from. The bytes therefore vary per origin, so the cache is keyed by
origin and BOTH routes derive it identically: the manifest checksum and the
served bytes must come from one buffer or every player downloads, fails
verification and retries forever.
EDID. getEdidIdentity() answers seven questions and cannot answer any others —
manufacturer, EDID version, physical size, gamma and the mode lists exist only
in the raw block, which getEdid() returns as 2048 bytes. The player ships those
on the register (identity, not a reading: it changes when someone swaps the
screen) and the SERVER parses them. That split is the point: a new field becomes
a server deploy instead of a bridge update behind a 4h CDN plus an OTA for the
host. Verified against real hardware — an XT245 with a CX101 decodes to RTK /
0x1010 / serial 1 / 2020w26 / 22x13cm, preferred 1920x1200@62, matching the
player's own DWS field for field. The odd-looking 62 is right: 168.5MHz over
2200 x 1245 is 61.5Hz, and rounding it to a nicer 60 would contradict the panel.
Also corrects two comments that had outgrown their reasoning: the BrightSign
capability baseline still explained its exclusions with "a canvas cannot read
the video plane", which native capture made obsolete, and player-parity.md
claimed the bridge is "always current" when a zone-wide Cloudflare Browser Cache
TTL had been rewriting its no-cache to max-age=14400 for months.
Every new guard is mutation-tested — the fix was reverted in the source and each
test confirmed to fail. 1676 -> 1714 tests, all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
* Run the ScreenTinker server on the player it serves
A BrightSign XT245 now downloads, installs and runs the server itself, with
the display showing what it is doing until it is up.
WHY IT NEEDED A NEW SHAPE
BrightSignOS cannot open a large autorun.zip. The 73MB build failed at boot
with "ZipArchive error at line 91", and the OS renamed it autorun.zip_invalid
- which is how a device that had already unpacked once came back up with no
autorun at all. The identical package cut to 32KB and five files boots fine;
paths (182 chars) and depth (8) are unremarkable, so the limit is in the
boot-time reader, not the archive. BrightSign's own notes acknowledge package
size as a problem and point at webpack; that route needs the dynamic requires
in scripts/ removed first, so instead autorun.zip carries only what starts the
process and the payload arrives over HTTP into a Node that has no such limit.
The payload can also be updated without re-provisioning the device.
WHY roNodeJs AND NOT THE WIDGET
The first version ran the server inside an roHtmlWidget with nodejs_enabled.
That is a Node context inside an Electron renderer, and it is not Node. Four
separate boot failures came out of it, each invisible to a local test because
a local test runs on real Node:
- shebangs are not stripped, so any `#!/usr/bin/env node` file dies with
"Failed to construct 'ContextifyScript': Invalid or unexpected token".
Note it names no token - "#" is not one. An ESM file compiled as CJS says
"Unexpected token 'export'" instead, which is how the two are told apart.
- require() of an ESM-only package is unsupported, which plain Node 24
handles. uuid 14 is ESM-only and 21 files import it.
- setInterval is the DOM's and returns a NUMBER, so setInterval(...).unref()
throws. Two call sites were unguarded; sixteen more were written
defensively and had been silently not unreffing.
- worker_threads cannot create a thread at all.
BrightSign's dev-cookbook is explicit: roNodeJs "for long running processes
like ... running a web server", roHtmlWidget "for browser-based apps". Their
cra-template examples do exactly this - server in roNodeJs, widget pointed at
localhost. It also fixes the lifecycle problem that was the original argument
against a server on this hardware: in a widget the server dies with the page,
taking an open SQLite WAL with it.
The shims for the first three are kept in the packager for now rather than
removed in the same change that moves the container, so that if something
breaks it is the move and not four simultaneous removals.
CHANGES THAT ARE NOT BRIGHTSIGN-SPECIFIC
db/database.js, routes/status.js fs.copyFileSync does not merely copy
bytes: it fchmods the destination to match the source. exFAT has no
permission bits, so the pre-migration snapshot failed with EPERM and the
failure path called process.exit(1) - which inside a widget also killed
the page, leaving a black screen and no diagnostic. The guard was right;
the copy was wrong. lib/fsutil.js copies without touching mode.
db/wal-checkpointer.js the module already degraded correctly when its
worker died or could not be respawned, but the FIRST spawn was not
wrapped, so a host that cannot make threads lost the whole server rather
than falling back to inline autocheckpoint.
db/sqlite-compat.js a better-sqlite3 facade over node:sqlite. With it the
bundle contains no native code at all, which is what lets an x86_64
laptop build a package for an aarch64 player. 1719/1719 tests pass on
Node 24 through this shim.
The packager refuses to build if a source file is untracked (git ls-files
decides what ships, and lib/fsutil.js reached a player without shipping
alongside the code that required it), if any .node binary is present, if a
shebang survives, or if a database, upload, cert or .env is staged - the first
build of this package swept up a real 33MB database and 105MB of uploads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
440 lines
24 KiB
JavaScript
440 lines
24 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',
|
|
// RESTORED in 1.9.31 with BASELINE.web, and for the same reason plus one of its own: a
|
|
// BrightSign runs the web player we serve, so it gets the fixed handler the moment the server
|
|
// does. The unit-specific question is whether the media element is even reachable on a player
|
|
// that puts video on a hardware plane — and that question is already settled by `audio.mute`
|
|
// above, which this baseline has always claimed: set_volume reaches setMediaVolume() and
|
|
// device:mute-changed reaches `currentVideoEl.muted`, the same element by the same path. If hwz
|
|
// silently swallowed one it would swallow both, so volume is exactly as honest as mute here.
|
|
'audio.volume',
|
|
'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 remote.screenshot / remote.stream — but the ORIGINAL reason is now wrong, so read this
|
|
// before restoring them. The old note said a canvas capture on a hwz player cannot read the
|
|
// video plane and returns a frame with a hole. Two things changed:
|
|
//
|
|
// 1. st-bridge.js gained captureScreen(), which uses the native @brightsign/screenshot module
|
|
// and DOES composite the hardware plane. Confirmed on hardware (XT245 / BOS 10.0.16). It
|
|
// needs only require(), not a host bridge — so it works on the exact units the note feared.
|
|
// 2. The canvas fallback is no longer silent: renderCaptureCanvas() paints "Video is playing
|
|
// on the hardware plane and cannot be captured" rather than a black rectangle.
|
|
//
|
|
// They stay out for a DIFFERENT and narrower reason: captureScreen() needs a node-enabled
|
|
// widget. A widget built by the BSN Supervisor has no require(), so there the path really does
|
|
// end at a canvas that cannot see the video. This baseline cannot tell the two apart.
|
|
//
|
|
// Restoring them would also be close to a no-op: server/player/index.html declares remote.stream
|
|
// unconditionally and remote.screenshot behind an always-true canvas check, and platform=
|
|
// 'brightsign' is only ever set by the same register that carries the declaration — so a
|
|
// BrightSign row essentially always HAS one and never reaches this baseline. (audio.volume moved
|
|
// INTO the list above in 1.9.31 — the payload it was waiting on now lands.)
|
|
],
|
|
// 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',
|
|
// RESTORED in 1.9.31, having been removed by the audit that found the slider dead. Both of the
|
|
// reasons it was removed have expired, and the second one was reasoning from the wrong artifact:
|
|
// 1. It read `data.payload?.value ?? data.value` while the dashboard sends `{ level: 0..1 }`,
|
|
// so the number was undefined and the handler declined. Fixed in 1.9.31 — index.html now
|
|
// takes the fraction as canonical (volumeLevelFromCommand) and set_volume reaches
|
|
// setMediaVolume().
|
|
// 2. The removal cited `git show v1.9.28:server/player/index.html` having no handler at all.
|
|
// But this player is SERVED BY THE SERVER: a browser panel loads it from whatever build is
|
|
// running, not from the release its row was created under. There is no such thing as a
|
|
// browser panel stuck on the v1.9.28 player once the server moves — which is the whole
|
|
// difference between this baseline and the Android/Tizen ones below, where an un-updated
|
|
// panel really is running an old artifact.
|
|
// So the moment the server ships the fix, an undeclared web display can be driven, and holding
|
|
// the entry back would hide a control that works. Released and live on prod 2026-08-06.
|
|
'audio.volume',
|
|
'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.
|
|
*
|
|
* 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',
|
|
// Clearing the staged-APK cache is part of the same self-update surface: a player that can
|
|
// update itself is a player that can hold a bad download and needs a way to drop it.
|
|
clear_update_cache: '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,
|
|
};
|