diff --git a/frontend/js/app.js b/frontend/js/app.js index 22c5820..237ea1f 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -16,6 +16,7 @@ import * as onboarding from './views/onboarding.js'; import * as help from './views/help.js'; import * as teams from './views/teams.js'; import * as admin from './views/admin.js'; +import * as adminPlayerDebug from './views/admin-player-debug.js'; import * as designer from './views/designer.js'; import * as playlists from './views/playlists.js'; import { applyBranding } from './branding.js'; @@ -213,6 +214,10 @@ function route() { } else if (hash === '#/help' || hash.startsWith('#/help')) { currentView = help; help.render(app); + } else if (hash.startsWith('#/admin/player-debug')) { + // Match prefix so query params (?page=2&ua=Tizen) route correctly. + currentView = adminPlayerDebug; + adminPlayerDebug.render(app); } else if (hash === '#/admin') { currentView = admin; admin.render(app); diff --git a/frontend/js/views/admin-player-debug.js b/frontend/js/views/admin-player-debug.js new file mode 100644 index 0000000..202e5cc --- /dev/null +++ b/frontend/js/views/admin-player-debug.js @@ -0,0 +1,336 @@ +// Admin view for the player_debug_logs telemetry sink. Platform-admin only. +// Mounted at #/admin/player-debug. Reads from /api/player-debug/list, +// /api/player-debug/summary, /api/player-debug/older-than (DELETE). +// +// Server-side pagination - we never render all 10k rows at once. Page param +// in the URL hash so refresh preserves position. +// +// IMPORTANT: device_id is whatever the player POSTed. The submitter is +// unauthenticated by design (so unpaired players can also send), which means +// device_id is self-reported, NOT server-verified. Surfaced via column label +// "device_id (self-reported)" and the help-text caption below the filters. + +import { isPlatformAdmin } from '../utils.js'; +import { showToast } from '../components/toast.js'; + +const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' }); +const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }); + +// Parse a query string from a hash like '#/admin/player-debug?page=2&ua=Tizen'. +// Returns a plain object - no URLSearchParams since the hash format isn't +// a standard URL. +function parseHashParams() { + const h = window.location.hash || ''; + const qi = h.indexOf('?'); + if (qi < 0) return {}; + const out = {}; + const qs = h.substring(qi + 1); + for (const part of qs.split('&')) { + if (!part) continue; + const eq = part.indexOf('='); + const k = eq >= 0 ? part.substring(0, eq) : part; + const v = eq >= 0 ? part.substring(eq + 1) : ''; + try { out[decodeURIComponent(k)] = decodeURIComponent(v); } catch { out[k] = v; } + } + return out; +} + +function setHashParams(updates) { + const base = '#/admin/player-debug'; + const merged = { ...parseHashParams(), ...updates }; + // Strip empty values so the URL stays tidy + const pairs = []; + for (const [k, v] of Object.entries(merged)) { + if (v == null || v === '') continue; + pairs.push(encodeURIComponent(k) + '=' + encodeURIComponent(v)); + } + // Replace, don't push - we don't want every filter keystroke in browser history + history.replaceState(null, '', pairs.length ? base + '?' + pairs.join('&') : base); +} + +// Pretty-print JSON for the expanded-row display. Returns the original string +// if parsing fails so we don't lose data when the field isn't JSON-shaped. +function prettyJson(s) { + if (s == null || s === '') return '(empty)'; + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return String(s); + } +} + +function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function fmtTime(unixSec) { + if (!unixSec) return ''; + try { return new Date(unixSec * 1000).toLocaleString(); } catch { return String(unixSec); } +} + +function uaShort(ua) { + if (!ua) return ''; + // Keep just the part most useful for at-a-glance scanning. Full UA in the + // expanded row. + return ua.length > 60 ? ua.substring(0, 60) + '...' : ua; +} + +export async function render(container) { + const user = JSON.parse(localStorage.getItem('user') || '{}'); + if (!isPlatformAdmin(user)) { + container.innerHTML = '

Access denied

Platform-admin role required.

'; + return; + } + + const params = parseHashParams(); + const currentPage = parseInt(params.page) || 1; + const currentUa = params.ua || ''; + const currentSince = params.since || ''; + const currentUntil = params.until || ''; + const currentHasError = params.has_error === '1'; + + container.innerHTML = ` + + +
+

Summary

+
Loading...
+
+ +
+

Filters

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ Note: device_id is self-reported by the player and is not server-verified. The submission endpoint is unauthenticated by design so unpaired players can also report errors. +
+
+ +
+

Logs

+

Loading...

+
+
+ `; + + // ---- handlers ---- + document.getElementById('pdApplyFilters').onclick = () => { + const ua = document.getElementById('pdFilterUa').value.trim(); + const since = document.getElementById('pdFilterSince').value.trim(); + const until = document.getElementById('pdFilterUntil').value.trim(); + const hasError = document.getElementById('pdFilterHasError').checked ? '1' : ''; + setHashParams({ page: 1, ua, since, until, has_error: hasError }); + loadList(); + }; + document.getElementById('pdClearFilters').onclick = () => { + document.getElementById('pdFilterUa').value = ''; + document.getElementById('pdFilterSince').value = ''; + document.getElementById('pdFilterUntil').value = ''; + document.getElementById('pdFilterHasError').checked = false; + setHashParams({ page: 1, ua: '', since: '', until: '', has_error: '' }); + loadList(); + }; + document.getElementById('pdDeleteOld').onclick = async () => { + if (!confirm('Delete all logs older than 30 days? This cannot be undone.')) return; + try { + const res = await API('/player-debug/older-than?days=30', { method: 'DELETE' }); + const data = await res.json(); + showToast(`Deleted ${data.deleted} log${data.deleted === 1 ? '' : 's'} older than 30 days`, 'success'); + loadSummary(); + loadList(); + } catch (err) { + showToast('Delete failed: ' + (err.message || err), 'error'); + } + }; + + loadSummary(); + loadList(); +} + +async function loadSummary() { + const el = document.getElementById('pdSummary'); + if (!el) return; + try { + const res = await API('/player-debug/summary'); + if (!res.ok) throw new Error('HTTP ' + res.status); + const data = await res.json(); + const families = [ + ['Tizen', data.byFamily.tizen, '#3b82f6'], + ['WebOS', data.byFamily.webos, '#a3e635'], + ['Fire TV', data.byFamily.fire_tv, '#f97316'], + ['Bravia', data.byFamily.bravia, '#a855f7'], + ['Edge', data.byFamily.edge, '#06b6d4'], + ['Chrome', data.byFamily.chrome, '#fbbf24'], + ['Firefox', data.byFamily.firefox, '#ef4444'], + ['Safari', data.byFamily.safari, '#64748b'], + ['Other', data.byFamily.other, '#94a3b8'], + ]; + el.innerHTML = ` +
Total: ${data.total}
+ ${families.map(([name, count, color]) => ` +
+ + ${name}: ${count} +
+ `).join('')} + `; + } catch (err) { + el.innerHTML = 'Failed to load summary: ' + esc(err.message || err) + ''; + } +} + +function ymdToUnix(s, endOfDay) { + if (!s) return ''; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); + if (!m) return ''; + const [, y, mo, d] = m; + const dt = new Date(Date.UTC(+y, +mo - 1, +d, endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0)); + return Math.floor(dt.getTime() / 1000); +} + +async function loadList() { + const el = document.getElementById('pdList'); + const meta = document.getElementById('pdRowMeta'); + const pag = document.getElementById('pdPagination'); + if (!el) return; + el.innerHTML = '

Loading...

'; + + const params = parseHashParams(); + const page = Math.max(1, parseInt(params.page) || 1); + const limit = 50; + const qs = new URLSearchParams(); + qs.set('page', page); + qs.set('limit', limit); + if (params.ua) qs.set('ua_contains', params.ua); + const since = ymdToUnix(params.since, false); + const until = ymdToUnix(params.until, true); + if (since) qs.set('since', since); + if (until) qs.set('until', until); + if (params.has_error === '1') qs.set('has_error', '1'); + + try { + const res = await API('/player-debug/list?' + qs.toString()); + if (!res.ok) throw new Error('HTTP ' + res.status); + const data = await res.json(); + const totalPages = Math.max(1, Math.ceil(data.total / data.limit)); + meta.textContent = `(${data.total} total, page ${data.page} of ${totalPages})`; + + if (data.rows.length === 0) { + el.innerHTML = '

No logs match the current filters.

'; + } else { + el.innerHTML = ` +
+ + + + + + + + + + + + ${data.rows.map(r => ` + + + + + + + + + + + + + `).join('')} + +
IDTimedevice_id (self-reported)IPUser agentFingerprint
${r.id}${esc(fmtTime(r.created_at))}${esc(r.device_id || '(none)')}${esc(r.ip || '')}${esc(uaShort(r.user_agent))}${esc(r.error_fingerprint || '')} + +
+
+
+
URL
+
${esc(r.url || '(none)')}
+
Full User Agent
+
${esc(r.user_agent || '(none)')}
+
error_data
+
${esc(prettyJson(r.error_data))}
+
+
+
context
+
${esc(prettyJson(r.context))}
+
+
+
+
+ `; + + el.querySelectorAll('button[data-expand]').forEach(btn => { + btn.onclick = () => { + const id = btn.getAttribute('data-expand'); + const exp = el.querySelector(`tr[data-expanded-for="${id}"]`); + if (exp) { + const visible = exp.style.display !== 'none'; + exp.style.display = visible ? 'none' : ''; + btn.textContent = visible ? 'Expand' : 'Collapse'; + } + }; + }); + } + + // ---- pagination ---- + pag.innerHTML = ''; + if (totalPages > 1) { + const prev = document.createElement('button'); + prev.className = 'btn btn-secondary btn-sm'; + prev.textContent = '< Prev'; + prev.disabled = page <= 1; + prev.onclick = () => { setHashParams({ page: page - 1 }); loadList(); }; + pag.appendChild(prev); + + const indicator = document.createElement('span'); + indicator.style.cssText = 'padding:0 12px;font-size:13px;color:var(--text-muted)'; + indicator.textContent = `Page ${page} of ${totalPages}`; + pag.appendChild(indicator); + + const next = document.createElement('button'); + next.className = 'btn btn-secondary btn-sm'; + next.textContent = 'Next >'; + next.disabled = page >= totalPages; + next.onclick = () => { setHashParams({ page: page + 1 }); loadList(); }; + pag.appendChild(next); + } + } catch (err) { + el.innerHTML = '

Failed to load: ' + esc(err.message || err) + '

'; + } +} diff --git a/server/db/schema.sql b/server/db/schema.sql index 01cf146..4575128 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -436,6 +436,33 @@ CREATE TABLE IF NOT EXISTS device_status_log ( timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); +-- ===================== PLAYER DEBUG LOGS ===================== +-- Smart TVs (Tizen, WebOS, Fire TV, etc.) have no accessible devtools. The +-- player captures errors into window.__debugLog client-side and POSTs them +-- to /api/player-debug. This table stores those reports. Submitter is +-- unauthenticated by design - the player may not have paired yet when an +-- error fires. device_id is nullable for unpaired players. +-- +-- Capped at 10,000 rows with FIFO eviction on insert (route-side, no sweep). +-- error_fingerprint is a client-computed hash of (error message + first stack +-- frame) - indexed so a future "top N unique errors this week" query is fast +-- without a schema change. + +CREATE TABLE IF NOT EXISTS player_debug_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT, + ip TEXT, + user_agent TEXT, + url TEXT, + error_fingerprint TEXT, + error_data TEXT, + context TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE INDEX IF NOT EXISTS idx_player_debug_fingerprint ON player_debug_logs(error_fingerprint); +CREATE INDEX IF NOT EXISTS idx_player_debug_created_at ON player_debug_logs(created_at); + -- ===================== SCHEMA MIGRATIONS ===================== CREATE TABLE IF NOT EXISTS schema_migrations ( diff --git a/server/player/debug-overlay.js b/server/player/debug-overlay.js new file mode 100644 index 0000000..07f8c98 --- /dev/null +++ b/server/player/debug-overlay.js @@ -0,0 +1,564 @@ +// Player debug overlay. Renders a fixed top-40% overlay on the screen so a +// user on a TV (no devtools) can take a phone photo and send it to support. +// +// Activation: +// - ?debug=1 in the URL (always shows on load) +// - Keyboard sequence d-e-b-u-g typed within 5 seconds (toggle) +// - Samsung remote red button, keyCode 403 (toggle) +// - smart-TV UA (/Tizen|SMART-TV|WebOS|AFTS|AFTT|AFTM|BRAVIA/i) + +// ?autodebug=1 (auto - lets us flip on via env config later) +// +// Freeze (so the overlay stops scrolling while a phone photo is taken): +// - 'f' key (toggle) +// - Samsung remote green button, keyCode 404 (toggle) +// - Obvious "[F] FROZEN" yellow badge top-right when active +// +// ES5 syntax throughout to match section 1's compatibility floor. No +// template literals, no arrow functions, no const/let, no destructuring. +// Defensive try/catch wraps everything: this script must never be the +// reason the player won't boot. + +(function () { + try { + var SMART_TV_RE = /Tizen|SMART-TV|WebOS|AFTS|AFTT|AFTM|BRAVIA/i; + var KEY_LOG_MAX = 10; + var LOG_VISIBLE = 30; + var REFRESH_MS = 500; + var SEQ_TARGET = 'debug'; + var SEQ_TTL_MS = 5000; + + var active = false; + var frozen = false; + var overlay = null; + var refreshTimer = null; + var keydownLog = []; // newest first; [{keyCode, key, t}, ...] + var seq = ''; + var seqResetTimer = null; + var bootT = nowMs(); + + function nowMs() { + try { return Date.now(); } catch (e) { return new Date().getTime(); } + } + + // Manual URL param parse - URLSearchParams may not exist on Tizen 4 era WebKit. + function getParam(name) { + var qs = (location && location.search) || ''; + if (qs.charAt(0) === '?') qs = qs.substring(1); + var parts = qs.split('&'); + for (var i = 0; i < parts.length; i++) { + var eq = parts[i].indexOf('='); + var k = eq >= 0 ? parts[i].substring(0, eq) : parts[i]; + var v = eq >= 0 ? parts[i].substring(eq + 1) : ''; + try { k = decodeURIComponent(k); } catch (e) {} + try { v = decodeURIComponent(v); } catch (e) {} + if (k === name) return v; + } + return null; + } + + function shouldAutoActivate() { + if (getParam('debug') !== null) return true; + if (getParam('autodebug') !== null && SMART_TV_RE.test((navigator && navigator.userAgent) || '')) return true; + return false; + } + + function esc(s) { + s = String(s == null ? '' : s); + return s.replace(/&/g, '&').replace(//g, '>'); + } + + // Pad-right helper. Keeps log lines column-aligned even on monospace + // fallback fonts. Using "[type ] message" feels noisy at TV + // viewing distance but the alignment makes scanning easier in a photo. + function padRight(s, n) { + s = String(s); + while (s.length < n) s += ' '; + return s; + } + + // Read connection state. The player's main inline script declares + // `socket` with `let` at script-top-level. By spec, classic-script + // top-level let/const declarations live in the document's global + // declarative environment record - accessible by bare name from other + // classic scripts (NOT on `window`). This should work since our overlay + // script is also classic and runs after the player's inline script via + // `defer`. But: verify on a real player in browser (load with ?debug=1, + // confirm the Conn line transitions unknown -> connected after the + // socket establishes). If it stays "unknown" the fallback fix is one + // line in the player's main script: `window.__playerState = { socket: socket }` + // after `socket = io(...)`, and we read window.__playerState.socket here. + function connectionState() { + try { + if (typeof socket !== 'undefined' && socket && typeof socket.connected === 'boolean') { + return socket.connected ? 'connected' : 'disconnected'; + } + } catch (e) {} + // Belt-and-suspenders fallback if the cross-script bare reference + // doesn't resolve on a given browser. + try { + if (window.__playerState && window.__playerState.socket && typeof window.__playerState.socket.connected === 'boolean') { + return window.__playerState.socket.connected ? 'connected' : 'disconnected'; + } + } catch (e) {} + return 'unknown'; + } + + function buildHtml() { + var ua = (navigator && navigator.userAgent) || ''; + var w = (window.innerWidth || 0) + 'x' + (window.innerHeight || 0); + var sd = ((screen && screen.width) || 0) + 'x' + ((screen && screen.height) || 0); + var uptime = Math.floor((nowMs() - bootT) / 1000) + 's'; + var conn = connectionState(); + + var topHtml = '' + + '
' + + '
UA: ' + esc(ua.substring(0, 140)) + '
' + + '
Screen: ' + esc(sd) + '  Viewport: ' + esc(w) + '  Conn: ' + esc(conn) + '  Uptime: ' + esc(uptime) + '
' + + '
URL: ' + esc((location.href || '').substring(0, 220)) + '
' + + '
'; + + var log = (window.__debugLog && window.__debugLog.length) ? window.__debugLog : []; + var start = Math.max(0, log.length - LOG_VISIBLE); + var slice = log.slice(start).reverse(); // newest at top + + var middleHtml = '
'; + for (var i = 0; i < slice.length; i++) { + var e = slice[i] || {}; + var color = '#fff'; + var t = e.type || '?'; + if (t === 'error' || t === 'rejection' || t === 'console.error') color = '#f87171'; + else if (t === 'console.warn') color = '#fbbf24'; + else if (t === 'init' || t === 'timing') color = '#a3e635'; + var msg = (e.message || ''); + if (t === 'error' && e.source) msg = msg + ' @ ' + e.source + ':' + e.line; + if (t === 'timing') msg = e.event + ' +' + e.sinceInit + 'ms'; + if (t === 'init') msg = 'ua=' + (e.ua || '').substring(0, 60) + ' screen=' + (e.sw || '?') + 'x' + (e.sh || '?'); + var line = '[' + padRight(t, 14) + '] ' + esc(String(msg).substring(0, 240)); + middleHtml += '
' + line + '
'; + } + if (slice.length === 0) middleHtml += '
(no entries yet)
'; + middleHtml += '
'; + + var keysHtml = '
'; + keysHtml += '
KEYS (last ' + keydownLog.length + ', newest first):
'; + for (var j = 0; j < keydownLog.length; j++) { + var k = keydownLog[j]; + keysHtml += '
[' + esc(k.keyCode) + '] ' + esc(k.key || '(no key field)') + '
'; + } + if (keydownLog.length === 0) keysHtml += '
(press any key to start logging)
'; + keysHtml += '
'; + + var freezeBadge = frozen + ? '
[F] FROZEN
' + : '
press F to freeze
'; + + var helpLabel = '
Debug Overlay - take a photo and send to support
'; + + return topHtml + middleHtml + keysHtml + freezeBadge + helpLabel; + } + + function render() { + if (!overlay || frozen) return; + try { overlay.innerHTML = buildHtml(); } catch (e) {} + } + + // Force a render bypassing the frozen gate. Used right after freeze + // toggles so the [F] FROZEN badge actually updates on the screen. + function forceRender() { + if (!overlay) return; + try { overlay.innerHTML = buildHtml(); } catch (e) {} + } + + function activate() { + if (active) return; + active = true; + try { + overlay = document.createElement('div'); + overlay.id = '__player-debug-overlay'; + overlay.style.cssText = '' + + 'position:fixed;top:0;left:0;width:100%;height:40%;' + + 'background:rgba(0,0,0,0.85);color:#fff;' + + 'font-family:Menlo,Consolas,Courier,monospace,monospace;' + + 'font-size:18px;line-height:1.3;' + + 'padding:12px 16px;box-sizing:border-box;' + + 'z-index:2147483647;overflow:hidden;' + + 'pointer-events:none;word-break:break-all;' + + 'text-shadow:0 0 1px #000'; + document.body.appendChild(overlay); + forceRender(); + refreshTimer = setInterval(render, REFRESH_MS); + } catch (e) {} + } + + function deactivate() { + if (!active) return; + active = false; + frozen = false; + try { + if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); + } catch (e) {} + overlay = null; + if (refreshTimer) { try { clearInterval(refreshTimer); } catch (e) {} refreshTimer = null; } + } + + function toggle() { active ? deactivate() : activate(); } + + function toggleFreeze() { + if (!active) return; + frozen = !frozen; + forceRender(); // bypass frozen gate so the badge updates immediately + } + + function handleKeydown(ev) { + try { + var kc = ev.keyCode || ev.which || 0; + var key = (ev.key || ''); + + // Capture into keydown log (newest first) + keydownLog.unshift({ keyCode: kc, key: key, t: nowMs() }); + if (keydownLog.length > KEY_LOG_MAX) keydownLog.length = KEY_LOG_MAX; + + // 'debug' sequence detection. Only ASCII letters contribute to the + // sequence; arrow keys, space, etc. don't reset progress but don't + // advance it either. + var ch = (key + '').toLowerCase(); + if (ch.length === 1 && ch >= 'a' && ch <= 'z') { + seq += ch; + if (seq.length > SEQ_TARGET.length) seq = seq.substring(seq.length - SEQ_TARGET.length); + if (seq === SEQ_TARGET) { + toggle(); + seq = ''; + } + if (seqResetTimer) clearTimeout(seqResetTimer); + seqResetTimer = setTimeout(function () { seq = ''; }, SEQ_TTL_MS); + } + + // Samsung remote red button (keyCode 403) - toggle overlay + if (kc === 403) toggle(); + + // 'f' key (only when overlay active) or Samsung remote green button + // (keyCode 404) - toggle freeze + if (active && (ch === 'f' || kc === 404)) toggleFreeze(); + } catch (e) {} + } + + // Capture phase = true so we see keys even if the player adds its own + // keydown handlers that call stopPropagation. The overlay's keys are + // operator/diagnostic only - we don't want them blocked. + try { document.addEventListener('keydown', handleKeydown, true); } catch (e) {} + + // Boot + if (shouldAutoActivate()) { + if (document.body) { + activate(); + } else { + try { document.addEventListener('DOMContentLoaded', activate); } catch (e) {} + } + } + + // ==================================================================== + // Section 4: error reporter + // ==================================================================== + // Auto-posts captured errors to /api/player-debug. Runs independent of + // the visible overlay (target population is smart TVs that may never + // open the overlay). Triggered by: + // - A new error-ish entry pushed to __debugLog (debounced 5s, batched) + // - Page unload (navigator.sendBeacon with size cap) + // + // Gated by: + // - window.__playerConfig.debugReporting (server-injected from + // PLAYER_DEBUG_REPORTING env var; default true; kill switch for + // self-hosters) + // - UA allow-list (smart TV markers) and deny-list (modern desktop + // browsers - they have devtools, we don't need their telemetry) + // - 5-minute backoff after a 429 from the server + + var REPORT_ENDPOINT = '/api/player-debug'; + var DEBOUNCE_MS = 5000; + var RATE_LIMIT_BACKOFF_MS = 5 * 60 * 1000; + var BEACON_SIZE_CAP = 50000; // leave headroom under the ~64KB beacon limit + var BEACON_FALLBACK_ENTRIES = 30; + var LOG_TAIL_FOR_REPORT = 50; + + var reportingEnabled = false; + var pendingQueue = []; + var debounceTimer = null; + var nextRetryAt = 0; + + // ---- UA gating ---- + // Order: allow-list smart-TV markers first (always report - this is the + // population we built for), then deny-list modern desktop browsers (they + // have devtools, no telemetry needed), then default to report for + // everything else (unknown UAs are the long tail of weird embedded + // browsers we want to catch). + function uaShouldReport(ua) { + if (!ua) return true; // empty UA is suspicious enough to report + if (SMART_TV_RE.test(ua)) return true; + // Modern desktop deny-list. Note: SMART_TV_RE already matched and + // returned above, so even if a Tizen UA contains "Chrome/108" (Tizen 7 + // is Chromium 108) it cannot be deny-listed here. + var DESKTOP_RE = /Chrome\/1[0-9]{2}\.|Firefox\/1[0-9]{2}\.|Version\/(?:1[5-9]|2[0-9])\..*\sSafari\//; + if (DESKTOP_RE.test(ua)) return false; + return true; + } + + // ---- Fingerprint ---- + // Two-pass djb2 (forward + reverse, different seeds) producing 16 hex + // chars. Pure ES5, deterministic, collision-resistant enough for + // grouping ("top N unique errors this week"). Not crypto. + function hash16(s) { + var pad = '00000000'; + var a = 5381; + var b = 0x9e3779b1 | 0; // unrelated seed + for (var i = 0; i < s.length; i++) a = ((a << 5) + a + s.charCodeAt(i)) | 0; + for (var j = s.length - 1; j >= 0; j--) b = ((b << 5) + b + s.charCodeAt(j)) | 0; + return (pad + (a >>> 0).toString(16)).slice(-8) + (pad + (b >>> 0).toString(16)).slice(-8); + } + + // Hash inputs per spec: + // message (first 200 chars) + first stack frame's function name + + // filename (line/col stripped for line-drift stability). + // For console-only entries or resource-load failures with no stack, fall + // back to type + message. + function fingerprintOf(entry) { + if (!entry) return ''; + var msg = String(entry.message || '').substring(0, 200); + var stackKey = ''; + var stack = entry.stack || ''; + if (stack) { + var lines = String(stack).split('\n'); + for (var i = 0; i < lines.length; i++) { + var trimmed = String(lines[i]).replace(/^\s+/, ''); + if (trimmed.indexOf('at ') === 0) { + // Strip ":line:col" or ":line" suffix so the fingerprint is + // stable across small refactors that move code by lines. + stackKey = trimmed.substring(3).replace(/:\d+(?::\d+)?\)?\s*$/, ''); + break; + } + } + } + if (!stackKey) stackKey = String(entry.type || ''); + return hash16(msg + '|' + stackKey); + } + + // ---- Device id / player state (best-effort, no PII) ---- + // Privacy rules from spec: do NOT capture input field values, pairing + // codes (after pairing completes), or content URLs. We capture only + // deviceId (a server-assigned UUID, harmless) and a coarse player state. + function getDeviceId() { + try { + if (typeof config !== 'undefined' && config && config.deviceId) return config.deviceId; + } catch (e) {} + try { + if (window.__playerState && window.__playerState.config && window.__playerState.config.deviceId) { + return window.__playerState.config.deviceId; + } + } catch (e) {} + return null; + } + + function getPlayerState() { + try { + if (typeof isPlaying !== 'undefined') { + if (isPlaying) return 'playing'; + if (typeof playlist !== 'undefined' && playlist && playlist.length === 0) return 'waiting'; + return 'idle'; + } + } catch (e) {} + return 'unknown'; + } + + // ---- Build report payload ---- + function buildPayload(triggerErrors) { + var log = (window.__debugLog && window.__debugLog.length) ? window.__debugLog : []; + var tailStart = Math.max(0, log.length - LOG_TAIL_FOR_REPORT); + var fp = ''; + if (triggerErrors && triggerErrors.length) { + try { fp = fingerprintOf(triggerErrors[0]); } catch (e) {} + } + return { + deviceId: getDeviceId(), + userAgent: (navigator && navigator.userAgent) || '', + url: (location && location.href) || '', + error_fingerprint: fp, + errors: triggerErrors || [], + context: { + screenW: (screen && screen.width) || 0, + screenH: (screen && screen.height) || 0, + viewportW: window.innerWidth || 0, + viewportH: window.innerHeight || 0, + deviceId: getDeviceId(), + playerState: getPlayerState(), + logTail: log.slice(tailStart) + } + }; + } + + // ---- Send (regular fetch path) ---- + function sendReport(triggerErrors) { + if (Date.now() < nextRetryAt) return; // in 429 backoff + var payload; + var body; + try { + payload = buildPayload(triggerErrors); + body = JSON.stringify(payload); + } catch (e) { return; } + try { + // keepalive lets the request survive page hide/navigation when the + // browser supports it (modern). Doesn't replace sendBeacon for unload + // but reduces the loss surface for in-flight POSTs. + fetch(REPORT_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body, + keepalive: true + }).then(function (resp) { + if (resp && resp.status === 429) { + nextRetryAt = Date.now() + RATE_LIMIT_BACKOFF_MS; + } + }).catch(function () { /* silent - we are telemetry, not a feature */ }); + } catch (e) {} + } + + // ---- Send (unload via beacon path) ---- + function sendBeaconReport(triggerErrors) { + if (Date.now() < nextRetryAt) return; + if (typeof navigator.sendBeacon !== 'function') { + // Old browser without Beacon API. Best-effort: try keepalive fetch. + sendReport(triggerErrors); + return; + } + var payload, body, blob; + try { + payload = buildPayload(triggerErrors); + body = JSON.stringify(payload); + } catch (e) { return; } + // Beacon caps around 64KB depending on browser. If we're over the + // safe cap, trim the heaviest field (errors[] usually, then context.logTail). + if (body.length > BEACON_SIZE_CAP) { + try { + if (payload.errors && payload.errors.length > BEACON_FALLBACK_ENTRIES) { + payload.errors = payload.errors.slice(-BEACON_FALLBACK_ENTRIES); + } + if (payload.context && payload.context.logTail && payload.context.logTail.length > BEACON_FALLBACK_ENTRIES) { + payload.context.logTail = payload.context.logTail.slice(-BEACON_FALLBACK_ENTRIES); + } + body = JSON.stringify(payload); + } catch (e) { return; } + } + try { + blob = new Blob([body], { type: 'application/json' }); + var ok = navigator.sendBeacon(REPORT_ENDPOINT, blob); + if (!ok) { + // Beacon refused (typically too-large payload). Retry once with + // an aggressively-trimmed payload. + try { + payload.errors = (payload.errors || []).slice(-BEACON_FALLBACK_ENTRIES); + payload.context = payload.context || {}; + payload.context.logTail = (payload.context.logTail || []).slice(-BEACON_FALLBACK_ENTRIES); + navigator.sendBeacon(REPORT_ENDPOINT, new Blob([JSON.stringify(payload)], { type: 'application/json' })); + } catch (e) {} + } + } catch (e) {} + } + + // ---- Queue + debounce ---- + function queueReport(entry) { + if (!reportingEnabled) return; + if (Date.now() < nextRetryAt) return; + pendingQueue.push(entry); + if (debounceTimer) return; // already armed + debounceTimer = setTimeout(function () { + debounceTimer = null; + if (pendingQueue.length === 0) return; + var batch = pendingQueue; + pendingQueue = []; + sendReport(batch); + }, DEBOUNCE_MS); + } + + function flushOnUnload() { + if (!reportingEnabled) return; + // Always send on unload, even with empty queue - the logTail context + // is useful for understanding what happened before the page went away. + var batch = pendingQueue.slice(); + pendingQueue = []; + if (debounceTimer) { try { clearTimeout(debounceTimer); } catch (e) {} debounceTimer = null; } + sendBeaconReport(batch); + } + + // ---- Wire it up ---- + // Reporter activates if (a) PLAYER_DEBUG_REPORTING isn't off AND (b) the + // UA passes the gating. Both checks happen here once at module init - + // we re-evaluate nothing at runtime since UA and config are stable. + try { + var cfg = (window.__playerConfig && typeof window.__playerConfig.debugReporting === 'boolean') + ? window.__playerConfig.debugReporting + : true; // default on when no config injected (e.g. dev without the env var) + var ua = (navigator && navigator.userAgent) || ''; + reportingEnabled = cfg && uaShouldReport(ua); + + if (reportingEnabled) { + // Wrap __debugLog_push so we trigger the debounce on error-ish + // entries pushed by section 1's inline trap. Non-error pushes + // (init, timing, console.log) accumulate in __debugLog as context + // but don't trigger a send. + try { + var origPush = window.__debugLog_push; + if (typeof origPush === 'function') { + window.__debugLog_push = function (entry) { + origPush(entry); + try { + if (entry && (entry.type === 'error' || entry.type === 'rejection' || entry.type === 'console.error')) { + queueReport(entry); + } + } catch (e) {} + }; + } + } catch (e) {} + + // Catch errors that landed BEFORE this script wrapped __debugLog_push. + // The inline trap captured them into __debugLog; if any are error-ish, + // trigger an initial debounced send so they don't sit silently until + // the next error or page unload. + try { + var log = window.__debugLog || []; + for (var i = 0; i < log.length; i++) { + var e = log[i]; + if (e && (e.type === 'error' || e.type === 'rejection' || e.type === 'console.error')) { + queueReport(e); + break; // one trigger is enough; debounce will batch + } + } + } catch (e) {} + + // Unload handlers. pagehide is preferred on iOS Safari and + // bfcache-aware browsers; beforeunload covers everything else. + try { window.addEventListener('pagehide', flushOnUnload); } catch (e) {} + try { window.addEventListener('beforeunload', flushOnUnload); } catch (e) {} + } + } catch (e) {} + + // Expose a manual control surface for support sessions ("paste this in the + // address bar"). Browsers and TV WebViews vary on whether javascript: + // URLs work, but where they do, support can ask the user to run + // javascript:__playerDebug.activate() to open the overlay without the + // user understanding query params. + window.__playerDebug = { + activate: activate, + deactivate: deactivate, + toggle: toggle, + toggleFreeze: toggleFreeze, + isActive: function () { return active; }, + isFrozen: function () { return frozen; }, + // Force a report now (manual control). Useful in support sessions: + // "open the player, run __playerDebug.report(), I'll see it in the + // admin view". + report: function () { sendReport([]); }, + reportingEnabled: function () { return reportingEnabled; }, + _internal: { fingerprintOf: fingerprintOf, uaShouldReport: uaShouldReport, hash16: hash16 } + }; + } catch (outerErr) { + // If this whole script fails to parse or init, the player must still boot. + } +})(); diff --git a/server/player/index.html b/server/player/index.html index 9749ba3..6c9fd8e 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -3,6 +3,119 @@ + + + + ScreenTinker Player