mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
Show a panel's IPv6, and size the pairing code to the screen it is on
Two field-reported gaps, unrelated except that both are about being able to read something off a screen. A PANEL'S IPv6 WAS NEVER COLLECTED, LET ALONE SHOWN. DeviceInfo.getLocalIp() filters to Inet4Address, so a v6-only panel reported no address at all and the dashboard rendered a dash for a screen that was perfectly reachable. It now reports both stacks in their own fields: a dual-stack panel genuinely has two addresses and either may be the one you need, so collapsing them into one column would make it mean "whichever interface enumerated first". Link-local (fe80::/10) is deliberately excluded. Every interface has one, they tend to enumerate first, and none can be dialled without also knowing the zone index — so admitting them would fill the field with a string nobody can paste anywhere and hide the address that works. Any %iface suffix is trimmed for the same reason. The 45-char cap the writer already applied is exactly the longest legitimate IPv6 text form, so it needed no change. The dashboard card renders only when a panel actually has a v6 address, rather than showing an empty row to the overwhelmingly v4 fleet. THE PAIRING CODE DID NOT SCALE, WHICH IS WORST WHERE IT MATTERS MOST. Every size on the pre-playback screens was a hard-coded pixel value. A CSS pixel covers a quarter of the screen area on a 4K panel that it does on 1080p, and a sixteenth on 8K — so the 72px code that fills a 1080p screen is a smudge on the 4K wall it was installed on, which is where signage actually goes. What has to stay constant is ANGULAR size, so the root font size is now viewport-proportional and everything on those screens is a rem against it. The code holds 6.67% of screen height at every resolution: 72px at 1080p — bit for bit what it renders today, so nothing changes for the existing fleet — 144px at 4K, 288px at 8K. Verified in a browser rather than by arithmetic: at a 1409px viewport the root computes to 13.0473px, which is 0.926vmin to four decimals. vmin, not vw, because portrait-mounted panels are common here and vw would render a 1080x1920 screen at half size. Clamped at both ends so the dashboard's preview iframe stays legible instead of microscopic and an ultrawide does not get silly. Applied to the web player (which BrightSign also runs) and to Tizen, where a 1920x1080 logical viewport makes it arithmetically identical to the values it replaces — the point being the panels where it is not. A test asserts the scaling cannot reach playback content: the whole safety argument is that only the chrome uses rem, and a stage or zone rule adopting it would start resizing CONTENT, which is a worse bug than the one being fixed. Android is untouched — its pairing code already autosizes within a dp-scaled layout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
This commit is contained in:
parent
16d8295373
commit
9face2fdd4
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -2070,6 +2079,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) }));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">🔇</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">🔇</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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
server/test/player-screen-scaling.test.js
Normal file
92
server/test/player-screen-scaling.test.js
Normal 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`);
|
||||
}
|
||||
});
|
||||
92
server/test/telemetry-ipv6.test.js
Normal file
92
server/test/telemetry-ipv6.test.js
Normal 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`);
|
||||
}
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue