Show only the controls a display can actually honour

Every device control was offered to every display. A browser tab was shown
"Reboot device", a Tizen TV was shown screen power, a player with no
framebuffer read was shown a live view that stayed black. They all looked
like working buttons and did nothing — the "reports success and changes
nothing" shape that keeps costing people days.

Players now declare what they can do at registration, because only the
player knows at runtime: an Android panel gains real screenshots when
accessibility is switched on and loses Tier-2 when device owner is revoked.
The dashboard hides what is not supported rather than disabling it, and the
Info tab lists the capability set so a missing control is explainable.

The declaration is three-state and the middle state is load bearing: NULL
means "has never told us anything" and falls back to a per-platform
baseline, because several hundred displays in the field will not update
before this deploys and blanking their controls would be a far worse bug.
An empty array means "I genuinely can do nothing" and is honoured.

Hiding a button is not enforcement, so unsupported commands are also
refused server-side — the socket is reachable directly and a stale tab
still renders the old controls. Group sends report skipped devices
separately from sent ones; counting an unreachable member as "sent" is how
an operator walks away believing the whole group rebooted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-08-05 14:24:52 -05:00
parent 6bc709d2f7
commit 0082191f9b
14 changed files with 841 additions and 33 deletions

View file

@ -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',

View file

@ -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');
}

View file

@ -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) {
<div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="devicePreviewBtn">${t('device.preview_btn')}</button>
<button class="btn btn-secondary btn-sm" id="renameBtn">${t('device.rename')}</button>
${can('remote.screenshot') ? `
<button class="btn btn-secondary btn-sm" id="screenshotBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
${t('device.screenshot_btn')}
</button>
</button>` : ''}
${device.android_version && !device.android_version.startsWith('Web/') ? `
<button class="btn btn-secondary btn-sm" id="deviceOwnerBtn" title="${t('device.owner_provision.tip')}">${t('device.owner_provision.btn')}</button>` : ''}
<button class="btn btn-secondary btn-sm" id="blockDeviceBtn">${device.blocked ? 'Unblock' : 'Block'}</button>
@ -219,21 +236,25 @@ async function loadDevice(deviceId, activeTab = null) {
</div>
</div>
${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')) ? `
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:8px 0 4px" title="${t('device.tier2.tip')}">
<span style="font-size:12px;color:var(--text-muted)">${t('device.tier2.label')}</span>
<button class="btn btn-secondary btn-sm" id="t2Reboot">${t('device.tier2.reboot')}</button>
<button class="btn btn-secondary btn-sm" id="t2Lock">${t('device.tier2.lock')}</button>
${(device.tier === 2 || can('system.kiosk')) ? `
<button class="btn btn-secondary btn-sm" id="t2KioskOn">${t('device.tier2.kiosk_on')}</button>
<button class="btn btn-secondary btn-sm" id="t2KioskOff">${t('device.tier2.kiosk_off')}</button>
<button class="btn btn-secondary btn-sm" id="t2KioskOff">${t('device.tier2.kiosk_off')}</button>` : ''}
</div>` : ''}
<div class="tabs">
<div class="tab active" data-tab="nowplaying">${t('device.tab.now_playing')} <span class="help-tip" data-tip="${t('device.tab.now_playing_tip')}">?</span></div>
<div class="tab" data-tab="playlist">${t('device.tab.playlist')} <span class="help-tip" data-tip="${t('device.tab.playlist_tip')}">?</span></div>
<div class="tab" data-tab="info">${t('device.tab.info')} <span class="help-tip" data-tip="${t('device.tab.info_tip')}">?</span></div>
<div class="tab" data-tab="remote">${t('device.tab.remote')} <span class="help-tip" data-tip="${t('device.tab.remote_tip')}">?</span></div>
${(device.client_type === 'apk' || device.android_version) ? `<div class="tab" data-tab="controls">${t('device.tab.controls')} <span class="help-tip" data-tip="${t('device.tab.controls_tip')}">?</span></div>` : ''}
${(can('remote.stream') || can('remote.input') || can('remote.screenshot')) ? `<div class="tab" data-tab="remote">${t('device.tab.remote')} <span class="help-tip" data-tip="${t('device.tab.remote_tip')}">?</span></div>` : ''}
${(can('audio.volume') || can('display.brightness') || can('system.brightness') || can('system.screen_timeout')) ? `<div class="tab" data-tab="controls">${t('device.tab.controls')} <span class="help-tip" data-tip="${t('device.tab.controls_tip')}">?</span></div>` : ''}
${device.tier === 2 ? `<div class="tab" data-tab="terminal">${t('device.tab.terminal')} <span class="help-tip" data-tip="${t('device.tab.terminal_tip')}">?</span></div>` : ''}
</div>
@ -443,6 +464,22 @@ async function loadDevice(deviceId, activeTab = null) {
` : ''}
</div>
<!-- What this display can do.
Controls are now hidden when the player cannot honour them, which on its own looks
like the dashboard has lost features. This is the answer to "where did the reboot
button go" it names the exact set the panel reported, and says plainly when the set
is a per-platform assumption rather than something the player actually declared. -->
<div style="margin-top:20px">
<h4 style="font-size:13px;margin-bottom:8px">${t('device.caps.title')}</h4>
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">
${caps ? t('device.caps.declared') : t('device.caps.assumed')}
</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
${(device.capabilities || []).map(c => `<span style="font-family:monospace;font-size:11px;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;padding:2px 6px">${esc(c)}</span>`).join('')
|| `<span style="font-size:12px;color:var(--danger)">${t('device.caps.none')}</span>`}
</div>
</div>
<!-- Uptime Timeline (24h) -->
<div style="margin-top:20px">
<h4 style="font-size:13px;margin-bottom:8px">${t('device.timeline.title')}</h4>
@ -515,42 +552,48 @@ async function loadDevice(deviceId, activeTab = null) {
</div>
<div style="margin-top:20px;display:flex;gap:8px;flex-wrap:wrap">
${can('system.reboot') ? `
<button class="btn btn-secondary btn-sm" id="rebootBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
${t('device.ctl.reboot_device')}
</button>
</button>` : ''}
${can('display.power') ? `
<button class="btn btn-secondary btn-sm" id="screenOffBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
${t('device.ctl.screen_off')}
</button>
</button>` : ''}
${can('display.power') ? `
<button class="btn btn-secondary btn-sm" id="screenOnBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
${t('device.ctl.screen_on')}
</button>
</button>` : ''}
${can('system.restart_player') ? `
<button class="btn btn-secondary btn-sm" id="launchAppBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
${t('device.ctl.launch_player')}
</button>
</button>` : ''}
${can('system.self_update') ? `
<button class="btn btn-secondary btn-sm" id="forceUpdateBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
${t('device.ctl.force_update')}
</button>
</button>` : ''}
${can('system.reboot') ? `
<button class="btn btn-danger btn-sm" id="shutdownBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg>
${t('device.ctl.shutdown')}
</button>
</button>` : ''}
</div>
<!-- #109: PiP overlay tester. Pushes device:pip-show/clear via POST /api/pip
@ -577,9 +620,11 @@ async function loadDevice(deviceId, activeTab = null) {
</div>
</div>
${(can('remote.stream') || can('remote.input') || can('remote.screenshot')) ? `
<!-- Remote Control Tab -->
<div class="tab-content" id="tab-remote">
<div class="remote-container">
${can('remote.stream') ? `
<div class="remote-screen" id="remoteScreen">
<canvas id="remoteCanvas" width="960" height="540" style="background:#000;width:100%"></canvas>
<div class="no-screenshot" id="remoteOverlay" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
@ -592,12 +637,14 @@ async function loadDevice(deviceId, activeTab = null) {
<p style="color:var(--text-secondary)">${t('device.remote.start_prompt')}</p>
</div>
</div>
</div>
</div>` : ''}
<div class="remote-controls">
${can('remote.stream') ? `
<button class="btn btn-primary" id="startRemoteBtn">${t('device.remote.start')}</button>
<button class="btn btn-secondary" id="stopRemoteBtn" style="display:none">${t('device.remote.stop')}</button>
<hr style="border-color:var(--border);margin:8px 0">
<!-- Always available -->
<hr style="border-color:var(--border);margin:8px 0">` : ''}
${can('remote.input') ? `
<!-- Key pad -->
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_UP')">${t('device.remote.vol_up')}</button>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_DOWN')">${t('device.remote.vol_down')}</button>
<hr style="border-color:var(--border);margin:8px 0">
@ -623,28 +670,31 @@ async function loadDevice(deviceId, activeTab = null) {
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_off')">${t('device.remote.scrn_off')}</button>
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_on')">${t('device.remote.scrn_on')}</button>
</div>
</div>
</div>` : ''}
${device.tier === 2 ? `
<span style="font-size:10px;color:var(--success);line-height:1.2;display:block;margin-top:8px">${t('device.remote.system_view_owner')}</span>
` : `
${can('remote.screenshot') ? `
<button class="btn btn-primary btn-sm" id="enableSystemCaptureBtn" onclick="window._enableSystemView()" title="${t('device.remote.system_view_tooltip')}" style="margin-top:8px">
${t('device.remote.enable_system_view')}
</button>
<span id="systemViewHint" style="font-size:10px;color:var(--text-muted);line-height:1.2;display:block;margin-top:4px">${t('device.remote.system_view_hint')}</span>`}
</div>
<span id="systemViewHint" style="font-size:10px;color:var(--text-muted);line-height:1.2;display:block;margin-top:4px">${t('device.remote.system_view_hint')}</span>` : ''}`}
</div>
</div>
</div>` : ''}
${(device.client_type === 'apk' || device.android_version) ? `
${(can('audio.volume') || can('display.brightness') || can('system.brightness') || can('system.screen_timeout')) ? `
<!-- Controls Tab (#160 Track-A system control no device owner needed) -->
<div class="tab-content" id="tab-controls">
<div style="font-size:11px;color:var(--text-muted);margin-bottom:12px">${t('device.sysctl.subtitle')}</div>
<div style="display:grid;grid-template-columns:130px 1fr;gap:14px 14px;align-items:center;font-size:13px;max-width:480px">
${can('audio.volume') ? `
<label>${t('device.sysctl.volume')}</label>
<input type="range" min="0" max="100" value="${Math.round((device.media_volume != null ? device.media_volume : 0.5) * 100)}" id="sysVolume" style="width:100%">
<input type="range" min="0" max="100" value="${Math.round((device.media_volume != null ? device.media_volume : 0.5) * 100)}" id="sysVolume" style="width:100%">` : ''}
${can('display.brightness') ? `
<label>${t('device.sysctl.brightness_window')}</label>
<input type="range" min="5" max="100" value="${Math.round((device.window_brightness != null && device.window_brightness >= 0 ? device.window_brightness : 1) * 100)}" id="sysWinBrightness" style="width:100%">
${(device.can_write_settings || device.tier === 2) ? `
<input type="range" min="5" max="100" value="${Math.round((device.window_brightness != null && device.window_brightness >= 0 ? device.window_brightness : 1) * 100)}" id="sysWinBrightness" style="width:100%">` : ''}
${(device.can_write_settings || device.tier === 2 || can('system.brightness') || can('system.screen_timeout')) ? `
<label>${t('device.sysctl.brightness_system')}</label>
<input type="range" min="5" max="100" value="${Math.round((device.system_brightness != null ? device.system_brightness : 0.8) * 100)}" id="sysBrightness" style="width:100%">
<label>${t('device.sysctl.sleep')}</label>
@ -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');

View file

@ -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

View file

@ -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,
};

View file

@ -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;

View file

@ -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.

View file

@ -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}`);

View file

@ -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');
});

View file

@ -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');
});

View file

@ -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(/<div\b/g) || []).length;
const close = (html.match(/<\/div>/g) || []).length;
assert.equal(open, close,
`unbalanced <div> for ${device.platform || device.android_version || 'apk'}: ${open} open, ${close} close`);
const bopen = (html.match(/<button\b/g) || []).length;
const bclose = (html.match(/<\/button>/g) || []).length;
assert.equal(bopen, bclose, 'unbalanced <button>');
}
});

View file

@ -0,0 +1,122 @@
'use strict';
// A group is the mixed-platform case by definition: a lobby group holding two Android panels and
// a couple of browser tabs. "Reboot" is a legitimate thing to ask that group, and the two Android
// panels should get it — but the browser tabs cannot reboot their host, and the response used to
// count them as sent. The operator reads "sent to 4/4 devices" and walks away believing the whole
// group rebooted, which is the exact failure the capability model exists to end, just aggregated.
//
// The choice being pinned here: report per-device, do NOT refuse the whole command. Failing all
// four because one member is a browser tab would be its own bug.
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 { freePort } = require('./helpers/free-port');
let PORT, BASE, proc, db;
const DATA_DIR = path.join(os.tmpdir(), 'st-grpcaps-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-grpcaps-' + 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;
const g = await jfetch('/api/groups', {
method: 'POST', headers: auth(), body: JSON.stringify({ name: 'lobby' }),
});
S.groupId = g.body.id;
// Two Android panels (declare nothing -> baseline, i.e. the legacy fleet) and two browser tabs.
S.android = [mkDevice({ client_type: 'apk', android_version: '12' }), mkDevice({ client_type: 'apk', android_version: '12' })];
S.web = [mkDevice({ android_version: 'Web/Chrome' }), mkDevice({ android_version: 'Web/Chrome' })];
for (const id of [...S.android, ...S.web]) {
db.prepare('INSERT INTO device_group_members (group_id, device_id) VALUES (?, ?)').run(S.groupId, id);
}
});
after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
function mkDevice(cols) {
const id = crypto.randomUUID();
db.prepare(`INSERT INTO devices (id, name, status, workspace_id, device_token, client_type, android_version, created_at)
VALUES (?, ?, 'offline', ?, ?, ?, ?, strftime('%s','now'))`)
.run(id, 'panel-' + id.slice(0, 4), S.wsId, crypto.randomBytes(16).toString('hex'),
cols.client_type || null, cols.android_version || null);
return id;
}
const send = (type) => jfetch(`/api/groups/${S.groupId}/command`, {
method: 'POST', headers: auth(), body: JSON.stringify({ type }),
});
test('reboot on a mixed group does not count the browser tabs as sent', async () => {
const r = await send('reboot');
assert.equal(r.status, 200);
assert.equal(r.body.total, 4);
assert.equal(r.body.unsupported, 2, 'the two web players cannot reboot their host');
assert.equal(r.body.offline, 2, 'the two Android panels are reachable in principle, just not connected');
assert.equal(r.body.sent, 0);
assert.notEqual(r.body.offline + r.body.sent, 4,
'before this, "4/4" was reported and the operator believed the whole group rebooted');
});
test('the response names which device was skipped and what it lacked', async () => {
const r = await send('reboot');
const skipped = r.body.results.filter(x => x.status === 'unsupported');
assert.equal(skipped.length, 2);
for (const x of skipped) {
assert.ok(S.web.includes(x.device_id), 'only the web players are skipped');
assert.equal(x.capability, 'system.reboot', 'so the reason is diagnosable without guessing');
assert.ok(x.name, 'named, because a device_id alone means nothing to an operator');
}
});
test('a command every member supports skips nobody', async () => {
// The control case: gating that quietly refuses everything looks identical to gating that
// works, until someone checks a command that should pass.
const r = await send('launch');
assert.equal(r.body.unsupported, 0, 'restarting the player is something all four can do');
assert.equal(r.body.offline, 4);
});
test('one unsupported member does not fail the command for the rest of the group', async () => {
const r = await send('reboot');
assert.equal(r.status, 200, 'the request itself succeeds');
assert.equal(r.body.success, true);
assert.equal(r.body.results.length, 4, 'every member is accounted for, none silently dropped');
});

View file

@ -4,6 +4,7 @@ const { db } = require('../db/database');
const { accessContext, accessibleWorkspaceIds } = require('../lib/tenancy');
const { workspaceRoom } = require('../lib/socket-rooms');
const { protectSocket } = require('../lib/safe-socket');
const playerCapabilities = require('../lib/player-capabilities');
// Phase 2.3: workspace-scoped socket rooms + per-command permission gates.
// Replaces the previous flat dashboardNs.emit broadcast (which leaked every
@ -121,6 +122,22 @@ module.exports = function setupDashboardSocket(io) {
if (typeof ack === 'function') ack({ delivered: false, reason: 'forbidden' });
return;
}
// Hiding the button is not enforcement. This socket is reachable directly, group sends fan
// out to mixed-platform fleets, and an older dashboard tab left open still renders the old
// controls. A command the panel cannot honour is refused HERE, with the capability named, so
// it fails loudly instead of being delivered and silently ignored — which is the failure
// this whole mechanism exists to end.
const devRow = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
const verdict = playerCapabilities.commandAllowed(devRow, type);
if (!verdict.ok) {
console.warn(`Command ${type} refused for device ${device_id}: needs ${verdict.capability}`);
if (typeof ack === 'function') {
ack({ delivered: false, reason: 'unsupported', capability: verdict.capability });
}
return;
}
const room = deviceNs.adapter.rooms.get(device_id);
if (room && room.size > 0) {
deviceNs.to(device_id).emit('device:command', { type, payload });

View file

@ -17,6 +17,7 @@ const flapLimiter = require('../lib/flap-limiter');
const sessionSettle = require('../lib/session-settle'); // #148 patch2: eviction-storm debounce
const { resolveIdentity } = require('../lib/device-identity');
const { resolveSyncBackend } = require('../lib/sync-backend');
const capsLib = require('../lib/player-capabilities');
const logCoalescer = require('../lib/log-coalescer');
const loopLag = require('../services/loop-lag');
const deviceSettings = require('../lib/device-settings'); // #150 delete+re-pair settings restore
@ -141,6 +142,32 @@ function applyHardwareIdentity(deviceId, data) {
.run(model, serial, osVersion, output, deviceId);
}
/*
* Persist what the player says it can do.
*
* Written on every register rather than change-detected like persistIdentity, because the set is
* RUNTIME state, not identity: an Android panel gains remote.screenshot the moment accessibility
* is switched on and loses the device-owner commands if it is demoted, with no reconnect and no
* version change to notice. A stale set here means the dashboard hides a control the panel now
* has, or offers one it just lost the exact failure this whole mechanism exists to prevent.
*
* An absent field must leave the column ALONE. A player that does not declare (every device in
* the field today, and any older build after an upgrade) has to keep falling back to its platform
* baseline; writing NULL would be the same outcome but writing '[]' would strip its entire UI.
* Those two are one typo apart, which is why the guard is explicit rather than a COALESCE.
*/
function applyCapabilities(deviceId, data) {
const raw = data && (data.capabilities ?? (data.device_info && data.device_info.capabilities));
if (raw === undefined || raw === null) return; // never declared — baseline stands
if (!Array.isArray(raw)) return; // malformed — keep whatever we had
// Store only names this server understands, so an unknown capability from a newer player cannot
// grow the column unboundedly. parseDeclared does the same filtering on read; doing it on write
// as well keeps the stored value honest about what the server will actually act on.
const known = raw.filter((c) => capsLib.CAP_SET.has(c));
db.prepare('UPDATE devices SET capabilities = ? WHERE id = ?').run(JSON.stringify(known), deviceId);
}
function generateDeviceToken() {
return crypto.randomBytes(32).toString('hex');
}
@ -866,6 +893,9 @@ module.exports = function setupDeviceSocket(io) {
// register payload, not device_info, so the emptiness guard above does not apply to
// them. The function no-ops when the panel reports none of them.
applyHardwareIdentity(device_id, data);
// Same reasoning, same unconditional call: capabilities ride the top level and the
// function no-ops when the panel declares nothing.
applyCapabilities(device_id, data);
heartbeat.registerConnection(device_id, socket.id);
// #134: a same-socket re-register is a playlist REFRESH (~45-60s), NOT a reconnect and NOT