diff --git a/README.md b/README.md index e0e1df8..a6c5911 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,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: diff --git a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt index 03bbde2..d99cd45 100644 --- a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt +++ b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt @@ -35,6 +35,10 @@ class DeviceInfo(private val context: Context) { // ISP's address as their screen's IP. Needs no permission — read straight off the // interfaces, so it works on Ethernet panels too, not just Wi-Fi. put("local_ip", getLocalIp() ?: JSONObject.NULL) + // Both stacks, not one: getLocalIp() filters to Inet4Address, so a v6-only panel + // reported NOTHING and the dashboard showed a dash for a screen that had a perfectly + // good address. A dual-stack panel now shows both. + put("local_ip6", getLocalIp6() ?: JSONObject.NULL) put("wifi_rssi", getWifiRSSI()) put("uptime_seconds", getUptimeSeconds()) // #74/#75: OS timezone + UTC clock (effective-tz resolution + dashboard skew indicator) @@ -221,6 +225,41 @@ class DeviceInfo(private val context: Context) { found } catch (e: Throwable) { null } + /** + * The panel's own IPv6 address, reported alongside the v4 one rather than instead of it — + * a dual-stack screen has both and an operator may need either. + * + * Deliberately NOT link-local (fe80::/10). Every interface has one, they are the addresses + * most likely to be enumerated first, and none of them can be dialled without also knowing + * the zone index — so putting one in the dashboard would fill the field with a string that + * cannot be pasted anywhere useful and hide the address that can. A global or unique-local + * address is the one someone reaching the panel on site actually needs. + * + * The scope check also drops multicast and the unspecified address; what survives is a + * routable unicast address. `hostAddress` can carry a %iface suffix on some builds, so it is + * trimmed — the field is for humans and for pasting into a browser. + */ + private fun getLocalIp6(): String? = try { + var found: String? = null + val ifaces = java.net.NetworkInterface.getNetworkInterfaces() + while (ifaces != null && ifaces.hasMoreElements() && found == null) { + val iface = ifaces.nextElement() + if (!iface.isUp || iface.isLoopback) continue + val addrs = iface.inetAddresses + while (addrs.hasMoreElements()) { + val addr = addrs.nextElement() + if (addr is java.net.Inet6Address && + !addr.isLoopbackAddress && !addr.isLinkLocalAddress && + !addr.isAnyLocalAddress && !addr.isMulticastAddress + ) { + found = addr.hostAddress?.substringBefore('%') + break + } + } + } + found + } catch (e: Throwable) { null } + @Suppress("DEPRECATION") private fun getWifiRSSI(): Int { return try { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 05251e2..588cbcb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -122,9 +122,19 @@ components: local_ip: type: [string, "null"] description: | - The device's **own address on its local network** (e.g. `192.168.1.42`), as + The device's **own IPv4 address on its local network** (e.g. `192.168.1.42`), as reported by the player itself. This is the one to use to reach a panel directly on - site. Null on players that do not report it, or where the platform withholds it. + site. Null on players that do not report it, where the platform withholds it, or on + a panel with no IPv4 address at all — see `local_ip6`. + local_ip6: + type: [string, "null"] + description: | + The device's **own IPv6 address on its local network**, reported alongside + `local_ip` rather than instead of it: a dual-stack panel has both and either may be + the one you need. Link-local addresses (`fe80::/10`) are deliberately excluded — + every interface has one and none can be reached without also knowing the zone + index, so this carries a global or unique-local address or nothing. Null on players + that do not report it and on IPv4-only panels. # --- Latest telemetry -------------------------------------------------------------- # Flattened from the most recent telemetry report. All null for a device that has diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 8429af2..eb07dec 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -260,6 +260,7 @@ export default { 'device.info.status': 'Status', 'device.info.ip_address': 'IP-Adresse', 'device.info.local_ip': 'Lokale IP', + 'device.info.local_ip6': 'Lokale IPv6', 'device.info.wifi_needs_location': 'Standortberechtigung erforderlich', 'device.info.battery': 'Akku', 'device.info.storage': 'Speicher', @@ -347,6 +348,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..2464929 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -476,6 +476,7 @@ export default { 'device.info.status': 'Status', 'device.info.ip_address': 'IP Address', 'device.info.local_ip': 'Local IP', + 'device.info.local_ip6': 'Local IPv6', 'device.info.wifi_needs_location': 'Needs location permission', 'device.info.battery': 'Battery', 'device.info.storage': 'Storage', @@ -648,6 +649,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..a1c1a71 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -290,6 +290,7 @@ export default { 'device.info.status': 'Estado', 'device.info.ip_address': 'Dirección IP', 'device.info.local_ip': 'IP local', + 'device.info.local_ip6': 'IPv6 local', 'device.info.wifi_needs_location': 'Requiere permiso de ubicación', 'device.info.battery': 'Batería', 'device.info.storage': 'Almacenamiento', @@ -377,6 +378,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..f144733 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -260,6 +260,7 @@ export default { 'device.info.status': 'Statut', 'device.info.ip_address': 'Adresse IP', 'device.info.local_ip': 'IP locale', + 'device.info.local_ip6': 'IPv6 locale', 'device.info.wifi_needs_location': 'Autorisation de localisation requise', 'device.info.battery': 'Batterie', 'device.info.storage': 'Stockage', @@ -347,6 +348,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..a69a088 100644 --- a/frontend/js/i18n/it.js +++ b/frontend/js/i18n/it.js @@ -276,6 +276,7 @@ export default { 'device.info.status': 'Stato', 'device.info.ip_address': 'Indirizzo IP', 'device.info.local_ip': 'IP locale', + 'device.info.local_ip6': 'IPv6 locale', 'device.info.wifi_needs_location': 'Richiede permesso di posizione', 'device.info.battery': 'Batteria', 'device.info.storage': 'Archiviazione', @@ -371,6 +372,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..7da268a 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -260,6 +260,7 @@ export default { 'device.info.status': 'Status', 'device.info.ip_address': 'Endereço IP', 'device.info.local_ip': 'IP local', + 'device.info.local_ip6': 'IPv6 local', 'device.info.wifi_needs_location': 'Requer permissão de localização', 'device.info.battery': 'Bateria', 'device.info.storage': 'Armazenamento', @@ -347,6 +348,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..df13ae3 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -366,6 +366,15 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.info.local_ip')}
${device.local_ip || '--'}
+ ${device.local_ip6 ? ` +
+ +
${t('device.info.local_ip6')}
+
${device.local_ip6}
+
` : ''} ${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.battery')}
@@ -1084,10 +1093,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 @@ -2070,6 +2085,9 @@ function updateTelemetryDisplay(telemetry) { if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) })); if (telemetry.wifi_ssid !== undefined) update('telWifi', ssidLabel(telemetry.wifi_ssid)); if (telemetry.local_ip) update('telLocalIp', telemetry.local_ip); + // update() no-ops when the card is absent, which is the case for a v4-only panel — a screen that + // acquires a v6 address mid-session picks the card up on the next full render, not this path. + if (telemetry.local_ip6) update('telLocalIp6', telemetry.local_ip6); if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm'); if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds)); if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) })); diff --git a/server/db/database.js b/server/db/database.js index 48f89fb..f0b4dde 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -382,6 +382,12 @@ const migrations = [ // the PUBLIC address the server sees the connection arrive from — both are useful and they are // not the same thing. A customer reading the public IP as "my screen's IP" prompted this. "ALTER TABLE device_telemetry ADD COLUMN local_ip TEXT", + // ...and its IPv6 one, in its own column rather than sharing the above. The player's collector + // filtered to Inet4Address, so a v6-only panel reported no address at all and the dashboard + // showed a dash for a screen that had a perfectly reachable address. Separate columns because a + // dual-stack panel genuinely has both and an operator may need either — collapsing them would + // make the field mean "whichever we happened to enumerate first". + "ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT", // Panel temperature in Celsius. REAL because the sensor reports fractions, and nullable because // only some hardware exposes one — Android and the browser players send nothing and must keep // reading as "no sensor" rather than "0 degrees", which is why every read site treats null as diff --git a/server/player/index.html b/server/player/index.html index 35b61fc..fb11b79 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -126,6 +126,26 @@ ScreenTinker Player @@ -841,8 +863,8 @@ if (document.getElementById('enableAudioPrompt')) return; const ov = document.createElement('div'); ov.id = 'enableAudioPrompt'; - ov.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:12px 22px;border-radius:8px;cursor:pointer;z-index:10000;font-size:14px;display:flex;gap:10px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,0.4)'; - ov.innerHTML = '🔇Tap to enable audio'; + ov.style.cssText = 'position:fixed;bottom:2.4rem;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.88);color:#fff;padding:1.2rem 2.2rem;border-radius:0.8rem;cursor:pointer;z-index:10000;font-size:1.4rem;display:flex;gap:1rem;align-items:center;box-shadow:0 0.4rem 1.6rem rgba(0,0,0,0.4)'; + ov.innerHTML = '🔇Tap to enable audio'; ov.addEventListener('click', () => { unlockAudioContext(); tryUnmuteLeader(); @@ -1030,9 +1052,9 @@ const tapOverlay = document.createElement('div'); tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer'; tapOverlay.innerHTML = ` -

ScreenTinker

-

Tap anywhere to start

-

Audio requires user interaction

+

ScreenTinker

+

Tap anywhere to start

+

Audio requires user interaction

`; tapOverlay.onclick = () => { unlockAudio(); diff --git a/server/routes/devices.js b/server/routes/devices.js index 44e30e3..4367d70 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -23,7 +23,7 @@ router.get('/', (req, res) => { const devices = db.prepare(` SELECT d.*, t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb, - t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, t.local_ip, + t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, t.local_ip, t.local_ip6, t.cpu_usage, s.filepath as screenshot_path, s.captured_at as screenshot_at, u.email as owner_email, u.name as owner_name diff --git a/server/test/openapi-contract.test.js b/server/test/openapi-contract.test.js index 185de59..de1c786 100644 --- a/server/test/openapi-contract.test.js +++ b/server/test/openapi-contract.test.js @@ -78,7 +78,7 @@ test('openapi: the spec version tracks the shipped release', () => { // GET /devices, so both must be documented and must not be described interchangeably. test('openapi: a device documents its WAN and LAN addresses distinctly', () => { const props = spec.components.schemas.Device.properties; - for (const field of ['ip_address', 'local_ip']) { + for (const field of ['ip_address', 'local_ip', 'local_ip6']) { assert.ok(props[field], `Device.${field} is returned by GET /devices but is not documented`); assert.ok( props[field].type.includes('null'), @@ -88,6 +88,11 @@ test('openapi: a device documents its WAN and LAN addresses distinctly', () => { } assert.match(props.ip_address.description, /WAN|public/i); assert.match(props.local_ip.description, /local network|LAN/i); + assert.match(props.local_ip6.description, /local network|LAN/i); + // The two LAN fields are a pair, not alternatives — a dual-stack panel reports both, so the + // spec must not let an integrator read one as a fallback for the other. + assert.match(props.local_ip.description, /IPv4/i); + assert.match(props.local_ip6.description, /IPv6/i); }); // "permission" is a sentinel, not a network name: Android 10+ withholds the SSID without a diff --git a/server/test/player-screen-scaling.test.js b/server/test/player-screen-scaling.test.js new file mode 100644 index 0000000..b8ca526 --- /dev/null +++ b/server/test/player-screen-scaling.test.js @@ -0,0 +1,92 @@ +'use strict'; + +/* + * The pairing code has to be readable on the panel it is displayed on. + * + * A CSS pixel covers a quarter of the screen area on a 4K panel that it does on 1080p, and a + * sixteenth on 8K. Every size on the player's pre-playback screens was a hard-coded pixel value, + * so the 72px pairing code that fills a 1080p screen became a smudge on a 4K wall — reported from + * the field, on exactly the screens signage gets installed on. + * + * The fix is a viewport-proportional root font size with everything on those screens expressed in + * rem, so the code holds the same ANGULAR size at any panel resolution. These tests pin the two + * properties that actually matter: nothing on those screens is left in px, and the scaling cannot + * leak into playback content. + */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); + +const SURFACES = [ + { name: 'web player', file: 'server/player/index.html' }, + { name: 'tizen player', file: 'tizen/css/style.css' }, +]; + +for (const { name, file } of SURFACES) { + test(`${name}: the root font size is viewport-proportional and clamped at both ends`, () => { + const src = read(file); + const m = src.match(/html\s*\{\s*font-size:\s*clamp\(\s*([\d.]+)px\s*,\s*([\d.]+)vmin\s*,\s*([\d.]+)px\s*\)/); + assert.ok(m, `${file} must set a clamped, vmin-based root font size`); + const [, min, vmin, max] = m.map(Number); + + // vmin, not vw: a portrait-mounted panel is as common as a landscape one, and vw would render + // a 1080x1920 screen at half size. + assert.ok(vmin > 0, 'the middle term must actually scale with the viewport'); + // 1rem should land on 10px at a 1080-tall viewport, so the rem values read as "px at 1080p" + // and a reviewer can check them against the design at a glance. + assert.ok(Math.abs(vmin * 1080 / 100 - 10) < 0.1, `1rem must be ~10px at 1080p, got ${vmin * 1080 / 100}px`); + // The floor keeps a dashboard preview iframe and a laptop window legible rather than + // microscopic; the ceiling stops an ultrawide from getting silly. + assert.ok(min >= 6 && min <= 10, `floor should keep small viewports readable, got ${min}px`); + assert.ok(max >= 32, `ceiling should not cap a genuine 8K panel too early, got ${max}px`); + }); +} + +test('web player: the pairing code scales, and is still the biggest thing on the screen', () => { + const src = read('server/player/index.html'); + const rule = src.slice(src.indexOf('.pairing-code {'), src.indexOf('.pairing-hint')); + const size = rule.match(/font-size:\s*([\d.]+)rem/); + assert.ok(size, 'the pairing code must be sized in rem, not px'); + assert.ok(Number(size[1]) >= 7, 'the code is what someone squints at from across a room'); + assert.match(rule, /letter-spacing:\s*[\d.]+rem/, 'letter-spacing must scale with it or the digits collide'); + + const h1 = src.match(/#setupScreen h1 \{ font-size: ([\d.]+)rem/); + assert.ok(h1 && Number(size[1]) > Number(h1[1]), 'the code must outrank the product name'); +}); + +test('tizen player: the pairing code scales too', () => { + const src = read('tizen/css/style.css'); + const rule = src.slice(src.indexOf('.code {'), src.indexOf('.hint {')); + assert.match(rule, /font-size:\s*[\d.]+rem/, 'the Tizen pairing code must be sized in rem'); + assert.match(rule, /letter-spacing:\s*[\d.]+rem/); +}); + +test('no pre-playback screen is left in hard-coded px', () => { + const src = read('server/player/index.html'); + // Everything from the setup screen through the status overlay. A px value surviving in here is + // one element that stays put while the rest of the screen grows around it. + for (const selector of ['#setupScreen h1', '#setupScreen .subtitle', '.pairing-hint', '.status-msg', '#statusOverlay h2', '#statusOverlay p']) { + const at = src.indexOf(selector + ' {'); + assert.ok(at > 0, `${selector} not found`); + const rule = src.slice(at, src.indexOf('}', at)); + const px = rule.match(/font-size:\s*[\d.]+px/); + assert.equal(px, null, `${selector} still has a hard-coded font-size — it will not scale`); + } +}); + +test('the scaling cannot reach playback content', () => { + const src = read('server/player/index.html'); + // The whole safety argument for moving the root font size is that only the pre-playback chrome + // uses rem. If a stage/zone/PiP rule starts using rem, resizing a panel would start resizing + // CONTENT, which is a different and much worse bug than the one being fixed here. + for (const selector of ['#stage', '.zone', '#pip']) { + const at = src.indexOf(selector + ' {'); + if (at < 0) continue; + const rule = src.slice(at, src.indexOf('}', at)); + assert.ok(!/[\d.]rem/.test(rule), `${selector} must not use rem — content sizing must stay independent`); + } +}); diff --git a/server/test/telemetry-ipv6.test.js b/server/test/telemetry-ipv6.test.js new file mode 100644 index 0000000..37b3324 --- /dev/null +++ b/server/test/telemetry-ipv6.test.js @@ -0,0 +1,92 @@ +'use strict'; + +/* + * A panel's own IPv6 address has to survive the trip from the player to the dashboard. + * + * It never used to exist: the Android collector filtered to Inet4Address, so a v6-only screen + * reported no address at all and the dashboard rendered a dash for a panel that was perfectly + * reachable. The fix is two columns rather than one, because a dual-stack panel genuinely has + * both and collapsing them would make the field mean "whichever interface enumerated first". + */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const Database = require('better-sqlite3'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); + +test('the Android collector reports BOTH stacks, not whichever it finds first', () => { + const src = read('android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt'); + assert.match(src, /put\("local_ip6"/, 'telemetry must carry local_ip6'); + assert.match(src, /getLocalIp6/, 'there must be a v6 collector'); + // The v4 one keeps its filter — this is an addition, not a replacement. A v4 panel must keep + // reporting exactly what it reported before. + assert.match(src, /addr is java\.net\.Inet4Address/, 'local_ip stays IPv4-only'); +}); + +test('link-local v6 is excluded — it cannot be dialled without a zone index', () => { + const src = read('android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt'); + const fn = src.slice(src.indexOf('private fun getLocalIp6'), src.indexOf('private fun getWifiRSSI')); + assert.ok(fn.length > 0, 'getLocalIp6 not found'); + for (const guard of ['isLinkLocalAddress', 'isLoopbackAddress', 'isAnyLocalAddress', 'isMulticastAddress']) { + assert.ok(fn.includes(guard), `getLocalIp6 must skip ${guard} addresses`); + } + // Every interface has an fe80:: address and it is usually enumerated first, so without this the + // field would fill up with strings nobody can paste anywhere and hide the useful address. + assert.match(fn, /substringBefore\('%'\)/, 'a %iface suffix must be trimmed before it reaches the UI'); +}); + +test('the column exists, holds a full-length v6 address, and is separate from local_ip', () => { + const db = new Database(':memory:'); + db.exec('CREATE TABLE device_telemetry (id INTEGER PRIMARY KEY, device_id TEXT, local_ip TEXT)'); + // The same statement the migration list runs. + db.exec('ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT'); + + const v6 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; + db.prepare('INSERT INTO device_telemetry (device_id, local_ip, local_ip6) VALUES (?, ?, ?)') + .run('d1', '192.168.1.42', v6); + const row = db.prepare('SELECT local_ip, local_ip6 FROM device_telemetry WHERE device_id = ?').get('d1'); + assert.equal(row.local_ip, '192.168.1.42', 'the v4 address is untouched by the addition'); + assert.equal(row.local_ip6, v6); + + // 45 characters is the longest legitimate IPv6 text form (IPv4-mapped, ::ffff:255.255.255.255). + // The write path caps at 45, so a real address must never be truncated by it. + assert.ok('::ffff:255.255.255.255'.length <= 45); + assert.ok(v6.length <= 45, 'a full uncompressed v6 address fits the cap the writer applies'); + db.close(); +}); + +test('the migration is registered, and does not replace the v4 column', () => { + const src = read('server/db/database.js'); + assert.match(src, /ALTER TABLE device_telemetry ADD COLUMN local_ip6 TEXT/); + assert.match(src, /ALTER TABLE device_telemetry ADD COLUMN local_ip TEXT/); +}); + +test('the server stores what the player sends, capped like its neighbour', () => { + const src = read('server/ws/deviceSocket.js'); + assert.match(src, /local_ip, local_ip6, temperature_c/, 'the INSERT must name the new column'); + assert.match( + src, + /typeof telemetry\.local_ip6 === 'string' \? telemetry\.local_ip6\.trim\(\)\.slice\(0, 45\)/, + 'device-supplied text headed for the dashboard must be trimmed and capped', + ); +}); + +test('both addresses reach the dashboard, and the v6 card only appears when there is one', () => { + const api = read('server/routes/devices.js'); + assert.match(api, /t\.local_ip, t\.local_ip6/, 'the device list must select the new column'); + + const ui = read('frontend/js/views/device-detail.js'); + assert.match(ui, /device\.local_ip6 \?/, 'the card must be conditional — no empty row for a v4-only fleet'); + assert.match(ui, /telLocalIp6/); + assert.match(ui, /device\.info\.local_ip6/, 'the label must go through i18n, not be hardcoded'); +}); + +test('every locale has the label — a missing key renders as the raw key', () => { + for (const lang of ['en', 'es', 'fr', 'de', 'pt', 'it']) { + const src = read(`frontend/js/i18n/${lang}.js`); + assert.match(src, /'device\.info\.local_ip6':/, `${lang} is missing device.info.local_ip6`); + } +}); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 2b602c9..c56ea82 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -1170,8 +1170,8 @@ module.exports = function setupDeviceSocket(io) { if (telemetry && deviceExists(device_id)) { db.prepare(` INSERT INTO device_telemetry (device_id, battery_level, battery_charging, storage_free_mb, storage_total_mb, - ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds, local_ip, temperature_c) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds, local_ip, local_ip6, temperature_c) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( device_id, telemetry.battery_level ?? null, @@ -1187,6 +1187,9 @@ module.exports = function setupDeviceSocket(io) { // Device-supplied text headed for a column the dashboard renders: trim and cap it. // 45 chars is the longest legitimate value (a full IPv6 address). typeof telemetry.local_ip === 'string' ? telemetry.local_ip.trim().slice(0, 45) || null : null, + // Same treatment for the v6 address. 45 is still the cap: it is the longest legitimate + // IPv6 text form (an IPv4-mapped one, `::ffff:255.255.255.255`). + typeof telemetry.local_ip6 === 'string' ? telemetry.local_ip6.trim().slice(0, 45) || null : null, // Only a finite number is a reading. A panel with no sensor sends nothing, and NaN or // Infinity from a flaky one must land as "no reading" rather than poisoning the column. typeof telemetry.temperature_c === 'number' && Number.isFinite(telemetry.temperature_c) diff --git a/tizen/css/style.css b/tizen/css/style.css index 85c12e3..5c0cc36 100644 --- a/tizen/css/style.css +++ b/tizen/css/style.css @@ -1,5 +1,21 @@ * { margin: 0; padding: 0; box-sizing: border-box; } +/* + * ONE KNOB for every pre-playback screen, matching server/player/index.html. + * + * A pairing code is read from across a room, so what must stay constant is ANGULAR size, not + * pixel size — and a CSS pixel covers a sixteenth of the screen area on an 8K panel that it does + * on 1080p. Sizes below are rem against this root: 1rem = 10px at a 1080-tall viewport, and it + * scales from there. A Tizen web app is handed a 1920x1080 logical viewport on most panels, where + * this is arithmetically identical to the pixel values it replaces — the point is the panels where + * it is not. + * + * vmin rather than vw: portrait-mounted panels are common and vw would halve everything on one. + * Only the setup/pairing/toast chrome uses rem; the stage and PiP layers are laid out in %/vw/vh + * and are untouched. + */ +html { font-size: clamp(7px, 0.926vmin, 56px); } + html, body { width: 100%; height: 100%; background: #000; color: #f1f5f9; @@ -19,39 +35,39 @@ html, body { .card { background: #111827; border: 1px solid #1f2937; - border-radius: 18px; - padding: 48px 64px; + border-radius: 1.8rem; + padding: 4.8rem 6.4rem; text-align: center; - max-width: 760px; + max-width: 76rem; } -.card h1 { color: #3b82f6; font-size: 44px; margin-bottom: 6px; } -.sub { color: #94a3b8; font-size: 22px; margin-bottom: 36px; } -.card label { display: block; text-align: left; color: #94a3b8; font-size: 18px; margin-bottom: 8px; } +.card h1 { color: #3b82f6; font-size: 4.4rem; margin-bottom: 0.6rem; } +.sub { color: #94a3b8; font-size: 2.2rem; margin-bottom: 3.6rem; } +.card label { display: block; text-align: left; color: #94a3b8; font-size: 1.8rem; margin-bottom: 0.8rem; } #serverUrl { - width: 100%; font-size: 26px; padding: 16px 20px; - border-radius: 10px; border: 2px solid #334155; - background: #0b1220; color: #f1f5f9; margin-bottom: 24px; + width: 100%; font-size: 2.6rem; padding: 1.6rem 2rem; + border-radius: 1rem; border: 0.2rem solid #334155; + background: #0b1220; color: #f1f5f9; margin-bottom: 2.4rem; } #serverUrl:focus { outline: none; border-color: #3b82f6; } button { - font-size: 24px; font-weight: bold; color: #fff; - background: #3b82f6; border: none; border-radius: 10px; - padding: 16px 40px; cursor: pointer; + font-size: 2.4rem; font-weight: bold; color: #fff; + background: #3b82f6; border: none; border-radius: 1rem; + padding: 1.6rem 4rem; cursor: pointer; } -button:focus { outline: 3px solid #93c5fd; } -button.ghost { background: transparent; color: #64748b; font-size: 18px; margin-top: 24px; padding: 8px; } +button:focus { outline: 0.3rem solid #93c5fd; } +button.ghost { background: transparent; color: #64748b; font-size: 1.8rem; margin-top: 2.4rem; padding: 0.8rem; } -.status { color: #64748b; font-size: 18px; margin-top: 20px; min-height: 24px; } +.status { color: #64748b; font-size: 1.8rem; margin-top: 2rem; min-height: 2.4rem; } .status.error { color: #ef4444; } /* Pairing code */ .code { - font-size: 96px; font-weight: bold; letter-spacing: 16px; - color: #22c55e; margin: 24px 0; font-family: monospace; + font-size: 9.6rem; font-weight: bold; letter-spacing: 1.6rem; + color: #22c55e; margin: 2.4rem 0; font-family: monospace; } -.hint { color: #94a3b8; font-size: 20px; line-height: 1.5; } +.hint { color: #94a3b8; font-size: 2rem; line-height: 1.5; } /* Playback stage */ .stage { background: #000; } @@ -82,8 +98,8 @@ button.ghost { background: transparent; color: #64748b; font-size: 18px; margin- /* Toast */ .toast { - position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); + position: absolute; bottom: 2.4rem; left: 50%; transform: translateX(-50%); background: rgba(17,24,39,0.92); color: #f1f5f9; - padding: 12px 24px; border-radius: 10px; font-size: 18px; - border: 1px solid #334155; + padding: 1.2rem 2.4rem; border-radius: 1rem; font-size: 1.8rem; + border: 0.1rem solid #334155; }