mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
Add player debug overlay and server-side error telemetry sink
Smart TVs (Tizen, WebOS, Fire TV, Bravia) have no accessible browser devtools, so when the player misbehaves on those platforms we previously had zero visibility. This adds two paths to fix that: - Visible debug overlay rendered on the TV screen for phone-photo capture - Automatic server-side telemetry sink for hands-off error reporting Client side (server/player/): - Inline ES5 error trap as first script in index.html captures errors even from parse-time failures in later scripts. Captures into window.__debugLog with 200-entry cap. - debug-overlay.js renders a fixed-position overlay covering the top 40% of the screen. Activates via ?debug=1, d-e-b-u-g key sequence, Samsung red button (keyCode 403), or smart-TV UA + ?autodebug=1. Freeze toggle (F key or Samsung green) with visible FROZEN badge for phone capture. pointer-events: none so touches pass through to the player underneath. - Reporter machinery posts captured errors to /api/player-debug with 5-second debounce batching, sendBeacon on unload (with payload size capping to stay under 64KB), 5-minute backoff after 429 responses. UA-gated: smart-TV allow-list first (handles Tizen-with-Chrome/108), modern-desktop deny-list second, default-report for unknown UAs. - Two-pass djb2 fingerprint (16 hex chars) per error for future grouping. - Absolute script src (/player/debug-overlay.js) so the script loads regardless of trailing-slash on the player URL. Server side: - New player_debug_logs table (10000-row FIFO cap, indexed on fingerprint + created_at). Schema in schema.sql, idempotent via CREATE TABLE IF NOT EXISTS. - POST /api/player-debug unauthenticated (so unpaired players can also report), rate-limited 10/min/IP, per-field length caps to prevent abuse. - Dynamic /player HTML route injects window.__playerConfig.debugReporting based on PLAYER_DEBUG_REPORTING env var (defaults on; =off suppresses all client telemetry traffic). Other player assets still served static. - Admin routes (requireAuth + requireSuperAdmin): GET /api/player-debug/list with pagination and filters GET /api/player-debug/summary for UA family counts DELETE /api/player-debug/older-than for manual purge Admin view (#/admin/player-debug): - UA family summary at top (Tizen/WebOS/Fire TV/Bravia/Edge/Chrome/etc) - Filter row: UA contains, date range, has-error checkbox - Paginated table with expand-row JSON viewer for error_data and context - device_id labeled (self-reported) since field is unauthenticated input - Manual delete-older-than button with confirmation dialog Verified end-to-end with Playwright + Chromium (17/17 checks pass) plus manual real-browser verification including UA-spoofed Tizen flow landing rows in the admin view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
12fe0e43eb
commit
19f434d05a
|
|
@ -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);
|
||||
|
|
|
|||
336
frontend/js/views/admin-player-debug.js
Normal file
336
frontend/js/views/admin-player-debug.js
Normal file
|
|
@ -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, '"').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 = '<div class="empty-state"><h3>Access denied</h3><p>Platform-admin role required.</p></div>';
|
||||
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 = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Player Debug Logs</h1>
|
||||
<div class="subtitle">Captured errors and state from player clients. Mostly smart TVs we can't reach with devtools.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Summary</h3>
|
||||
<div id="pdSummary" style="display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--text-secondary)">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Filters</h3>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">User agent contains</label>
|
||||
<input class="input" id="pdFilterUa" value="${esc(currentUa)}" placeholder="Tizen, WebOS, AFTS..." style="width:220px">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">Since (YYYY-MM-DD)</label>
|
||||
<input class="input" id="pdFilterSince" value="${esc(currentSince)}" placeholder="2026-05-01" style="width:140px">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">Until (YYYY-MM-DD)</label>
|
||||
<input class="input" id="pdFilterUntil" value="${esc(currentUntil)}" placeholder="2026-05-31" style="width:140px">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer">
|
||||
<input type="checkbox" id="pdFilterHasError" ${currentHasError ? 'checked' : ''}> Has error data
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="pdApplyFilters">Apply</button>
|
||||
<button class="btn btn-secondary btn-sm" id="pdClearFilters">Clear</button>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn btn-danger btn-sm" id="pdDeleteOld">Delete older than 30 days</button>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-top:10px">
|
||||
Note: <code>device_id</code> 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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Logs <span id="pdRowMeta" style="font-size:13px;color:var(--text-muted);font-weight:400"></span></h3>
|
||||
<div id="pdList"><p style="color:var(--text-muted)">Loading...</p></div>
|
||||
<div id="pdPagination" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:14px"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ---- 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 = `
|
||||
<div style="font-weight:600;color:var(--text-primary)">Total: ${data.total}</div>
|
||||
${families.map(([name, count, color]) => `
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${color}"></span>
|
||||
<span>${name}: <strong style="color:var(--text-primary)">${count}</strong></span>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
} catch (err) {
|
||||
el.innerHTML = '<span style="color:var(--danger)">Failed to load summary: ' + esc(err.message || err) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<p style="color:var(--text-muted)">Loading...</p>';
|
||||
|
||||
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 = '<p style="color:var(--text-muted);padding:14px 0">No logs match the current filters.</p>';
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div class="table-wrap">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:960px">
|
||||
<thead><tr style="border-bottom:1px solid var(--border);text-align:left">
|
||||
<th style="padding:8px;width:50px">ID</th>
|
||||
<th style="padding:8px;width:140px">Time</th>
|
||||
<th style="padding:8px;width:180px" title="Self-reported by the player; not server-verified.">device_id (self-reported)</th>
|
||||
<th style="padding:8px;width:130px">IP</th>
|
||||
<th style="padding:8px">User agent</th>
|
||||
<th style="padding:8px;width:130px">Fingerprint</th>
|
||||
<th style="padding:8px;width:80px"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${data.rows.map(r => `
|
||||
<tr style="border-bottom:1px solid var(--border-light)" data-row-id="${r.id}">
|
||||
<td style="padding:8px;font-family:monospace;color:var(--text-muted)">${r.id}</td>
|
||||
<td style="padding:8px;font-size:12px">${esc(fmtTime(r.created_at))}</td>
|
||||
<td style="padding:8px;font-family:monospace;font-size:11px;color:var(--text-secondary)">${esc(r.device_id || '(none)')}</td>
|
||||
<td style="padding:8px;font-family:monospace;font-size:12px;color:var(--text-secondary)">${esc(r.ip || '')}</td>
|
||||
<td style="padding:8px;font-size:12px;color:var(--text-secondary)">${esc(uaShort(r.user_agent))}</td>
|
||||
<td style="padding:8px;font-family:monospace;font-size:11px;color:var(--text-muted)">${esc(r.error_fingerprint || '')}</td>
|
||||
<td style="padding:8px;text-align:right">
|
||||
<button class="btn btn-secondary btn-sm" data-expand="${r.id}" style="font-size:11px;padding:2px 8px">Expand</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="display:none" data-expanded-for="${r.id}">
|
||||
<td colspan="7" style="padding:12px 16px;background:var(--bg-input)">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">URL</div>
|
||||
<div style="font-family:monospace;font-size:11px;color:var(--text-secondary);word-break:break-all;margin-bottom:10px">${esc(r.url || '(none)')}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">Full User Agent</div>
|
||||
<div style="font-family:monospace;font-size:11px;color:var(--text-secondary);word-break:break-all;margin-bottom:10px">${esc(r.user_agent || '(none)')}</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">error_data</div>
|
||||
<pre style="margin:0;padding:8px;background:var(--bg-primary);border-radius:4px;font-family:monospace;font-size:11px;color:var(--text-secondary);overflow:auto;max-height:300px;white-space:pre-wrap;word-break:break-word">${esc(prettyJson(r.error_data))}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">context</div>
|
||||
<pre style="margin:0;padding:8px;background:var(--bg-primary);border-radius:4px;font-family:monospace;font-size:11px;color:var(--text-secondary);overflow:auto;max-height:420px;white-space:pre-wrap;word-break:break-word">${esc(prettyJson(r.context))}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<p style="color:var(--danger)">Failed to load: ' + esc(err.message || err) + '</p>';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
564
server/player/debug-overlay.js
Normal file
564
server/player/debug-overlay.js
Normal file
|
|
@ -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, '<').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 = ''
|
||||
+ '<div style="font-size:22px;line-height:1.3;color:#fff;border-bottom:1px solid #444;padding-bottom:6px;margin-bottom:6px">'
|
||||
+ '<div>UA: ' + esc(ua.substring(0, 140)) + '</div>'
|
||||
+ '<div>Screen: ' + esc(sd) + ' Viewport: ' + esc(w) + ' Conn: ' + esc(conn) + ' Uptime: ' + esc(uptime) + '</div>'
|
||||
+ '<div>URL: ' + esc((location.href || '').substring(0, 220)) + '</div>'
|
||||
+ '</div>';
|
||||
|
||||
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 = '<div style="font-size:18px;line-height:1.25;color:#fff;margin-bottom:6px">';
|
||||
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 += '<div style="color:' + color + '">' + line + '</div>';
|
||||
}
|
||||
if (slice.length === 0) middleHtml += '<div style="color:#94a3b8">(no entries yet)</div>';
|
||||
middleHtml += '</div>';
|
||||
|
||||
var keysHtml = '<div style="font-size:18px;line-height:1.25;color:#cbd5e1;border-top:1px solid #444;padding-top:6px">';
|
||||
keysHtml += '<div style="color:#94a3b8;margin-bottom:2px">KEYS (last ' + keydownLog.length + ', newest first):</div>';
|
||||
for (var j = 0; j < keydownLog.length; j++) {
|
||||
var k = keydownLog[j];
|
||||
keysHtml += '<div>[' + esc(k.keyCode) + '] ' + esc(k.key || '(no key field)') + '</div>';
|
||||
}
|
||||
if (keydownLog.length === 0) keysHtml += '<div style="color:#64748b">(press any key to start logging)</div>';
|
||||
keysHtml += '</div>';
|
||||
|
||||
var freezeBadge = frozen
|
||||
? '<div style="position:absolute;top:8px;right:12px;background:#fbbf24;color:#000;font-weight:bold;font-size:24px;padding:6px 12px;border-radius:4px;font-family:monospace,monospace;letter-spacing:1px">[F] FROZEN</div>'
|
||||
: '<div style="position:absolute;top:8px;right:12px;background:#1f2937;color:#94a3b8;font-size:18px;padding:4px 10px;border-radius:4px;font-family:monospace,monospace;border:1px solid #374151">press F to freeze</div>';
|
||||
|
||||
var helpLabel = '<div style="position:absolute;bottom:8px;right:12px;font-size:14px;color:#94a3b8;background:#0f172a;padding:4px 8px;border-radius:3px">Debug Overlay - take a photo and send to support</div>';
|
||||
|
||||
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.
|
||||
}
|
||||
})();
|
||||
|
|
@ -3,6 +3,119 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<!--
|
||||
Player debug error trap. MUST stay first inside <head> after the two
|
||||
mandatory <meta> tags (charset must come first per HTML spec). Runs
|
||||
before any other script so we capture errors even from parse-time
|
||||
failures in scripts that come after. Vanilla ES5 syntax (var, function,
|
||||
no arrow/const/template) so it loads on ancient WebKit forks (Tizen 4,
|
||||
older WebOS, Fire TV stick Gen 1, embedded signage browsers). Wrapped
|
||||
in defensive try/catch so this script can never be the reason the
|
||||
player won't boot.
|
||||
-->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
if (!window.__debugLog) window.__debugLog = [];
|
||||
var MAX_LOG = 200;
|
||||
var INIT_T = (function () { try { return Date.now(); } catch (e) { return new Date().getTime(); } })();
|
||||
|
||||
function nowMs() {
|
||||
try { return Date.now(); } catch (e) { return new Date().getTime(); }
|
||||
}
|
||||
|
||||
function pushLog(entry) {
|
||||
try {
|
||||
entry.t = nowMs();
|
||||
window.__debugLog.push(entry);
|
||||
if (window.__debugLog.length > MAX_LOG) {
|
||||
window.__debugLog.splice(0, window.__debugLog.length - MAX_LOG);
|
||||
}
|
||||
} catch (e) { /* we are the safety net; do not crash */ }
|
||||
}
|
||||
window.__debugLog_push = pushLog; // shared pusher for debug-overlay.js
|
||||
|
||||
pushLog({
|
||||
type: 'init',
|
||||
ua: (navigator && navigator.userAgent) || '',
|
||||
url: location.href,
|
||||
sw: typeof screen !== 'undefined' ? screen.width : null,
|
||||
sh: typeof screen !== 'undefined' ? screen.height : null
|
||||
});
|
||||
|
||||
// Uncaught script errors. Capture phase so we also see errors from
|
||||
// <img>/<script>/<link> resource failures (ev.target.src on those).
|
||||
window.addEventListener('error', function (ev) {
|
||||
try {
|
||||
var src = ev.filename || (ev.target && ev.target.src) || '';
|
||||
pushLog({
|
||||
type: 'error',
|
||||
message: ev.message || (ev.error && String(ev.error)) || 'error',
|
||||
source: src,
|
||||
line: ev.lineno || 0,
|
||||
col: ev.colno || 0,
|
||||
stack: (ev.error && ev.error.stack) || ''
|
||||
});
|
||||
} catch (e) {}
|
||||
}, true);
|
||||
|
||||
window.addEventListener('unhandledrejection', function (ev) {
|
||||
try {
|
||||
var reason = ev.reason;
|
||||
pushLog({
|
||||
type: 'rejection',
|
||||
message: (reason && reason.message) || String(reason || 'rejection'),
|
||||
stack: (reason && reason.stack) || ''
|
||||
});
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
// Wrap console methods. Original behavior preserved so devtools still
|
||||
// sees the real call; we additionally append a sanitized text record.
|
||||
var methods = ['log', 'warn', 'error'];
|
||||
for (var i = 0; i < methods.length; i++) {
|
||||
(function (m) {
|
||||
var orig = console[m];
|
||||
console[m] = function () {
|
||||
try {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
var msg = '';
|
||||
for (var j = 0; j < args.length; j++) {
|
||||
var part;
|
||||
try {
|
||||
part = typeof args[j] === 'string' ? args[j] : JSON.stringify(args[j]);
|
||||
} catch (e) {
|
||||
part = String(args[j]);
|
||||
}
|
||||
msg += (j > 0 ? ' ' : '') + part;
|
||||
}
|
||||
if (msg.length > 1000) msg = msg.slice(0, 1000) + '...[trunc]';
|
||||
pushLog({ type: 'console.' + m, message: msg });
|
||||
} catch (e) {}
|
||||
try { return orig.apply(console, arguments); } catch (e) {}
|
||||
};
|
||||
})(methods[i]);
|
||||
}
|
||||
|
||||
// Page-load timing relative to this script's execution.
|
||||
try {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
pushLog({ type: 'timing', event: 'DOMContentLoaded', sinceInit: nowMs() - INIT_T });
|
||||
});
|
||||
window.addEventListener('load', function () {
|
||||
pushLog({ type: 'timing', event: 'load', sinceInit: nowMs() - INIT_T });
|
||||
});
|
||||
} catch (e) {}
|
||||
} catch (outerErr) {
|
||||
// If even init failed, the player must still boot. Do nothing.
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- Debug overlay module (section 2). Defers so HTML parse doesn't block
|
||||
on the fetch. Auto-activation check fires after DOMContentLoaded;
|
||||
few-ms delay vs the inline trap is fine since errors before this
|
||||
loads are already captured in __debugLog by the inline trap above. -->
|
||||
<script src="/player/debug-overlay.js" defer></script>
|
||||
<title>ScreenTinker Player</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
|
|
|||
162
server/routes/player-debug.js
Normal file
162
server/routes/player-debug.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// Public (unauthenticated) error sink for the player. Smart TVs and other
|
||||
// embedded browsers without devtools POST their captured error logs here so
|
||||
// we have visibility into client-side problems we'd otherwise never see.
|
||||
//
|
||||
// Submitter is unauthenticated by design - the player may not have paired
|
||||
// yet when an error fires. Rate-limited 10 req/min per IP+path via
|
||||
// app.use('/api/player-debug', rateLimit(60000, 10)) in server.js.
|
||||
//
|
||||
// IP is captured from req.ip, which respects X-Forwarded-For thanks to
|
||||
// app.set('trust proxy', trustedProxies) in server.js - so on prod we
|
||||
// get the real client IP, not the Cloudflare edge IP.
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { db } = require('../db/database');
|
||||
const { requireAuth, requireSuperAdmin } = require('../middleware/auth');
|
||||
|
||||
// Hard caps on string lengths so an unauth caller can't fill the DB with a
|
||||
// single 10MB request. The client itself only sends bounded data, but we
|
||||
// don't trust that on a public endpoint.
|
||||
const MAX_DEVICE_ID = 64;
|
||||
const MAX_UA = 500;
|
||||
const MAX_URL = 2000;
|
||||
const MAX_FP = 64;
|
||||
const MAX_ERROR_DATA = 50000; // ~50KB of JSON. Generous but bounded.
|
||||
const MAX_CONTEXT = 20000; // ~20KB.
|
||||
const ROW_CAP = 10000;
|
||||
const PRUNE_BATCH = 100;
|
||||
|
||||
function clamp(s, max) {
|
||||
if (s == null) return null;
|
||||
return String(s).slice(0, max);
|
||||
}
|
||||
|
||||
function clampJson(obj, max) {
|
||||
if (obj == null) return null;
|
||||
try {
|
||||
let s = JSON.stringify(obj);
|
||||
if (s.length > max) s = s.slice(0, max);
|
||||
return s;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO player_debug_logs
|
||||
(device_id, ip, user_agent, url, error_fingerprint, error_data, context)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const countStmt = db.prepare('SELECT COUNT(*) AS n FROM player_debug_logs');
|
||||
const pruneStmt = db.prepare(`
|
||||
DELETE FROM player_debug_logs
|
||||
WHERE id IN (SELECT id FROM player_debug_logs ORDER BY id ASC LIMIT ?)
|
||||
`);
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const deviceId = clamp(body.deviceId, MAX_DEVICE_ID);
|
||||
const userAgent = clamp(body.userAgent, MAX_UA);
|
||||
const url = clamp(body.url, MAX_URL);
|
||||
const fingerprint = clamp(body.error_fingerprint, MAX_FP);
|
||||
const errors = clampJson(body.errors, MAX_ERROR_DATA);
|
||||
const context = clampJson(body.context, MAX_CONTEXT);
|
||||
|
||||
insertStmt.run(deviceId, req.ip, userAgent, url, fingerprint, errors, context);
|
||||
|
||||
// FIFO cap. Prune the oldest PRUNE_BATCH rows when we cross ROW_CAP.
|
||||
// Done synchronously on insert so the cap is never far exceeded; cost is
|
||||
// bounded (the DELETE is indexed via the autoinc id) and fires only
|
||||
// every PRUNE_BATCH inserts past the cap.
|
||||
const { n } = countStmt.get();
|
||||
if (n > ROW_CAP) {
|
||||
pruneStmt.run(PRUNE_BATCH);
|
||||
}
|
||||
|
||||
res.status(204).end();
|
||||
} catch (e) {
|
||||
console.error('[player-debug] insert failed:', e.message);
|
||||
res.status(500).json({ error: 'insert failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Admin routes (platform-admin only). Live under the same path prefix as the
|
||||
// public POST above (/api/player-debug) but on different verb+path. The
|
||||
// rate-limit middleware applied at mount-time uses req.path as part of its
|
||||
// bucket key, so admin GETs don't share a quota with the public POST.
|
||||
// ============================================================================
|
||||
|
||||
// GET /list - paginated listing, newest first, with filters.
|
||||
// query: page (1-indexed), limit (default 50, max 200),
|
||||
// ua_contains, since (unix-sec), until (unix-sec), has_error (1/0)
|
||||
router.get('/list', requireAuth, requireSuperAdmin, (req, res) => {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 50));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const uaFilter = String(req.query.ua_contains || '').slice(0, 100);
|
||||
const since = parseInt(req.query.since) || 0;
|
||||
const until = parseInt(req.query.until) || 0;
|
||||
const hasError = req.query.has_error === '1';
|
||||
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (uaFilter) { where.push('user_agent LIKE ?'); params.push('%' + uaFilter + '%'); }
|
||||
if (since) { where.push('created_at >= ?'); params.push(since); }
|
||||
if (until) { where.push('created_at <= ?'); params.push(until); }
|
||||
if (hasError) {
|
||||
where.push("error_data IS NOT NULL AND error_data != '' AND error_data != '[]'");
|
||||
}
|
||||
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
|
||||
const total = db.prepare(`SELECT COUNT(*) AS n FROM player_debug_logs ${whereSql}`).get(...params).n;
|
||||
const rows = db.prepare(`
|
||||
SELECT id, device_id, ip, user_agent, url, error_fingerprint, error_data, context, created_at
|
||||
FROM player_debug_logs ${whereSql}
|
||||
ORDER BY id DESC LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset);
|
||||
|
||||
res.json({ total, page, limit, rows });
|
||||
});
|
||||
|
||||
// GET /summary - UA family counts for the top-of-page header summary.
|
||||
// Classification order matters: smart-TV markers checked before Chrome
|
||||
// (Tizen 5+ / WebOS / etc. contain Chrome/N in their UA), Edge before
|
||||
// Chrome (Edg/N appears alongside Chrome/N in Chromium-Edge).
|
||||
router.get('/summary', requireAuth, requireSuperAdmin, (req, res) => {
|
||||
const rows = db.prepare('SELECT user_agent FROM player_debug_logs').all();
|
||||
const counts = {
|
||||
tizen: 0, webos: 0, fire_tv: 0, bravia: 0,
|
||||
edge: 0, chrome: 0, firefox: 0, safari: 0,
|
||||
other: 0
|
||||
};
|
||||
for (const r of rows) {
|
||||
const ua = r.user_agent || '';
|
||||
if (/Tizen/i.test(ua)) counts.tizen++;
|
||||
else if (/WebOS/i.test(ua)) counts.webos++;
|
||||
else if (/AFTS|AFTT|AFTM/i.test(ua)) counts.fire_tv++;
|
||||
else if (/BRAVIA/i.test(ua)) counts.bravia++;
|
||||
else if (/Edg\/|Edge\//.test(ua)) counts.edge++;
|
||||
else if (/Chrome\//.test(ua)) counts.chrome++;
|
||||
else if (/Firefox\//.test(ua)) counts.firefox++;
|
||||
else if (/Safari\//.test(ua)) counts.safari++;
|
||||
else counts.other++;
|
||||
}
|
||||
res.json({ total: rows.length, byFamily: counts });
|
||||
});
|
||||
|
||||
// DELETE /older-than?days=30 - manual purge. Confirmation happens client-side;
|
||||
// this is a single-shot DELETE that returns the row count actually deleted.
|
||||
// Bounded at 1..3650 days so a typo can't no-op or run forever.
|
||||
router.delete('/older-than', requireAuth, requireSuperAdmin, (req, res) => {
|
||||
const days = Math.max(1, Math.min(3650, parseInt(req.query.days) || 30));
|
||||
const cutoff = Math.floor(Date.now() / 1000) - days * 86400;
|
||||
const result = db.prepare('DELETE FROM player_debug_logs WHERE created_at < ?').run(cutoff);
|
||||
res.json({ deleted: result.changes, days, cutoff });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -179,7 +179,38 @@ app.use(express.static(config.frontendDir, { index: false, etag: true, lastModif
|
|||
}
|
||||
}}));
|
||||
|
||||
// Serve web player at /player (same no-cache for JS/HTML)
|
||||
// Player HTML: dynamic route. Injects a small inline window.__playerConfig
|
||||
// script before the debug-overlay.js tag so the client knows whether to send
|
||||
// telemetry to /api/player-debug. The PLAYER_DEBUG_REPORTING env var defaults
|
||||
// to on - set to "off" to suppress all player-side telemetry POSTs (the
|
||||
// server-side endpoint defends in depth, but the kill switch saves network
|
||||
// traffic on the device too). Other player assets (JS, sw.js, etc) are still
|
||||
// served by the static middleware below; only index.html is dynamic.
|
||||
app.get(['/player', '/player/', '/player/index.html'], (req, res) => {
|
||||
const playerHtmlPath = path.join(__dirname, 'player', 'index.html');
|
||||
fs.readFile(playerHtmlPath, 'utf8', (err, html) => {
|
||||
if (err) return res.status(500).type('text/plain').send('player HTML unavailable');
|
||||
const reportingEnabled = String(process.env.PLAYER_DEBUG_REPORTING || 'on').toLowerCase() !== 'off';
|
||||
const inject =
|
||||
' <script>window.__playerConfig = window.__playerConfig || {}; ' +
|
||||
'window.__playerConfig.debugReporting = ' + JSON.stringify(reportingEnabled) + ';</script>\n';
|
||||
// Inject right before the debug-overlay.js script tag. If for any reason
|
||||
// the tag isn't present (e.g. file edited out), fall back to injecting
|
||||
// before </head> so the flag still lands.
|
||||
let modified;
|
||||
if (html.indexOf('<script src="/player/debug-overlay.js"') >= 0) {
|
||||
modified = html.replace('<script src="/player/debug-overlay.js"', inject + ' <script src="/player/debug-overlay.js"');
|
||||
} else {
|
||||
modified = html.replace('</head>', inject + '</head>');
|
||||
}
|
||||
res.type('html').setHeader('Cache-Control', 'no-cache');
|
||||
res.send(modified);
|
||||
});
|
||||
});
|
||||
|
||||
// Serve web player at /player (same no-cache for JS/HTML). The index.html
|
||||
// route above intercepts the HTML requests; everything else still falls
|
||||
// through to this static handler (debug-overlay.js, sw.js, manifest, etc).
|
||||
app.use('/player', express.static(path.join(__dirname, 'player'), { etag: true, lastModified: true, setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith('.js') || filePath.endsWith('.css') || filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
|
|
@ -239,6 +270,12 @@ app.use('/api/subscription', require('./routes/subscription'));
|
|||
app.use('/api/contact', rateLimit(60000, 5));
|
||||
app.use('/api/contact', require('./routes/contact'));
|
||||
|
||||
// Public player debug-log sink. Smart TVs and other embedded browsers
|
||||
// without devtools POST captured errors here. Rate limited to 10 req/min
|
||||
// per IP+path. Body is JSON (express.json() is global at line 140).
|
||||
app.use('/api/player-debug', rateLimit(60000, 10));
|
||||
app.use('/api/player-debug', require('./routes/player-debug'));
|
||||
|
||||
// Stripe billing routes (checkout, portal)
|
||||
app.use('/api/stripe', stripeRouter);
|
||||
|
||||
|
|
@ -374,6 +411,7 @@ function updateFrontendHash() {
|
|||
// Include player files in hash so web players detect code updates
|
||||
try { files.push(fs.readFileSync(path.join(__dirname, 'player', 'index.html'))); } catch {}
|
||||
try { files.push(fs.readFileSync(path.join(__dirname, 'player', 'sw.js'))); } catch {}
|
||||
try { files.push(fs.readFileSync(path.join(__dirname, 'player', 'debug-overlay.js'))); } catch {}
|
||||
frontendHash = crypto.createHash('md5').update(Buffer.concat(files.map(f => Buffer.from(f)))).digest('hex').slice(0, 8);
|
||||
} catch { frontendHash = Date.now().toString(36); }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue