mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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
260 lines
11 KiB
JavaScript
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,
|
|
};
|