harden: relay timeouts, DNS resolver reuse, cache hygiene + frontend polish
Server (relay/SSRF): - Bound connect+TLS+request under CONNECT_TIMEOUT and body download under BODY_TIMEOUT so a stalling upstream can't pin a relay permit indefinitely - Add inbound header_read_timeout to defeat slow-loris connections - Allowlist relayed Content-Type (fall back to octet-stream) to block content-confusion from attacker-controlled stream origins - Reuse one DNS resolver process-wide instead of rebuilding it per request (restores hickory's DNS cache; drops per-segment resolver setup) - Probe cache: random per-process hash seed (DefaultHasher was deterministic) + bounded growth with expired-entry eviction - Real Ctrl-C handler for a clean shutdown (honours the banner) - Collapse the wrap_raw no-op alias into wrap Frontend: - Guide: visible focus ring tracking arrow-key navigation - Player: buffering/tuning indicator while <video> is stalled - prefers-contrast: more pass (lifts --ink-dim to meet WCAG AA) - Service worker: date-stamped cache names so shell updates propagate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+9
-3
@@ -112,7 +112,7 @@ export class Guide {
|
||||
|
||||
_renderRow(ch, idx) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'guide-row';
|
||||
row.className = 'guide-row' + (idx === this.focusRow ? ' focused' : '');
|
||||
row.style.position = 'absolute';
|
||||
row.style.top = (idx * ROW_H) + 'px';
|
||||
row.style.left = '0'; row.style.right = '0';
|
||||
@@ -189,12 +189,18 @@ export class Guide {
|
||||
_onKey(e) {
|
||||
const list = this.agg.channels;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': e.preventDefault(); this.focusRow = Math.min(list.length-1, this.focusRow+1); this._scrollToFocus(); break;
|
||||
case 'ArrowUp': e.preventDefault(); this.focusRow = Math.max(0, this.focusRow-1); this._scrollToFocus(); break;
|
||||
case 'ArrowDown': e.preventDefault(); this.focusRow = Math.min(list.length-1, this.focusRow+1); this._moveFocus(); break;
|
||||
case 'ArrowUp': e.preventDefault(); this.focusRow = Math.max(0, this.focusRow-1); this._moveFocus(); break;
|
||||
case 'Enter': e.preventDefault(); { const ch = list[this.focusRow]; if (ch){ this.onTune(ch); this.hide(); } } break;
|
||||
case 'Escape': e.preventDefault(); this.hide(); break;
|
||||
}
|
||||
}
|
||||
// Scroll the focused row into view, then repaint so the .focused highlight
|
||||
// tracks the cursor even when no scroll was needed (scroll event wouldn't fire).
|
||||
_moveFocus() {
|
||||
this._scrollToFocus();
|
||||
this._renderViewport();
|
||||
}
|
||||
_scrollToFocus() {
|
||||
const y = this.focusRow * ROW_H;
|
||||
if (y < this.scroll.scrollTop) this.scroll.scrollTop = y;
|
||||
|
||||
+21
-1
@@ -27,6 +27,7 @@ export class Player {
|
||||
this._fallbackIdx = 0;
|
||||
this._wireControls();
|
||||
this._wireActivity();
|
||||
this._wireBuffering();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +94,31 @@ export class Player {
|
||||
if (this.hls) { try { this.hls.destroy(); } catch {} this.hls = null; }
|
||||
this.video.removeAttribute('src');
|
||||
try { this.video.load(); } catch {}
|
||||
this._setBuffering(false); // never leave the indicator stuck across a zap
|
||||
}
|
||||
|
||||
destroy() { this._teardown(); }
|
||||
|
||||
// ---------- buffering / stall indicator ----------
|
||||
// Bridge the gap between the scrying static clearing and the first frame:
|
||||
// without a hint, a slow-loading stream is indistinguishable from a dead one.
|
||||
_wireBuffering() {
|
||||
const v = this.video;
|
||||
const on = () => this._setBuffering(true);
|
||||
const off = () => this._setBuffering(false);
|
||||
v.addEventListener('waiting', on);
|
||||
v.addEventListener('stalled', on);
|
||||
v.addEventListener('playing', off);
|
||||
v.addEventListener('canplay', off);
|
||||
v.addEventListener('pause', off);
|
||||
v.addEventListener('emptied', off);
|
||||
}
|
||||
_setBuffering(on) {
|
||||
if (this.els.screen) this.els.screen.classList.toggle('buffering', on);
|
||||
}
|
||||
|
||||
// ---------- dead-stream "the orb clouds over" ----------
|
||||
_showNoSignal() { this.els.nosignal.hidden = false; }
|
||||
_showNoSignal() { this.els.nosignal.hidden = false; this._setBuffering(false); }
|
||||
_hideNoSignal() { this.els.nosignal.hidden = true; }
|
||||
|
||||
// ---------- live-aware chrome ----------
|
||||
|
||||
@@ -91,6 +91,19 @@ img{display:block}
|
||||
transition:opacity .12s linear}
|
||||
.static.show{opacity:.9}
|
||||
|
||||
/* ===================== buffering / tuning ===================== */
|
||||
/* Corner hint shown while <video> is stalled — top-left keeps clear of the
|
||||
brand bug (top-right) and the channel banner (bottom-left). */
|
||||
.screen.buffering .bezel::after{
|
||||
content:"\21BB tuning\2026";
|
||||
position:absolute;left:18px;top:14px;z-index:7;
|
||||
font-size:12px;letter-spacing:2px;color:var(--red);
|
||||
text-shadow:0 0 10px var(--red-glow);
|
||||
background:var(--osd-bg);border:1px solid var(--line);
|
||||
padding:4px 10px;border-radius:6px;
|
||||
animation:orb-buf 1.1s ease-in-out infinite}
|
||||
@keyframes orb-buf{0%,100%{opacity:.45}50%{opacity:1}}
|
||||
|
||||
/* ===================== the orb clouds over ===================== */
|
||||
.nosignal{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
gap:10px;background:#050102;text-align:center;padding:20px}
|
||||
@@ -155,6 +168,10 @@ img{display:block}
|
||||
.guide-chcell{position:sticky;left:0;z-index:2;flex:0 0 var(--ch-col,150px);display:flex;align-items:center;gap:8px;
|
||||
padding:0 8px;background:var(--panel-2);border-right:1px solid var(--line);cursor:pointer}
|
||||
.guide-chcell:hover{background:var(--red-deep)}
|
||||
/* keyboard cursor: arrow-key navigation marks the focused row so it isn't blind */
|
||||
.guide-row.focused{background:rgba(255,43,43,.07)}
|
||||
.guide-row.focused .guide-chcell{background:var(--red-deep);
|
||||
box-shadow:inset 3px 0 0 var(--red),0 0 12px rgba(255,43,43,.25)}
|
||||
.guide-chcell .num{color:var(--red);font-weight:bold;font-variant-numeric:tabular-nums;min-width:24px}
|
||||
.guide-chcell .nm{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.guide-chcell .skull{margin-left:auto;opacity:.55;font-size:12px}
|
||||
@@ -202,6 +219,19 @@ kbd{background:#000;border:1px solid var(--line);border-bottom-width:2px;border-
|
||||
.brand-bug{display:none}
|
||||
}
|
||||
|
||||
/* ===================== high-contrast pass (a11y) ===================== */
|
||||
/* Default --ink-dim (#c98b86 on #050102 ≈ 3.5:1) misses WCAG AA for the small
|
||||
text it's used on everywhere (OSD sub, guide names, time labels, help dd).
|
||||
Lift it and strengthen panels/borders only when the user asks for it. */
|
||||
@media (prefers-contrast: more){
|
||||
:root{
|
||||
--ink-dim:#ffb8b0;
|
||||
--line:#5a1416;
|
||||
--panel:#1e0608;
|
||||
--panel-2:#260a0c;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== reduced motion kill-switch ===================== */
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.crt-vignette{animation:none}
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
// NEVER cached — the relay marks them no-store and we bypass them here, so the SW
|
||||
// is the sole freshness authority for metadata only.
|
||||
|
||||
const SHELL = 'orb-shell-v1';
|
||||
const DATA = 'orb-data-v1';
|
||||
// Bump these on every shell change so returning visitors actually receive the
|
||||
// new assets — a static 'v1' name would serve stale JS/CSS indefinitely. The
|
||||
// activate handler deletes any cache whose name isn't in this current pair.
|
||||
const SHELL = 'orb-shell-20260615';
|
||||
const DATA = 'orb-data-20260615';
|
||||
|
||||
const SHELL_ASSETS = [
|
||||
'/', '/index.html', '/styles.css',
|
||||
|
||||
Reference in New Issue
Block a user