diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 9e30533..6a39bce 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -246,6 +246,7 @@ export default { 'dashboard.toast.playlist_assigned_other': 'Playlist assigned to {n} devices', 'dashboard.toast.command_sent': '{cmd} sent to {sent}/{total} devices', 'dashboard.toast.command_sent_with_offline': '{cmd} sent to {sent}/{total} devices ({offline} offline)', + 'dashboard.toast.command_unsupported_n': '{n} skipped — their players do not support it.', // Content library 'content.title': 'Content Library', @@ -669,6 +670,11 @@ export default { 'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect', 'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable', 'device.toast.command_no_ack': '{cmd} — no server response', + 'device.toast.command_unsupported': '{cmd} — this player does not support it ({cap}). Reload the page to refresh the controls.', + 'device.caps.title': 'Player capabilities', + 'device.caps.declared': 'Reported by the player itself. Controls this display cannot honour are hidden.', + 'device.caps.assumed': 'This player has not reported its capabilities, so the defaults for its platform are assumed. They update the next time it connects.', + 'device.caps.none': 'The player reports it can do nothing.', // Settings 'settings.title': 'Settings', diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 1369532..e79ecfc 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -927,10 +927,17 @@ function attachGroupHandlers(groupsWithDevices, allDevices) { try { const result = await api.sendGroupCommand(groupId, type); - const msg = result.offline > 0 + // A group is routinely mixed-platform, so these buttons stay visible — "reboot" is + // meaningful for the Android panels in the group even when the web players in it can + // never honour it. What must not happen is the toast counting those as sent: the + // operator would walk away believing the whole group rebooted. + let msg = result.offline > 0 ? t('dashboard.toast.command_sent_with_offline', { cmd: cmdLabel, sent: result.sent, total: result.total, offline: result.offline }) : t('dashboard.toast.command_sent', { cmd: cmdLabel, sent: result.sent, total: result.total }); - showToast(msg, result.offline > 0 ? 'warning' : 'success'); + if (result.unsupported > 0) { + msg += ' ' + t('dashboard.toast.command_unsupported_n', { n: result.unsupported }); + } + showToast(msg, (result.offline > 0 || result.unsupported > 0) ? 'warning' : 'success'); } catch (err) { showToast(err.message, 'error'); } diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 655636b..974c787 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -192,6 +192,22 @@ async function loadDevice(deviceId, activeTab = null) { try { const device = await api.getDevice(deviceId); currentDevice = device; + + /* + * Does this display support `cap`? Drives which controls render at all. + * + * Every control used to be offered to every display: a browser tab was shown "Reboot device", + * a Tizen TV was shown screen power. They did nothing, silently, and read as bugs. Hidden + * rather than disabled — a greyed-out button on a panel that will NEVER gain the capability is + * a permanent question ("what do I have to do to enable this?") with no answer. The capability + * list is shown in the Info tab so a missing control is explainable. + * + * The server resolves the baseline for the ~440 displays that declare nothing, so this sees a + * populated list either way and never has to know the difference. + */ + const caps = Array.isArray(device.capabilities) ? device.capabilities : null; + const can = (cap) => (caps ? caps.includes(cap) : true); // no list at all => pre-capability server, show everything + const latestTelemetry = device.telemetry?.[0] || {}; const diagWidget = (device.assignments || []).find(a => a && a.widget_type === 'diag-smoothness'); @@ -205,13 +221,14 @@ async function loadDevice(deviceId, activeTab = null) {
+ ${can('remote.screenshot') ? ` + ` : ''} ${device.android_version && !device.android_version.startsWith('Web/') ? ` ` : ''} @@ -219,21 +236,25 @@ async function loadDevice(deviceId, activeTab = null) {
- ${device.tier === 2 ? ` + ${/* tier===2 is kept alongside the capability: it is already an accurate RUNTIME signal from + the panel, and a device-owner display that has not yet shipped a capability declaration + would otherwise lose these buttons the day this deploys. */ + (device.tier === 2 || can('system.device_owner')) ? `
${t('device.tier2.label')} + ${(device.tier === 2 || can('system.kiosk')) ? ` - + ` : ''}
` : ''}
${t('device.tab.now_playing')} ?
${t('device.tab.playlist')} ?
${t('device.tab.info')} ?
-
${t('device.tab.remote')} ?
- ${(device.client_type === 'apk' || device.android_version) ? `
${t('device.tab.controls')} ?
` : ''} + ${(can('remote.stream') || can('remote.input') || can('remote.screenshot')) ? `
${t('device.tab.remote')} ?
` : ''} + ${(can('audio.volume') || can('display.brightness') || can('system.brightness') || can('system.screen_timeout')) ? `
${t('device.tab.controls')} ?
` : ''} ${device.tier === 2 ? `
${t('device.tab.terminal')} ?
` : ''}
@@ -443,6 +464,22 @@ async function loadDevice(deviceId, activeTab = null) { ` : ''} + +
+

${t('device.caps.title')}

+
+ ${caps ? t('device.caps.declared') : t('device.caps.assumed')} +
+
+ ${(device.capabilities || []).map(c => `${esc(c)}`).join('') + || `${t('device.caps.none')}`} +
+
+

${t('device.timeline.title')}

@@ -515,42 +552,48 @@ async function loadDevice(deviceId, activeTab = null) {
+ ${can('system.reboot') ? ` + ` : ''} + ${can('display.power') ? ` + ` : ''} + ${can('display.power') ? ` + ` : ''} + ${can('system.restart_player') ? ` + ` : ''} + ${can('system.self_update') ? ` + ` : ''} + ${can('system.reboot') ? ` + ` : ''}
+ ${can('remote.stream') ? `
@@ -592,12 +637,14 @@ async function loadDevice(deviceId, activeTab = null) {

${t('device.remote.start_prompt')}

-
+
` : ''}
+ ${can('remote.stream') ? ` -
- +
` : ''} + ${can('remote.input') ? ` +
@@ -623,28 +670,31 @@ async function loadDevice(deviceId, activeTab = null) {
- + ` : ''} ${device.tier === 2 ? ` ${t('device.remote.system_view_owner')} ` : ` + ${can('remote.screenshot') ? ` - ${t('device.remote.system_view_hint')}`} + ${t('device.remote.system_view_hint')}` : ''}`} - + ` : ''} - ${(device.client_type === 'apk' || device.android_version) ? ` + ${(can('audio.volume') || can('display.brightness') || can('system.brightness') || can('system.screen_timeout')) ? `
${t('device.sysctl.subtitle')}
+ ${can('audio.volume') ? ` - + ` : ''} + ${can('display.brightness') ? ` - - ${(device.can_write_settings || device.tier === 2) ? ` + ` : ''} + ${(device.can_write_settings || device.tier === 2 || can('system.brightness') || can('system.screen_timeout')) ? ` @@ -755,9 +805,14 @@ async function loadDevice(deviceId, activeTab = null) { if (activeTab) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); - const tab = document.querySelector(`.tab[data-tab="${activeTab}"]`); + // Both loops above just cleared every tab, so a requested tab that no longer renders (its + // capability went away, or the page was reloaded against a player that has since declared a + // smaller set) would leave NO tab selected and the page blank. Fall back to Info, which is + // never gated. + const wanted = document.getElementById(`tab-${activeTab}`) ? activeTab : 'info'; + const tab = document.querySelector(`.tab[data-tab="${wanted}"]`); if (tab) tab.classList.add('active'); - const content = document.getElementById(`tab-${activeTab}`); + const content = document.getElementById(`tab-${wanted}`); if (content) content.classList.add('active'); } @@ -1230,13 +1285,18 @@ function setupActions(device) { }, 3000); }); - // Send a command and surface the three-state ack as a toast. + // Send a command and surface the ack as a toast. // - delivered: device received it (green/success) // - queued: device is offline, will deliver on reconnect (amber/warning) + // - unsupported: the player cannot do this at all (red/error, names the capability) // - no_ack / fallback: server didn't respond or queue unavailable (red/error) function sendWithFeedback(type, cmdLabel, successKey) { sendCommand(device.id, type, {}, (ack) => { if (ack?.delivered) showToast(t(successKey), 'success'); + // Reachable from a stale tab rendered before the panel declared its capabilities: the + // button was there when the page loaded and is gone on reload. Say why rather than + // showing the generic "undeliverable", which reads as a network problem. + else if (ack?.reason === 'unsupported') showToast(t('device.toast.command_unsupported', { cmd: cmdLabel, cap: ack.capability || '' }), 'error'); else if (ack?.queued) showToast(t('device.toast.command_queued', { cmd: cmdLabel }), 'warning'); else if (ack?.reason === 'no_ack') showToast(t('device.toast.command_no_ack', { cmd: cmdLabel }), 'error'); else showToast(t('device.toast.command_undeliverable', { cmd: cmdLabel }), 'error'); diff --git a/server/db/database.js b/server/db/database.js index 4de3c8b..0f1d8a0 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -398,6 +398,12 @@ const migrations = [ // Which physical output this row paints. A dual-output player runs one player per connector and // registers as two devices; without this they are indistinguishable in the dashboard. "ALTER TABLE devices ADD COLUMN output_index INTEGER", + // What the player says it can do, as a JSON array (see lib/player-capabilities.js). NULL means + // the panel has never declared — the overwhelming majority of the fleet on the day this ships — + // and resolves to a per-platform baseline. That NULL is load bearing: an empty array is a player + // genuinely reporting it can do nothing, and collapsing the two would either strip the UI from + // every existing display or ignore a player that told us the truth. + "ALTER TABLE devices ADD COLUMN capabilities TEXT", // Backfill a unique 6-digit PIN for already-paired devices that predate the // settings_pin column (their next reconnect re-sends device:paired with it, so // the existing fleet isn't locked out of the on-device menu). Idempotent: the diff --git a/server/lib/player-capabilities.js b/server/lib/player-capabilities.js index 5bcfad8..d338b14 100644 --- a/server/lib/player-capabilities.js +++ b/server/lib/player-capabilities.js @@ -31,7 +31,7 @@ const CAPABILITIES = [ // audio 'audio.mute', 'audio.volume', // display - 'display.rotation', 'display.power', 'display.resolution', + 'display.rotation', 'display.power', 'display.resolution', 'display.brightness', // remote view / control 'remote.screenshot', 'remote.stream', 'remote.input', // lifecycle @@ -39,6 +39,12 @@ const CAPABILITIES = [ // 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 @@ -60,7 +66,7 @@ const BASELINE = { 'playback.video', 'playback.image', 'playback.widget', 'playback.youtube', 'playback.zones', 'playback.transitions', 'playback.pip', 'audio.mute', 'audio.volume', - 'display.rotation', 'display.power', + 'display.rotation', 'display.power', 'display.brightness', 'remote.screenshot', 'remote.stream', 'remote.input', 'system.reboot', 'system.restart_player', 'system.self_update', 'sync.clock', 'offline.cache', @@ -155,4 +161,82 @@ function parseDeclared(raw) { return list.filter((c) => CAP_SET.has(c)); } -module.exports = { CAPABILITIES, CAP_SET, BASELINE, capabilitiesFor, supports, platformFamily, parseDeclared }; +/* + * 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, +}; diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js index 18feff2..b2ccb8f 100644 --- a/server/routes/device-groups.js +++ b/server/routes/device-groups.js @@ -9,6 +9,7 @@ const { accessContext } = require('../lib/tenancy'); // scope. No-op for JWT sessions; for tokens a read/write scope is rejected. const { requireScope } = require('../middleware/apiToken'); const { resolveSyncBackend, BACKENDS } = require('../lib/sync-backend'); +const playerCapabilities = require('../lib/player-capabilities'); const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/; const ALLOWED_COMMANDS = [ @@ -385,8 +386,10 @@ router.post('/:id/command', requireScope('full'), requireGroupWrite, (req, res) if (!type) return res.status(400).json({ error: 'command type required' }); if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' }); + // SELECT * because the capability check needs the platform/declaration columns, not just the + // three fields the response uses. const devices = db.prepare(` - SELECT d.id, d.name, d.status FROM devices d + SELECT d.* FROM devices d JOIN device_group_members dgm ON d.id = dgm.device_id WHERE dgm.group_id = ? `).all(req.params.id); @@ -395,6 +398,16 @@ router.post('/:id/command', requireScope('full'), requireGroupWrite, (req, res) const results = []; for (const device of devices) { + // A group is the mixed-platform case by definition — a lobby group holding two Android panels + // and a BrightSign gets "reboot" sent to all three, and the one that cannot honour it used to + // report 'sent'. Reporting per-device rather than refusing the whole command: the operator's + // intent is valid for the members that can do it, and failing the lot because one member is a + // browser tab would be its own bug. + const verdict = playerCapabilities.commandAllowed(device, type); + if (!verdict.ok) { + results.push({ device_id: device.id, name: device.name, status: 'unsupported', capability: verdict.capability }); + continue; + } const room = deviceNs.adapter.rooms.get(device.id); if (room && room.size > 0) { deviceNs.to(device.id).emit('device:command', { type, payload: payload || {} }); @@ -406,8 +419,9 @@ router.post('/:id/command', requireScope('full'), requireGroupWrite, (req, res) const sent = results.filter(r => r.status === 'sent').length; const offline = results.filter(r => r.status === 'offline').length; - console.log(`Group command '${type}' sent to group '${req.group.name}': ${sent} sent, ${offline} offline`); - res.json({ success: true, sent, offline, total: devices.length, results }); + const unsupported = results.filter(r => r.status === 'unsupported').length; + console.log(`Group command '${type}' sent to group '${req.group.name}': ${sent} sent, ${offline} offline, ${unsupported} unsupported`); + res.json({ success: true, sent, offline, unsupported, total: devices.length, results }); }); module.exports = router; diff --git a/server/routes/devices.js b/server/routes/devices.js index 88ca81e..e98dcc7 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -8,6 +8,7 @@ const { accessContext } = require('../lib/tenancy'); const { stripDeviceSecrets, stripDeviceSecretsForList } = require('../lib/device-sanitize'); const { layoutZones, orphanCountsByDevice } = require('../lib/zone-validate'); const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings preservation +const playerCapabilities = require('../lib/player-capabilities'); // List devices in the caller's current workspace. // Phase 2.2a: filter by workspace_id instead of user_id. The caller's current @@ -171,7 +172,13 @@ router.get('/:id', (req, res) => { 'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC' ).all(req.params.id, dayAgo).map(r => r.reported_at); - res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog, deviceEvents }); + // The RESOLVED capability set, not the raw column. The dashboard hides controls a panel cannot + // honour, and it must not have to know about the baseline fallback — a legacy device declaring + // nothing has to arrive at the dashboard looking exactly like one that declared its baseline, + // or ~440 existing displays lose their controls the moment this ships. + const capabilities = playerCapabilities.capabilitiesFor(device); + + res.json({ ...stripDeviceSecrets(device), capabilities, telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog, deviceEvents }); }); // Helper: check device write access via the workspace the device belongs to. diff --git a/server/services/scheduler.js b/server/services/scheduler.js index 3e2003a..8a77ef3 100644 --- a/server/services/scheduler.js +++ b/server/services/scheduler.js @@ -1,5 +1,6 @@ const { db } = require('../db/database'); const { _localParts } = require('../lib/schedule-eval'); +const playerCapabilities = require('../lib/player-capabilities'); let io = null; @@ -120,6 +121,11 @@ function rebootDue(schedule, tz, now, lastDate) { function maybeRebootDevice(device, now, deviceNs) { const { due, today } = rebootDue(effectiveRebootSchedule(device), deviceTz(device), now, device.reboot_last_date); if (!due) return; + // A nightly reboot can be scheduled on a group, and a group holds browser tabs. Sending it + // anyway was harmless in itself, but the log line below then claimed a reboot had fired every + // night for a display that cannot reboot — which is what someone reads when they are trying to + // work out why a panel never came back. + if (!playerCapabilities.supports(device, 'system.reboot')) return; db.prepare('UPDATE devices SET reboot_last_date = ? WHERE id = ?').run(today, device.id); deviceNs.to(device.id).emit('device:command', { type: 'reboot', payload: { scheduled: true } }); console.log(`[reboot] scheduled reboot fired for device ${device.id} (${device.name || 'unnamed'}) at local ${today}`); diff --git a/server/test/capability-declaration.test.js b/server/test/capability-declaration.test.js new file mode 100644 index 0000000..0ec187c --- /dev/null +++ b/server/test/capability-declaration.test.js @@ -0,0 +1,172 @@ +'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.reboot'), + 'and that baseline is what keeps the existing fleet\'s controls on screen'); +}); diff --git a/server/test/device-command-gating.test.js b/server/test/device-command-gating.test.js new file mode 100644 index 0000000..70cbe3c --- /dev/null +++ b/server/test/device-command-gating.test.js @@ -0,0 +1,87 @@ +'use strict'; + +// Hiding a button is not enforcement, and the dashboard is not the only way to send a command. +// The socket is reachable directly, a group send fans out to a mixed-platform fleet, and an +// operator with a tab open from before the panel declared anything still has the old controls on +// screen. In every one of those paths a command the player cannot honour used to be DELIVERED and +// silently dropped — the "reports success and changes nothing" shape again, one layer down. +// +// So the refusal lives on the server and names the capability, and the group route reports the +// skipped devices separately from the ones it actually reached. A group toast that counts an +// unreachable web player as "sent" is how an operator walks away believing the whole group +// rebooted. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const caps = require('../lib/player-capabilities'); + +test('a browser tab is refused reboot, and the refusal says which capability was missing', () => { + const web = { android_version: 'Web/Chrome' }; + const verdict = caps.commandAllowed(web, 'reboot'); + assert.equal(verdict.ok, false); + assert.equal(verdict.capability, 'system.reboot', 'the operator has to be told WHY, not just "no"'); +}); + +test('the legacy fleet is not locked out of the commands it has always accepted', () => { + // The failure mode that would be worse than the bug: several hundred Android displays declare + // nothing, and a refusal keyed off "declared nothing => supports nothing" bricks every control + // in the product at once. + const legacy = { client_type: 'apk', android_version: '9' }; + for (const cmd of ['reboot', 'launch', 'refresh', 'update', 'screen_on', 'screen_off', 'set_volume']) { + assert.equal(caps.commandAllowed(legacy, cmd).ok, true, `${cmd} must still reach a legacy Android panel`); + } +}); + +test('a command with no capability requirement is never refused', () => { + // set_debug is diagnostics. Gating it would take away the tool you reach for precisely when a + // panel is misreporting what it can do. + assert.equal(caps.capabilityForCommand('set_debug'), null); + assert.equal(caps.commandAllowed({ android_version: 'Web/Chrome' }, 'set_debug').ok, true); +}); + +test('an unrecognised command type is passed through, not silently swallowed', () => { + // New player features ship before the server learns their names. Refusing by default would make + // every such command fail with a confusing "unsupported" instead of reaching the panel. + assert.equal(caps.capabilityForCommand('some_future_command'), null); + assert.equal(caps.commandAllowed({ client_type: 'apk' }, 'some_future_command').ok, true); +}); + +test('shutdown and reboot share one privilege, so a panel cannot be half-refused', () => { + // They are the same "device power lifecycle" authority. Splitting them produced a UI with + // Shutdown present and Reboot missing on the same display, which reads as a broken dashboard. + assert.equal(caps.capabilityForCommand('shutdown'), caps.capabilityForCommand('reboot')); +}); + +test('the per-window dim is NOT the backlight — conflating them hides a working slider', () => { + // set_brightness is the player's own overlay (Android Tier 0, no device owner); + // set_system_brightness writes the real backlight and needs settings-write. Mapping both to + // system.brightness — which is deliberately absent from every baseline because it is + // conditional — would have removed the overlay slider from the entire undeclared Android fleet. + const legacy = { client_type: 'apk', android_version: '11' }; + assert.equal(caps.commandAllowed(legacy, 'set_brightness').ok, true, 'overlay dim has always worked here'); + assert.equal(caps.commandAllowed(legacy, 'set_system_brightness').ok, false, 'backlight is conditional'); + assert.notEqual(caps.capabilityForCommand('set_brightness'), caps.capabilityForCommand('set_system_brightness')); +}); + +test('every command in the map points at a capability that actually exists', () => { + // A typo here does not fail loudly: supports() returns false for an unknown name, so the command + // is refused for EVERY device on every platform, forever. + for (const [cmd, cap] of Object.entries(caps.COMMAND_CAPABILITY)) { + if (cap === null) continue; + assert.ok(caps.CAP_SET.has(cap), `${cmd} maps to unknown capability ${cap}`); + } +}); + +test('a device row that failed to load refuses everything rather than guessing', () => { + // A missing row would otherwise fall through platformFamily() to the web baseline and cheerfully + // authorise commands against a device that does not exist. + assert.equal(caps.commandAllowed(null, 'reboot').ok, false); + assert.equal(caps.commandAllowed(undefined, 'set_volume').ok, false); +}); + +test('a player declaring nothing at all is refused every gated command', () => { + const mute = { client_type: 'apk', capabilities: '[]' }; + assert.equal(caps.commandAllowed(mute, 'reboot').ok, false); + assert.equal(caps.commandAllowed(mute, 'set_volume').ok, false); + assert.equal(caps.commandAllowed(mute, 'set_debug').ok, true, 'ungated commands still pass'); +}); diff --git a/server/test/device-controls-hidden.test.js b/server/test/device-controls-hidden.test.js new file mode 100644 index 0000000..549e6d0 --- /dev/null +++ b/server/test/device-controls-hidden.test.js @@ -0,0 +1,190 @@ +'use strict'; + +// The dashboard offered every control to every display. "Reboot device" on a browser tab, screen +// power on a Tizen TV, a Remote tab whose live view is a permanently black canvas on a player with +// no framebuffer read. Every one of them looked like a working button and did nothing — the +// "reports success and changes nothing" shape that keeps costing people days. +// +// Controls are now HIDDEN, not disabled: a greyed-out button on a panel that will never gain the +// capability is a permanent unanswerable question. Which makes the opposite failure the dangerous +// one — a gate that is slightly too strict strips controls from the several hundred displays +// already in the field, none of which declare anything. That case gets its own test below, and it +// is the one to read first if this file ever goes red. +// +// This renders the real device-detail template out of the source file rather than asserting on a +// copy of it, so a control added later without a gate shows up here instead of in production. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const SRC = fs.readFileSync( + path.join(__dirname, '..', '..', 'frontend', 'js', 'views', 'device-detail.js'), 'utf8'); + +// The template is one tagged region inside loadDevice(). Pull it out and evaluate it against +// stubbed helpers — the point is which controls appear, not how they are styled. +const START = 'contentEl.innerHTML = `'; +const template = (() => { + const i = SRC.indexOf(START); + assert.ok(i > 0, 'device-detail.js no longer has the innerHTML template this test renders'); + const j = SRC.indexOf('\n `;', i); + assert.ok(j > i, 'could not find the end of the template'); + return SRC.slice(i + START.length, j); +})(); + +function render(device) { + const caps = Array.isArray(device.capabilities) ? device.capabilities : null; + const sandbox = { + device, + caps, + can: (cap) => (caps ? caps.includes(cap) : true), + latestTelemetry: {}, + diagWidget: null, + // Stubs. Each returns something recognisable so a control cannot be "found" by accident. + t: (key) => key, + esc: (s) => String(s == null ? '' : s), + formatBytes: () => '0 MB', + formatUptime: () => '0m', + ssidLabel: () => 'ssid', + livenessBadge: () => ({ state: 'online', label: 'online', title: '' }), + renderDiagPanel: () => '', + renderDeviceClock: () => '', + renderPlaylist: () => '', + isBrightSignDevice: (d) => String(d.platform || '').toLowerCase().includes('brightsign'), + TERMINAL_PRESETS: [], + localStorage: { getItem: () => null, setItem: () => {} }, + Math, Date, JSON, String, Array, Object, + }; + return vm.runInNewContext('`' + template + '`', sandbox); +} + +const ANDROID_FULL = { + client_type: 'apk', android_version: '13', + capabilities: ['playback.video', 'audio.volume', 'display.power', 'display.brightness', + 'remote.screenshot', 'remote.stream', 'remote.input', + 'system.reboot', 'system.restart_player', 'system.self_update'], +}; +const WEB = { + android_version: 'Web/Chrome', + capabilities: ['playback.video', 'audio.volume', 'remote.screenshot', 'remote.stream', + 'remote.input', 'system.restart_player'], +}; +const TIZEN = { + platform: 'Tizen 6.5', + capabilities: ['playback.video', 'audio.volume', 'display.rotation', 'remote.input', + 'system.restart_player'], +}; +const BRIGHTSIGN = { + platform: 'brightsign', hardware_model: 'XT245', + capabilities: ['playback.video', 'audio.volume', 'display.power', 'display.rotation', + 'remote.input', 'system.reboot', 'system.restart_player'], +}; + +const has = (html, id) => html.includes(`id="${id}"`); + +test('a browser tab is no longer offered controls over a machine it cannot touch', () => { + const html = render(WEB); + assert.equal(has(html, 'rebootBtn'), false, 'a tab cannot reboot the PC it is running on'); + assert.equal(has(html, 'shutdownBtn'), false); + assert.equal(has(html, 'screenOffBtn'), false, 'nor switch off the monitor'); + assert.equal(has(html, 'screenOnBtn'), false); + assert.equal(has(html, 'forceUpdateBtn'), false, 'nor update itself — the page reloads instead'); + assert.ok(has(html, 'launchAppBtn'), 'but reloading the player IS something it can do'); +}); + +test('a Tizen TV is not offered screen power or the reboot it has no API for', () => { + const html = render(TIZEN); + assert.equal(has(html, 'screenOffBtn'), false); + assert.equal(has(html, 'screenOnBtn'), false); + assert.equal(has(html, 'rebootBtn'), false); + assert.equal(has(html, 'forceUpdateBtn'), false); +}); + +test('a BrightSign IS offered the screen power and reboot it genuinely has', () => { + // The check that catches gating written as "hide everything that is not Android", which would + // read as correct on every other test in this file. + const html = render(BRIGHTSIGN); + assert.ok(has(html, 'screenOffBtn')); + assert.ok(has(html, 'screenOnBtn')); + assert.ok(has(html, 'rebootBtn')); +}); + +test('an Android panel keeps the full control set', () => { + const html = render(ANDROID_FULL); + for (const id of ['rebootBtn', 'screenOffBtn', 'screenOnBtn', 'launchAppBtn', 'forceUpdateBtn', + 'screenshotBtn', 'startRemoteBtn', 'sysVolume', 'sysWinBrightness']) { + assert.ok(has(html, id), `${id} must survive`); + } +}); + +test('THE REGRESSION THAT MATTERS: an undeclared legacy display loses nothing', () => { + // ~440 real displays declare nothing. If the gate reads "no declaration => supports nothing", + // every one of them loses its entire control panel the moment this deploys — a far worse bug + // than the one being fixed. The server resolves a per-platform baseline for them, and this + // asserts the client renders whatever it is handed rather than second-guessing it. + const legacyAndroid = { client_type: 'apk', android_version: '9' }; // no capabilities field + const html = render(legacyAndroid); + for (const id of ['rebootBtn', 'screenOffBtn', 'screenOnBtn', 'launchAppBtn', 'forceUpdateBtn', + 'screenshotBtn', 'startRemoteBtn']) { + assert.ok(has(html, id), `${id} disappeared for a display that never declared anything`); + } +}); + +test('the live view is hidden on a player that cannot capture, and the key pad is not', () => { + // Start used to produce a canvas that stayed black forever, which reads as a dead panel rather + // than as an unsupported feature. The D-pad still works there — it is a different mechanism. + const html = render(TIZEN); + assert.equal(has(html, 'startRemoteBtn'), false, 'no screenshot stream to start'); + assert.equal(has(html, 'remoteCanvas'), false, 'and no permanently black canvas'); + assert.ok(html.includes('KEYCODE_DPAD_CENTER'), 'key input is unaffected'); +}); + +test('a player with no remote surface at all loses the whole Remote tab', () => { + const blind = { platform: 'brightsign', capabilities: ['playback.video', 'audio.volume'] }; + const html = render(blind); + assert.equal(html.includes('data-tab="remote"'), false, 'no tab'); + assert.equal(has(html, 'tab-remote'), false, 'and no orphaned tab body behind it'); +}); + +test('a tab trigger is never rendered without its content, or the click blanks the page', () => { + // setupTabs() does getElementById(`tab-${dataset.tab}`).classList.add(...) with no null check, + // so a trigger whose body was gated away throws on click and leaves every tab deselected. + for (const device of [WEB, TIZEN, BRIGHTSIGN, ANDROID_FULL, { client_type: 'apk' }]) { + const html = render(device); + for (const m of html.matchAll(/data-tab="([\w-]+)"/g)) { + assert.ok(has(html, `tab-${m[1]}`), + `tab "${m[1]}" has a trigger but no content for ${device.platform || device.android_version || 'apk'}`); + } + } +}); + +test('the capability list is shown, so a missing control is explainable', () => { + // Hiding controls with no explanation just moves the confusion: "the reboot button vanished" + // is a support ticket unless the page says what the panel reported. + const html = render(TIZEN); + assert.ok(html.includes('device.caps.title')); + assert.ok(html.includes('remote.input'), 'the actual declared names are listed'); + assert.ok(html.includes('device.caps.declared')); + + const legacy = render({ client_type: 'apk' }); + assert.ok(legacy.includes('device.caps.assumed'), + 'and an undeclared display says so rather than presenting a guess as fact'); +}); + +test('every gated control still renders balanced markup', () => { + // A gate placed around an opening tag but not its close leaves the rest of the page inside a + // stray element, which does not throw and does not show up in any assertion above. + for (const device of [WEB, TIZEN, BRIGHTSIGN, ANDROID_FULL, { client_type: 'apk' }, + { platform: 'brightsign', capabilities: [] }]) { + const html = render(device); + const open = (html.match(//g) || []).length; + assert.equal(open, close, + `unbalanced
for ${device.platform || device.android_version || 'apk'}: ${open} open, ${close} close`); + const bopen = (html.match(//g) || []).length; + assert.equal(bopen, bclose, 'unbalanced