From 0f2ec474f47ed16a76e44a91d255adf98fed0d97 Mon Sep 17 00:00:00 2001 From: Rob K Date: Fri, 7 Aug 2026 08:52:19 +0100 Subject: [PATCH 1/2] Surface the screenshot-request verdict as a toast The server already acks dashboard:request-screenshot with { delivered, reason } (offline / unsupported via the capability registry), but no dashboard sender passed a callback, so clicking Screenshot on an offline device or an unsupporting player type showed "Screenshot requested" and then silently did nothing. requestScreenshot() now takes an optional callback using the same .timeout(5000) pattern as sendCommand(); the device-detail Screenshot button passes one and toasts the verdict (requested / unsupported / offline / no response). The dashboard grid and the 5s Now Playing poll keep firing-and-forgetting - no behavior change there. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU --- frontend/js/i18n/de.js | 3 +++ frontend/js/i18n/en.js | 3 +++ frontend/js/i18n/es.js | 3 +++ frontend/js/i18n/fr.js | 3 +++ frontend/js/i18n/it.js | 3 +++ frontend/js/i18n/pt.js | 3 +++ frontend/js/socket.js | 17 +++++++++++++++-- frontend/js/views/device-detail.js | 12 +++++++++--- 8 files changed, 42 insertions(+), 5 deletions(-) diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 8429af2..2fe1010 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -347,6 +347,9 @@ export default { 'device.assign.select_first': 'Erst etwas auswählen', 'device.assign.kiosk_widget_name': 'Kiosk: {name}', 'device.toast.screenshot_requested': 'Screenshot angefordert', + 'device.toast.screenshot_unsupported': 'Der Player dieses Displays unterstützt keine Screenshots', + 'device.toast.screenshot_offline': 'Display ist offline — Screenshot nicht angefordert', + 'device.toast.screenshot_failed': 'Screenshot-Anfrage fehlgeschlagen — keine Antwort vom Server', 'device.toast.renamed': 'Bildschirm umbenannt', 'device.toast.removing': 'Wird entfernt...', 'device.toast.removed': 'Bildschirm entfernt', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 821c15d..adcdaef 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -648,6 +648,9 @@ export default { 'device.assign.kiosk_widget_name': 'Kiosk: {name}', // Toasts 'device.toast.screenshot_requested': 'Screenshot requested', + 'device.toast.screenshot_unsupported': 'This display\'s player can\'t take screenshots', + 'device.toast.screenshot_offline': 'Display is offline — screenshot not requested', + 'device.toast.screenshot_failed': 'Screenshot request failed — no response from server', 'device.toast.renamed': 'Display renamed', 'device.toast.removing': 'Removing...', 'device.toast.removed': 'Display removed', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 9a6da03..ca1c47d 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -377,6 +377,9 @@ export default { 'device.assign.select_first': 'Primero selecciona algo', 'device.assign.kiosk_widget_name': 'Kiosco: {name}', 'device.toast.screenshot_requested': 'Captura solicitada', + 'device.toast.screenshot_unsupported': 'El reproductor de esta pantalla no admite capturas de pantalla', + 'device.toast.screenshot_offline': 'La pantalla está desconectada — captura no solicitada', + 'device.toast.screenshot_failed': 'La solicitud de captura falló — sin respuesta del servidor', 'device.toast.renamed': 'Pantalla renombrada', 'device.toast.removing': 'Eliminando...', 'device.toast.removed': 'Pantalla eliminada', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 438f36c..8acb3ec 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -347,6 +347,9 @@ export default { 'device.assign.select_first': 'Sélectionnez d\'abord un élément', 'device.assign.kiosk_widget_name': 'Kiosque : {name}', 'device.toast.screenshot_requested': 'Capture demandée', + 'device.toast.screenshot_unsupported': 'Le lecteur de cet écran ne prend pas en charge les captures d\'écran', + 'device.toast.screenshot_offline': 'Écran hors ligne — capture non demandée', + 'device.toast.screenshot_failed': 'Échec de la demande de capture — pas de réponse du serveur', 'device.toast.renamed': 'Écran renommé', 'device.toast.removing': 'Suppression...', 'device.toast.removed': 'Écran retiré', diff --git a/frontend/js/i18n/it.js b/frontend/js/i18n/it.js index 091c2d9..c5ad537 100644 --- a/frontend/js/i18n/it.js +++ b/frontend/js/i18n/it.js @@ -371,6 +371,9 @@ export default { 'device.assign.kiosk_widget_name': 'Chiosco: {name}', // Toasts 'device.toast.screenshot_requested': 'Richiesta screenshot inviata', + 'device.toast.screenshot_unsupported': 'Il player di questo schermo non supporta gli screenshot', + 'device.toast.screenshot_offline': 'Lo schermo è offline — screenshot non richiesto', + 'device.toast.screenshot_failed': 'Richiesta screenshot non riuscita — nessuna risposta dal server', 'device.toast.renamed': 'Schermo rinominato', 'device.toast.removing': 'Rimozione in corso...', 'device.toast.removed': 'Schermo rimosso', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index e7a90d5..3dcd64a 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -347,6 +347,9 @@ export default { 'device.assign.select_first': 'Selecione algo primeiro', 'device.assign.kiosk_widget_name': 'Quiosque: {name}', 'device.toast.screenshot_requested': 'Captura solicitada', + 'device.toast.screenshot_unsupported': 'O player desta tela não suporta capturas de tela', + 'device.toast.screenshot_offline': 'A tela está offline — captura não solicitada', + 'device.toast.screenshot_failed': 'Falha na solicitação de captura — sem resposta do servidor', 'device.toast.renamed': 'Tela renomeada', 'device.toast.removing': 'Removendo...', 'device.toast.removed': 'Tela removida', diff --git a/frontend/js/socket.js b/frontend/js/socket.js index a1e5840..e5e2d20 100644 --- a/frontend/js/socket.js +++ b/frontend/js/socket.js @@ -112,9 +112,22 @@ function emit(event, data) { if (cbs) cbs.forEach(cb => cb(data)); } -export function requestScreenshot(deviceId) { +// Optional callback receives the server-side ack: { delivered, reason, capability }. +// reason is 'offline' (no live connection) or 'unsupported' (this player type can't +// take screenshots). Callers without a callback keep firing-and-forgetting — the +// dashboard grid and the device-detail 5s poll stay silent; only the explicit +// Screenshot button asks for the verdict. +export function requestScreenshot(deviceId, callback) { console.log('requestScreenshot:', deviceId, 'socket connected:', dashboardSocket?.connected); - if (dashboardSocket) dashboardSocket.emit('dashboard:request-screenshot', { device_id: deviceId }); + if (!dashboardSocket) return; + if (typeof callback === 'function') { + dashboardSocket.timeout(5000).emit('dashboard:request-screenshot', { device_id: deviceId }, (err, ack) => { + if (err) callback({ delivered: false, reason: 'no_ack' }); + else callback(ack || { delivered: false, reason: 'no_ack' }); + }); + } else { + dashboardSocket.emit('dashboard:request-screenshot', { device_id: deviceId }); + } } export function startRemote(deviceId) { diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 32e6fba..7a26256 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -1084,10 +1084,16 @@ function setupActions(device) { document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device)); - // Screenshot button + // Screenshot button — pass a callback so the server's verdict surfaces as a toast + // instead of the request silently going nowhere (offline device, or a player type + // that can't capture at all, e.g. BrightSign). document.getElementById('screenshotBtn')?.addEventListener('click', () => { - requestScreenshot(device.id); - showToast(t('device.toast.screenshot_requested'), 'info'); + requestScreenshot(device.id, (ack) => { + if (ack?.delivered) showToast(t('device.toast.screenshot_requested'), 'info'); + else if (ack?.reason === 'unsupported') showToast(t('device.toast.screenshot_unsupported'), 'warning'); + else if (ack?.reason === 'offline') showToast(t('device.toast.screenshot_offline'), 'warning'); + else showToast(t('device.toast.screenshot_failed'), 'error'); + }); }); // Rename From 7d7be365f954538eee10c45a365048018c61484d Mon Sep 17 00:00:00 2001 From: Rob K Date: Fri, 7 Aug 2026 08:52:26 +0100 Subject: [PATCH 2/2] Warn against proxy-level security headers in the README helmet already sets X-Frame-Options, HSTS, CSP, etc., and manages them per route (widget/kiosk renders and the device preview remove or relax X-Frame-Options so they can be framed). A proxy-level header block adds a second copy, and browsers treat conflicting duplicate X-Frame-Options values as deny - which blanks the same-origin /player iframe behind the dashboard's Preview button. Seen in the wild behind a Caddy config that added X-Frame-Options: DENY on top of the app's SAMEORIGIN. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 1796d73..6e5709b 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,28 @@ server { } ``` +#### Don't add security headers at the proxy + +The app already sets `X-Frame-Options`, `Strict-Transport-Security`, `Content-Security-Policy`, +`X-Content-Type-Options`, etc. via [helmet](https://helmetjs.github.io/), and manages them +**per route**: widget/kiosk renders and the device preview deliberately remove or relax +`X-Frame-Options` so they can be framed, while the dashboard keeps the strict policy. + +A proxy-level header block (nginx `add_header X-Frame-Options DENY;`, a Caddy +`header { ... }` snippet, or a "security headers" preset) *adds a second copy* of these +headers on top of the app's. Browsers treat conflicting duplicate `X-Frame-Options` +values as `deny`, which breaks the dashboard's device Preview (a same-origin iframe of +`/player`) and widget previews with console errors like: + +``` +Refused to display 'https://…' in a frame because it set multiple +'X-Frame-Options' headers with conflicting values ('DENY, SAMEORIGIN'). +Falling back to 'deny'. +``` + +Let the proxy handle TLS, compression, and body-size limits only, and leave security +headers to the app. + ### Updating To update a running instance to the latest version: