mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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:
parent
6bc709d2f7
commit
0082191f9b
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`}
|
||||
<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>
|
||||
</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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
172
server/test/capability-declaration.test.js
Normal file
172
server/test/capability-declaration.test.js
Normal 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');
|
||||
});
|
||||
87
server/test/device-command-gating.test.js
Normal file
87
server/test/device-command-gating.test.js
Normal 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');
|
||||
});
|
||||
190
server/test/device-controls-hidden.test.js
Normal file
190
server/test/device-controls-hidden.test.js
Normal 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>');
|
||||
}
|
||||
});
|
||||
122
server/test/group-command-mixed-fleet.test.js
Normal file
122
server/test/group-command-mixed-fleet.test.js
Normal 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');
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue