mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
The parity doc and the capability model had drifted from the players in both
directions, and nothing failed when they did. Auditing all four players against
their shipped sources turned up three controls a customer can press today that
change nothing, and a set of baselines that were partly too generous and partly
too stingy.
The three dead controls:
- The volume slider works on Android only. The dashboard sends set_volume as
{ level: 0..1 }; the web player reads payload.value and Tizen reads
payload.value ?? payload.volume, so on both the number is undefined and the
handler quietly declines. Three complete, working volume implementations
that cannot be driven. The fix is one line in each player and belongs to
those files; audio.volume is out of the web and brightsign baselines until
it lands, held there by a biconditional test that fails the moment a player
starts reading `level`.
- Every #161 Tier-2 command was refused for the entire fleet. lock_now,
power_menu, status_bar, block_uninstall and unblock_uninstall were gated on
system.device_owner, which no player declares and no baseline grants, so
supports() was false everywhere -- including on the device-owner panels the
feature was built for. The dashboard still drew the buttons because it also
gates on device.tier === 2. Fixed here: those five now accept
system.device_owner OR system.kiosk, which PlayerCapabilities.kt declares
under `if (isOwner)` and nothing else, and which no non-Android player
declares. Android should declare system.device_owner and retire the
stand-in.
- enable_system_capture required the capability it creates. It raises the
MediaProjection consent dialog -- the way a panel GAINS capture -- and was
gated on remote.screenshot, so the only panel that needs it was the one
panel that could not be sent it. Now ungated. The dashboard still hides the
button behind the same check; that half is a frontend change.
The baselines describe what an un-updated fielded display can do, and since
v1.9.29 is the first build in which any player declares anything, that means
v1.9.28. Every entry is now justified against `git show v1.9.28:<source>`:
- android loses display.power (v1.9.28 answers screen_on with a logged no-op,
so the ON half is dead on every fielded panel and one capability renders
both buttons) and system.reboot (owner-only; off-owner it paints an
accessibility power dialog over the signage). Scheduled reboots now skip
undeclared Android panels rather than logging a reboot that never happened,
which is the reason that gate exists.
- tizen gains display.power: v1.9.28 implements both halves with no signing
and no panel API, so withholding it hid a working control.
- brightsign loses audio.volume, display.power, system.reboot,
system.restart_player and offline.cache. All need a host bridge the unit is
not known to have, and restart_player without one is the page reload that
darkened a panel on 2026-07-28.
Also found, not fixed here because the files belong to others:
st-bridge.js computeCapabilities() is dead code -- nothing calls BS.capabilities()
-- and its 199 lines of passing tests constrain nothing a BrightSign actually
declares; the two disagree on six capabilities and the bridge is right about
most of them. BrightSign's "Force update" button is dead. PlayerCapabilities.kt
under-declares display.brightness.
The new test reads the player sources rather than the table: a dead-button rule
(every gated command has a branch somewhere), an unreachable-capability rule
(which would have caught system.device_owner), and biconditionals so a fix in a
player fails the test until the baseline follows. Claims that need hardware --
CEC reaching a display, a widget being allowed a service worker, SyncManager
holding frame lock -- are marked unverifiable in the document instead of
asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
256 lines
14 KiB
JavaScript
256 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
* The parity matrix keeps drifting away from the players, because a capability list is prose that
|
|
* happens to be executable: nothing breaks when it starts lying. Tizen claimed audio.volume with
|
|
* no set_volume handler anywhere in the shipped player, and the dashboard drew a slider that did
|
|
* nothing. Tizen claimed offline.cache while caching only the PLAYLIST, so a panel survived an
|
|
* outage knowing exactly what it could not show. A real BrightSign advertised offline.cache while
|
|
* its widget refuses to register a service worker at all.
|
|
*
|
|
* None of those were caught by a test, because every test asserted the table against itself.
|
|
*
|
|
* So these tests read the PLAYER SOURCES. They are grep-shaped and that is deliberate: the point is
|
|
* to fail when the code and the claim disagree, not to re-implement the players. Where a claim
|
|
* genuinely cannot be settled from source — CEC actually reaching a display, a widget actually
|
|
* being allowed to register a worker — there is no test here and docs/player-parity.md says so in
|
|
* as many words.
|
|
*
|
|
* Several of these are BICONDITIONAL: they fail both when a baseline over-claims and when a player
|
|
* gains the handler and the baseline was not updated. A test that only fires in one direction is
|
|
* how "too stingy" survives for months after the bug it was working around is fixed.
|
|
*/
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const caps = require('../lib/player-capabilities');
|
|
|
|
const ROOT = path.join(__dirname, '..', '..');
|
|
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
|
|
|
const SRC = {
|
|
web: read('server/player/index.html'),
|
|
android: [
|
|
'android/app/src/main/java/com/remotedisplay/player/MainActivity.kt',
|
|
'android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt',
|
|
'android/app/src/main/java/com/remotedisplay/player/telemetry/PlayerCapabilities.kt',
|
|
].map(read).join('\n'),
|
|
tizen: ['tizen/js/app.js', 'tizen/js/device-control.js', 'tizen/js/capabilities.js'].map(read).join('\n'),
|
|
brightsign: ['brightsign/st-bridge.js', 'brightsign/autorun.brs'].map(read).join('\n'),
|
|
};
|
|
|
|
/*
|
|
* Which command names each player has a branch for. A command a player never names is a dead
|
|
* button by construction — the socket delivers it and the handler falls off the end.
|
|
*/
|
|
function handles(player, command) {
|
|
const src = player === 'brightsign' ? SRC.brightsign + SRC.web : SRC[player];
|
|
// Kotlin `"reboot" ->`, JS `case 'reboot':`, JS `type === 'reboot'`, `data.type === 'reboot'`.
|
|
return new RegExp(`['"]${command}['"]`).test(src);
|
|
}
|
|
|
|
test('every capability name in every baseline exists in the vocabulary', () => {
|
|
// A typo does not fail loudly: supports() returns false for an unknown name, so the control just
|
|
// never appears for that 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}`);
|
|
assert.equal(new Set(list).size, list.length, `${family} baseline has a duplicate entry`);
|
|
}
|
|
});
|
|
|
|
test('every command in the routing map points at capabilities that exist', () => {
|
|
for (const [cmd, value] of Object.entries(caps.COMMAND_CAPABILITY)) {
|
|
for (const cap of caps.capabilitiesForCommand(cmd)) {
|
|
assert.ok(caps.CAP_SET.has(cap), `${cmd} maps to unknown capability ${cap}`);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
assert.ok(value.length > 1, `${cmd} is a one-element list — write it as a plain string`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('THE DEAD-BUTTON RULE: every gated command is handled by some player', () => {
|
|
// Not "by every player" — display.power is Android/Tizen/BrightSign and that is correct. But a
|
|
// command NO player names cannot do anything for anyone, and the capability gating it is a lie
|
|
// wherever it is declared.
|
|
const players = ['android', 'web', 'tizen', 'brightsign'];
|
|
for (const cmd of Object.keys(caps.COMMAND_CAPABILITY)) {
|
|
const who = players.filter((p) => handles(p, cmd));
|
|
assert.ok(who.length > 0, `no player has a branch for command '${cmd}' — it is a dead button everywhere`);
|
|
}
|
|
});
|
|
|
|
test('THE UNREACHABLE-CAPABILITY RULE: a gating capability must be reachable', () => {
|
|
/*
|
|
* The bug this catches, found by this audit: all five #161 Tier-2 commands were gated on
|
|
* 'system.device_owner', which no player declares and no baseline grants. supports() therefore
|
|
* returned false for every device in the fleet and the commands were refused universally,
|
|
* including on the device-owner panels they were built for.
|
|
*
|
|
* A capability is reachable if some player's source names it (it can be declared at runtime) or
|
|
* some baseline grants it (an undeclared display gets it). If neither is true, every command
|
|
* behind it is refused for every device, forever, in silence.
|
|
*/
|
|
const declaredAnywhere = new Set();
|
|
for (const src of Object.values(SRC)) {
|
|
for (const c of caps.CAPABILITIES) if (src.includes(`'${c}'`) || src.includes(`"${c}"`)) declaredAnywhere.add(c);
|
|
}
|
|
const inSomeBaseline = new Set(Object.values(caps.BASELINE).flat());
|
|
|
|
for (const cmd of Object.keys(caps.COMMAND_CAPABILITY)) {
|
|
const needed = caps.capabilitiesForCommand(cmd);
|
|
if (!needed.length) continue;
|
|
const reachable = needed.some((c) => declaredAnywhere.has(c) || inSomeBaseline.has(c));
|
|
assert.ok(reachable, `command '${cmd}' needs ${needed.join(' or ')}, which no player declares and no baseline grants — it is refused for the entire fleet`);
|
|
}
|
|
});
|
|
|
|
test('a device-owner Android panel can actually be sent the Tier-2 commands', () => {
|
|
// The end-to-end shape of the bug above, as the operator meets it: a real owner panel declaring
|
|
// exactly what PlayerCapabilities.kt declares under `if (isOwner)`.
|
|
const owner = {
|
|
client_type: 'apk',
|
|
capabilities: JSON.stringify(['playback.video', 'system.kiosk', 'system.reboot', 'system.time']),
|
|
};
|
|
for (const cmd of ['lock_now', 'power_menu', 'status_bar', 'block_uninstall', 'unblock_uninstall', 'kiosk_lock']) {
|
|
assert.equal(caps.commandAllowed(owner, cmd).ok, true, `${cmd} must reach a device-owner panel`);
|
|
}
|
|
});
|
|
|
|
test('and an ordinary Android panel is still refused them, by name', () => {
|
|
const plain = { client_type: 'apk', capabilities: JSON.stringify(['playback.video', 'remote.input']) };
|
|
const verdict = caps.commandAllowed(plain, 'lock_now');
|
|
assert.equal(verdict.ok, false);
|
|
assert.equal(verdict.capability, 'system.device_owner', 'the refusal names the canonical capability');
|
|
});
|
|
|
|
test('the capture bootstrap is not gated on the capability it creates', () => {
|
|
// enable_system_capture raises the MediaProjection consent dialog. Gating it on remote.screenshot
|
|
// meant the only panel that needs it — no accessibility, no projection grant, therefore no
|
|
// declared remote.screenshot — was the one panel that could not be sent it.
|
|
const noCapture = { client_type: 'apk', capabilities: JSON.stringify(['playback.video']) };
|
|
assert.equal(caps.commandAllowed(noCapture, 'enable_system_capture').ok, true);
|
|
});
|
|
|
|
/* ---- baselines, pinned to the players ------------------------------------------------------- */
|
|
|
|
test('BICONDITIONAL: audio.volume in a baseline iff that player reads the payload the dashboard sends', () => {
|
|
/*
|
|
* frontend/js/views/device-detail.js sends set_volume as `{ level: 0..1 }`. Android reads
|
|
* payload.level. The web player reads `payload?.value ?? data.value` and Tizen reads
|
|
* `payload.value ?? payload.volume`, so on both the number is undefined, isFinite fails, and the
|
|
* slider does nothing — a handler that exists and cannot be driven.
|
|
*
|
|
* This fires in BOTH directions on purpose. Fix the one-line payload bug in a player and this
|
|
* test tells you to put the baseline entry back, which is the half everyone forgets.
|
|
*/
|
|
const readsLevel = {
|
|
android: /optDouble\("level"/.test(SRC.android),
|
|
web: /set_volume[\s\S]{0,400}?\blevel\b/.test(SRC.web),
|
|
tizen: /applyVolume[\s\S]{0,400}?\blevel\b/.test(SRC.tizen),
|
|
};
|
|
readsLevel.brightsign = readsLevel.web; // BrightSign runs the web player
|
|
|
|
for (const family of ['android', 'web', 'tizen', 'brightsign']) {
|
|
const claimed = caps.BASELINE[family].includes('audio.volume');
|
|
assert.equal(claimed, readsLevel[family],
|
|
claimed
|
|
? `BASELINE.${family} claims audio.volume but that player never reads payload.level — the slider is dead`
|
|
: `the ${family} player now reads payload.level: restore 'audio.volume' to BASELINE.${family}`);
|
|
}
|
|
});
|
|
|
|
test('audio.mute is real on all four, unlike its neighbour', () => {
|
|
// The contrast that makes the volume result meaningful: mute is driven by device:mute-changed,
|
|
// not by a set_volume payload, and every player has that listener.
|
|
for (const p of ['android', 'web', 'tizen']) {
|
|
assert.ok(handles(p, 'device:mute-changed'), `${p} must handle device:mute-changed`);
|
|
}
|
|
for (const family of Object.keys(caps.BASELINE)) {
|
|
assert.ok(caps.BASELINE[family].includes('audio.mute'), `${family} baseline should keep audio.mute`);
|
|
}
|
|
});
|
|
|
|
test('BICONDITIONAL: offline.cache in a baseline iff that player has a media cache', () => {
|
|
// Tizen's media cache is a NEW file — it was not in v1.9.28 — so the baseline must not claim it
|
|
// even though HEAD's capabilities.js declares it at runtime. BrightSign's widget is not known to
|
|
// permit a service worker at all.
|
|
const hasMediaCache = {
|
|
android: fs.existsSync(path.join(ROOT, 'android/app/src/main/java/com/remotedisplay/player/data/ContentCache.kt')),
|
|
web: fs.existsSync(path.join(ROOT, 'server/player/sw.js')),
|
|
};
|
|
assert.ok(hasMediaCache.android && hasMediaCache.web, 'the two players that do cache must still have the code');
|
|
assert.ok(caps.BASELINE.android.includes('offline.cache'));
|
|
assert.ok(caps.BASELINE.web.includes('offline.cache'));
|
|
assert.equal(caps.BASELINE.tizen.includes('offline.cache'), false,
|
|
'the fielded .wgt caches the playlist JSON, not the media');
|
|
assert.equal(caps.BASELINE.brightsign.includes('offline.cache'), false,
|
|
'a widget that refuses to register a worker cannot cache one byte');
|
|
});
|
|
|
|
test('display.power is claimed only where BOTH halves work without privilege', () => {
|
|
// Android v1.9.28 answers screen_on with a logged no-op and gates screen_off on
|
|
// owner/admin/accessibility, so the pair is not honest for an undeclared panel. Tizen implements
|
|
// both with a plain overlay and no signing requirement.
|
|
assert.equal(caps.BASELINE.android.includes('display.power'), false);
|
|
assert.ok(caps.BASELINE.tizen.includes('display.power'));
|
|
assert.equal(caps.BASELINE.web.includes('display.power'), false, 'a browser tab cannot power a panel');
|
|
assert.equal(caps.BASELINE.brightsign.includes('display.power'), false, 'CEC needs the host bridge');
|
|
|
|
// And the fielded Tizen player really does implement both halves.
|
|
assert.ok(/showScreenOff/.test(SRC.tizen) && /clearScreenOff/.test(SRC.tizen));
|
|
});
|
|
|
|
test('no baseline claims anything that needs a host bridge or a privilege grant', () => {
|
|
// The one-line version of the whole audit. Each of these is conditional at runtime on every
|
|
// platform that has it at all, so no undeclared display may be assumed to have it.
|
|
const CONDITIONAL = [
|
|
'system.kiosk', 'system.brightness', 'system.screen_timeout', 'system.install_apk',
|
|
'system.shell', 'system.time', 'system.device_owner', 'sync.native', 'display.resolution',
|
|
];
|
|
for (const [family, list] of Object.entries(caps.BASELINE)) {
|
|
for (const c of CONDITIONAL) {
|
|
assert.equal(list.includes(c), false, `${family} baseline claims the conditional capability ${c}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('the BrightSign baseline claims nothing that needs autorun.brs', () => {
|
|
// The bridge's JS half is served by us and is always current, but `port` needs an roHtmlWidget
|
|
// created with nodejs_enabled:true by the on-device BrightScript. No released package shipped
|
|
// both halves, and the one unit on alpha runs BSN Supervisor's widget, where hasHost() is false.
|
|
for (const c of ['system.reboot', 'display.power', 'display.resolution', 'system.self_update', 'system.restart_player']) {
|
|
assert.equal(caps.BASELINE.brightsign.includes(c), false,
|
|
`BASELINE.brightsign claims ${c}, which the web player only declares behind BS.hasHost()`);
|
|
}
|
|
// The web player really does gate all of them on the bridge — if that changes, this reasoning
|
|
// needs revisiting rather than silently going stale.
|
|
assert.ok(/BS && BS\.hasHost\(\)/.test(SRC.web), 'the web player still gates host capabilities on hasHost()');
|
|
});
|
|
|
|
test('the Android baseline claims only what a Tier-0 panel can do', () => {
|
|
// set_volume / set_brightness are #160 Track-A (released v1.9.10) and are applied with no owner,
|
|
// no admin and no WRITE_SETTINGS. Their Tier-1 siblings are not claimed.
|
|
assert.ok(handles('android', 'set_brightness') && handles('android', 'set_volume'));
|
|
assert.ok(caps.BASELINE.android.includes('display.brightness'), 'the per-window dim is Tier 0');
|
|
assert.equal(caps.BASELINE.android.includes('system.brightness'), false, 'the backlight needs WRITE_SETTINGS');
|
|
assert.equal(caps.BASELINE.android.includes('system.reboot'), false, 'STPolicy.reboot() needs device owner');
|
|
});
|
|
|
|
test('capability names are spelled the same in the server and in all four players', () => {
|
|
/*
|
|
* The failure this catches is silent by design: the server's parser DROPS a name it does not
|
|
* recognise, so a typo in a player removes a control rather than raising anything. Any
|
|
* capability-shaped string a player quotes must be one the server knows.
|
|
*/
|
|
const SHAPE = /['"]((?:playback|audio|display|remote|system|sync|offline)\.[a-z_]+)['"]/g;
|
|
for (const [player, src] of Object.entries(SRC)) {
|
|
for (const m of src.matchAll(SHAPE)) {
|
|
assert.ok(caps.CAP_SET.has(m[1]),
|
|
`${player} quotes '${m[1]}', which is not in CAPABILITIES — the server would drop it silently`);
|
|
}
|
|
}
|
|
});
|