Merge main into thumbnail-backfill

This commit is contained in:
ScreenTinker 2026-08-07 08:38:56 -05:00
commit b00efa4f14
19 changed files with 415 additions and 53 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -366,6 +366,15 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.info.local_ip')}</div>
<div class="info-card-value small" id="telLocalIp">${device.local_ip || '--'}</div>
</div>
${device.local_ip6 ? `
<div class="info-card">
<!-- Rendered only when the panel actually has one. A v6 address is long, and showing an
empty row for the overwhelmingly v4 fleet would cost every operator screen space to
tell them nothing. A dual-stack panel shows both cards; a v6-only panel used to
show a dash here and nothing else, because the player only ever collected v4. -->
<div class="info-card-label">${t('device.info.local_ip6')}</div>
<div class="info-card-value small" id="telLocalIp6">${device.local_ip6}</div>
</div>` : ''}
${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.battery')}</div>
@ -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) }));

View file

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

View file

@ -126,6 +126,26 @@
<title>ScreenTinker Player</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
/*
* ONE KNOB for every pre-playback screen (setup, pairing, status, the audio prompt).
*
* A signage panel is read from across a room, so the thing that has to stay constant is
* ANGULAR size, not pixel size — and a CSS pixel covers a quarter of the screen area on a 4K
* panel that it does on 1080p, a sixteenth on 8K. A 72px pairing code that fills the wall on a
* 1080p screen is a smudge on an 8K one, which is exactly the complaint this fixes.
*
* So every size on those screens is a rem against this root: 1rem = 10px at 1080p, 20px at 4K,
* 40px at 8K — the same apparent size at the same viewing distance, at any panel resolution.
*
* vmin rather than vw, because portrait-mounted panels are common here and vw would render a
* 1080x1920 screen at half size. Clamped at both ends so a dashboard preview iframe or a
* laptop window stays legible instead of microscopic, and an ultrawide does not get silly.
*
* Nothing outside these screens uses rem, so this cannot reach playback content — zones,
* images and video are laid out in % and px by the layout engine and are untouched.
*/
html { font-size: clamp(7px, 0.926vmin, 56px); }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: -apple-system, sans-serif; }
/* Setup Screen */
@ -133,23 +153,25 @@
position: fixed; inset: 0; background: #111827; display: flex; flex-direction: column;
align-items: center; justify-content: center; z-index: 1000; color: #f1f5f9;
}
#setupScreen h1 { font-size: 36px; color: #3b82f6; margin-bottom: 8px; }
#setupScreen .subtitle { color: #94a3b8; font-size: 16px; margin-bottom: 48px; }
#setupScreen .form { width: 400px; max-width: 90vw; }
#setupScreen label { display: block; font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
#setupScreen input { width: 100%; padding: 12px; background: #0f172a; border: 1px solid #334155;
border-radius: 8px; color: #f1f5f9; font-size: 16px; margin-bottom: 24px; outline: none; }
#setupScreen h1 { font-size: 3.6rem; color: #3b82f6; margin-bottom: 0.8rem; }
#setupScreen .subtitle { color: #94a3b8; font-size: 1.6rem; margin-bottom: 4.8rem; }
#setupScreen .form { width: 40rem; max-width: 90vw; }
#setupScreen label { display: block; font-size: 1.4rem; color: #94a3b8; margin-bottom: 0.8rem; }
#setupScreen input { width: 100%; padding: 1.2rem; background: #0f172a; border: 0.1rem solid #334155;
border-radius: 0.8rem; color: #f1f5f9; font-size: 1.6rem; margin-bottom: 2.4rem; outline: none; }
#setupScreen input:focus { border-color: #3b82f6; }
#setupScreen button { width: 100%; padding: 12px; background: #3b82f6; color: white;
border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
#setupScreen button { width: 100%; padding: 1.2rem; background: #3b82f6; color: white;
border: none; border-radius: 0.8rem; font-size: 1.6rem; font-weight: 600; cursor: pointer; }
#setupScreen button:hover { background: #2563eb; }
#setupScreen button:disabled { opacity: 0.5; cursor: not-allowed; }
.pairing-code { font-size: 72px; font-weight: 700; color: #3b82f6; font-family: monospace;
letter-spacing: 12px; margin: 24px 0; }
.pairing-hint { color: #64748b; font-size: 14px; }
.status-msg { color: #94a3b8; font-size: 14px; margin-top: 16px; }
.spinner { width: 40px; height: 40px; border: 3px solid #334155; border-top-color: #3b82f6;
border-radius: 50%; animation: spin 1s linear infinite; margin: 24px auto; }
/* The number someone is squinting at from the far side of a shop. It gets the most room the
screen can give it, which is why it is the largest multiple here. */
.pairing-code { font-size: 7.2rem; font-weight: 700; color: #3b82f6; font-family: monospace;
letter-spacing: 1.2rem; margin: 2.4rem 0; }
.pairing-hint { color: #64748b; font-size: 1.4rem; }
.status-msg { color: #94a3b8; font-size: 1.4rem; margin-top: 1.6rem; }
.spinner { width: 4rem; height: 4rem; border: 0.3rem solid #334155; border-top-color: #3b82f6;
border-radius: 50%; animation: spin 1s linear infinite; margin: 2.4rem auto; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Player */
@ -201,8 +223,8 @@
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: #94a3b8; z-index: 500;
}
#statusOverlay h2 { color: #3b82f6; font-size: 28px; margin-bottom: 8px; }
#statusOverlay p { font-size: 16px; }
#statusOverlay h2 { color: #3b82f6; font-size: 2.8rem; margin-bottom: 0.8rem; }
#statusOverlay p { font-size: 1.6rem; }
</style>
</head>
<body>
@ -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 = '<span style="font-size:20px">&#128263;</span><span>Tap to enable audio</span>';
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 = '<span style="font-size:2rem">&#128263;</span><span>Tap to enable audio</span>';
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 = `
<h1 style="color:#3b82f6;font-size:36px;font-family:sans-serif;margin-bottom:12px">ScreenTinker</h1>
<p style="color:#94a3b8;font-size:18px;font-family:sans-serif">Tap anywhere to start</p>
<p style="color:#64748b;font-size:13px;font-family:sans-serif;margin-top:24px">Audio requires user interaction</p>
<h1 style="color:#3b82f6;font-size:3.6rem;font-family:sans-serif;margin-bottom:1.2rem">ScreenTinker</h1>
<p style="color:#94a3b8;font-size:1.8rem;font-family:sans-serif">Tap anywhere to start</p>
<p style="color:#64748b;font-size:1.3rem;font-family:sans-serif;margin-top:2.4rem">Audio requires user interaction</p>
`;
tapOverlay.onclick = () => {
unlockAudio();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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