mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -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
173 lines
7.9 KiB
JavaScript
173 lines
7.9 KiB
JavaScript
'use strict';
|
|
|
|
// End-to-end for the one thing the whole capability model rests on: what the player says at
|
|
// registration is what the dashboard renders from.
|
|
//
|
|
// The trap this guards is a three-state column read as two. NULL means "this display has never
|
|
// told us anything" and must fall back to its platform baseline, because several hundred displays
|
|
// in the field will not update before the next dashboard deploy and blanking their controls is a
|
|
// far worse bug than the one being fixed. '[]' means "I genuinely can do nothing" and must be
|
|
// honoured. Anything that collapses those two — COALESCE, a falsy check, `caps || baseline` —
|
|
// looks correct in review and takes out either the legacy fleet or the honest players.
|
|
//
|
|
// Capabilities are also re-read on EVERY register, not once: an Android panel gains real
|
|
// screenshots the moment accessibility is switched on and loses Tier-2 when device owner is
|
|
// revoked. A first-registration-only write would pin the display to whatever was true at pairing.
|
|
|
|
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { spawn } = require('node:child_process');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const fs = require('node:fs');
|
|
const crypto = require('node:crypto');
|
|
const Database = require('better-sqlite3');
|
|
const ioClient = require('../node_modules/socket.io-client');
|
|
|
|
const { freePort } = require('./helpers/free-port');
|
|
let PORT, BASE, proc, db;
|
|
const DATA_DIR = path.join(os.tmpdir(), 'st-caps-' + crypto.randomBytes(4).toString('hex'));
|
|
const LOG = path.join(os.tmpdir(), 'st-caps-' + crypto.randomBytes(4).toString('hex') + '.log');
|
|
const S = {};
|
|
|
|
const jfetch = async (p, opts = {}) => {
|
|
const res = await fetch(BASE + p, opts);
|
|
let body = null; try { body = await res.json(); } catch { /* */ }
|
|
return { status: res.status, body };
|
|
};
|
|
const auth = () => ({ Authorization: 'Bearer ' + S.token, 'Content-Type': 'application/json' });
|
|
|
|
before(async () => {
|
|
PORT = await freePort();
|
|
BASE = `http://127.0.0.1:${PORT}`;
|
|
const logFd = fs.openSync(LOG, 'w');
|
|
proc = spawn('node', ['server.js'], {
|
|
cwd: path.join(__dirname, '..'),
|
|
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
|
|
stdio: ['ignore', logFd, logFd],
|
|
});
|
|
let up = false;
|
|
for (let i = 0; i < 80; i++) {
|
|
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ }
|
|
await new Promise(r => setTimeout(r, 250));
|
|
}
|
|
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
|
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
|
|
|
|
const email = 'u' + crypto.randomBytes(5).toString('hex') + '@x.local';
|
|
const reg = await jfetch('/api/auth/register', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password: 'Passw0rd123' }),
|
|
});
|
|
S.token = reg.body.token;
|
|
const me = await jfetch('/api/auth/me', { headers: auth() });
|
|
S.wsId = me.body.accessible_workspaces[0].id;
|
|
});
|
|
after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
|
|
|
|
function makeDevice() {
|
|
const id = crypto.randomUUID();
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
db.prepare(`INSERT INTO devices (id, name, status, workspace_id, device_token, client_type, created_at)
|
|
VALUES (?, 'CAPS', 'online', ?, ?, 'apk', strftime('%s','now'))`)
|
|
.run(id, S.wsId, token);
|
|
return { id, token };
|
|
}
|
|
|
|
function register(dev, payload = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const s = ioClient(BASE + '/device', { transports: ['websocket'], reconnection: false });
|
|
s.on('connect', () => s.emit('device:register', { device_id: dev.id, device_token: dev.token, ...payload }));
|
|
s.on('device:registered', () => resolve(s));
|
|
s.on('device:auth-error', (e) => reject(new Error(e && e.error)));
|
|
setTimeout(() => reject(new Error('register timeout')), 10000);
|
|
});
|
|
}
|
|
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
|
const stored = (id) => db.prepare('SELECT capabilities FROM devices WHERE id = ?').get(id).capabilities;
|
|
|
|
test('a player that declares its capabilities has them persisted', async () => {
|
|
const dev = makeDevice();
|
|
const s = await register(dev, { capabilities: ['playback.video', 'system.reboot'] });
|
|
await wait(400);
|
|
assert.deepEqual(JSON.parse(stored(dev.id)), ['playback.video', 'system.reboot']);
|
|
s.close();
|
|
});
|
|
|
|
test('THE DISTINCTION: a player that declares nothing leaves the column NULL', async () => {
|
|
// Not '[]'. The legacy fleet lands here, and NULL is what routes them to their platform
|
|
// baseline instead of to an empty dashboard.
|
|
const dev = makeDevice();
|
|
const s = await register(dev);
|
|
await wait(400);
|
|
assert.equal(stored(dev.id), null,
|
|
'an absent field must stay distinguishable from an empty declaration');
|
|
s.close();
|
|
});
|
|
|
|
test('...while an EMPTY declaration is stored as an empty array and honoured', async () => {
|
|
const dev = makeDevice();
|
|
const s = await register(dev, { capabilities: [] });
|
|
await wait(400);
|
|
assert.equal(stored(dev.id), '[]', 'a player saying "I can do nothing" is a real answer');
|
|
s.close();
|
|
});
|
|
|
|
test('capabilities are re-read on every register, not frozen at pairing', async () => {
|
|
// Accessibility switched on between boots is the concrete case: the panel gains real
|
|
// screenshots and the Remote tab has to appear without a re-pair.
|
|
const dev = makeDevice();
|
|
let s = await register(dev, { capabilities: ['playback.video'] });
|
|
await wait(400);
|
|
s.close();
|
|
await wait(200);
|
|
|
|
s = await register(dev, { capabilities: ['playback.video', 'remote.screenshot'] });
|
|
await wait(400);
|
|
assert.deepEqual(JSON.parse(stored(dev.id)), ['playback.video', 'remote.screenshot'],
|
|
'a stale set would keep a working control hidden until someone re-paired the display');
|
|
s.close();
|
|
});
|
|
|
|
test('a capability the server has never heard of is dropped, not stored', async () => {
|
|
// The column feeds a UI gate and a server-side command check. Letting arbitrary strings through
|
|
// would let a player invent its own permissions by naming them.
|
|
const dev = makeDevice();
|
|
const s = await register(dev, { capabilities: ['playback.video', 'system.root_shell_lol'] });
|
|
await wait(400);
|
|
assert.deepEqual(JSON.parse(stored(dev.id)), ['playback.video']);
|
|
s.close();
|
|
});
|
|
|
|
test('a garbage capabilities field does not stop the display registering', async () => {
|
|
// A player mid-rollout with a bug in its declaration must still come online; a screen that
|
|
// refuses to connect is worse than one with the wrong buttons.
|
|
const dev = makeDevice();
|
|
const s = await register(dev, { capabilities: 'not-an-array' });
|
|
await wait(400);
|
|
assert.equal(s.connected, true, 'the register still succeeded');
|
|
s.close();
|
|
});
|
|
|
|
test('the device API returns the RESOLVED list, so the dashboard never re-derives it', async () => {
|
|
// Two implementations of "what can this display do" drift apart, and the one in the browser is
|
|
// the one nobody runs tests against. The server answers; the dashboard only renders.
|
|
const declared = makeDevice();
|
|
const s = await register(declared, { capabilities: ['playback.video'] });
|
|
await wait(400);
|
|
s.close();
|
|
|
|
const legacy = makeDevice(); // never registered: NULL column, baseline expected
|
|
|
|
const a = await jfetch(`/api/devices/${declared.id}`, { headers: auth() });
|
|
assert.equal(a.status, 200);
|
|
assert.deepEqual(a.body.capabilities, ['playback.video']);
|
|
|
|
const b = await jfetch(`/api/devices/${legacy.id}`, { headers: auth() });
|
|
assert.equal(b.status, 200);
|
|
assert.ok(Array.isArray(b.body.capabilities) && b.body.capabilities.length > 0,
|
|
'an undeclared Android panel must come back with its baseline, not an empty list');
|
|
assert.ok(b.body.capabilities.includes('system.restart_player'),
|
|
'and that baseline is what keeps the existing fleet\'s controls on screen');
|
|
});
|