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:
+40
-4
@@ -7,9 +7,10 @@
|
|||||||
mod relay;
|
mod relay;
|
||||||
mod ssrf;
|
mod ssrf;
|
||||||
|
|
||||||
|
use std::collections::hash_map::RandomState;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{BuildHasher, Hash, Hasher};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@@ -36,6 +37,12 @@ use relay::{
|
|||||||
const MAX_INFLIGHT_RELAY: usize = 32;
|
const MAX_INFLIGHT_RELAY: usize = 32;
|
||||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||||
const PROBE_TTL: Duration = Duration::from_secs(300);
|
const PROBE_TTL: Duration = Duration::from_secs(300);
|
||||||
|
/// Bound how long a client may take to send its request headers — defeats
|
||||||
|
/// slow-loris connections that would otherwise hold a Tokio task open forever.
|
||||||
|
const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
/// Hard ceiling on cached probe entries; past this we evict expired ones (and,
|
||||||
|
/// if still full, clear) so the cache can't grow without bound over a long run.
|
||||||
|
const PROBE_CACHE_MAX: usize = 4096;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Metrics {
|
struct Metrics {
|
||||||
@@ -57,6 +64,10 @@ struct AppState {
|
|||||||
metrics: Metrics,
|
metrics: Metrics,
|
||||||
relay_gate: Semaphore,
|
relay_gate: Semaphore,
|
||||||
probe_cache: Mutex<HashMap<u64, ProbeCacheEntry>>,
|
probe_cache: Mutex<HashMap<u64, ProbeCacheEntry>>,
|
||||||
|
// Random per-process seed for the probe-cache key. DefaultHasher is
|
||||||
|
// deterministic across runs, so an attacker could engineer URL collisions;
|
||||||
|
// RandomState reseeds every boot, making the key unpredictable.
|
||||||
|
cache_hasher: RandomState,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -75,11 +86,13 @@ async fn main() {
|
|||||||
metrics: Metrics::default(),
|
metrics: Metrics::default(),
|
||||||
relay_gate: Semaphore::new(MAX_INFLIGHT_RELAY),
|
relay_gate: Semaphore::new(MAX_INFLIGHT_RELAY),
|
||||||
probe_cache: Mutex::new(HashMap::new()),
|
probe_cache: Mutex::new(HashMap::new()),
|
||||||
|
cache_hasher: RandomState::new(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let listener = TcpListener::bind(bind).await.expect("bind");
|
let listener = TcpListener::bind(bind).await.expect("bind");
|
||||||
eprintln!("The Pondering Orb is gazing on http://{bind} (Ctrl-C to close the eye)");
|
eprintln!("The Pondering Orb is gazing on http://{bind} (Ctrl-C to close the eye)");
|
||||||
|
|
||||||
|
let serve = async {
|
||||||
loop {
|
loop {
|
||||||
let (stream, _peer) = match listener.accept().await {
|
let (stream, _peer) = match listener.accept().await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -92,12 +105,26 @@ async fn main() {
|
|||||||
let st = state.clone();
|
let st = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let svc = service_fn(move |req| handle(st.clone(), req));
|
let svc = service_fn(move |req| handle(st.clone(), req));
|
||||||
if let Err(e) = http1::Builder::new().serve_connection(io, svc).await {
|
let conn = http1::Builder::new()
|
||||||
|
.header_read_timeout(HEADER_READ_TIMEOUT)
|
||||||
|
.serve_connection(io, svc);
|
||||||
|
if let Err(e) = conn.await {
|
||||||
// client disconnects are noise; log at debug granularity only
|
// client disconnects are noise; log at debug granularity only
|
||||||
let _ = e;
|
let _ = e;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Honour the banner: a real Ctrl-C handler exits cleanly instead of relying
|
||||||
|
// on the default SIGINT kill (and keeps tokio's `signal` feature in use).
|
||||||
|
tokio::select! {
|
||||||
|
_ = serve => {}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
eprintln!("\nThe eye closes.");
|
||||||
|
log_line("shutdown", "ctrl-c", None, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn locate_web_root() -> PathBuf {
|
fn locate_web_root() -> PathBuf {
|
||||||
@@ -217,8 +244,9 @@ async fn serve_probe(state: &AppState, req: &Request<Incoming>) -> Response<Full
|
|||||||
_ => return json(StatusCode::BAD_REQUEST, "{\"ok\":false,\"status\":0,\"latency_ms\":0}"),
|
_ => return json(StatusCode::BAD_REQUEST, "{\"ok\":false,\"status\":0,\"latency_ms\":0}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// hash the URL for the cache key (never store the raw URL — token hygiene)
|
// hash the URL for the cache key (never store the raw URL — token hygiene).
|
||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
// Random per-process seed → keys aren't attacker-predictable across runs.
|
||||||
|
let mut hasher = state.cache_hasher.build_hasher();
|
||||||
raw.hash(&mut hasher);
|
raw.hash(&mut hasher);
|
||||||
let key = hasher.finish();
|
let key = hasher.finish();
|
||||||
|
|
||||||
@@ -244,6 +272,14 @@ async fn serve_probe(state: &AppState, req: &Request<Incoming>) -> Response<Full
|
|||||||
state.metrics.probe_dead.fetch_add(1, Ordering::Relaxed);
|
state.metrics.probe_dead.fetch_add(1, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
if let Ok(mut cache) = state.probe_cache.lock() {
|
if let Ok(mut cache) = state.probe_cache.lock() {
|
||||||
|
// Bound growth: at the ceiling, drop expired entries first; if that
|
||||||
|
// doesn't free space (all entries still fresh), clear and start over.
|
||||||
|
if cache.len() >= PROBE_CACHE_MAX {
|
||||||
|
cache.retain(|_, (ts, ..)| ts.elapsed() < PROBE_TTL);
|
||||||
|
if cache.len() >= PROBE_CACHE_MAX {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
cache.insert(key, (Instant::now(), outcome.ok, outcome.status, outcome.latency_ms));
|
cache.insert(key, (Instant::now(), outcome.ok, outcome.status, outcome.latency_ms));
|
||||||
}
|
}
|
||||||
log_line("probe", if outcome.ok { "live" } else { "dead" }, parsed.host_str(), Some(outcome.latency_ms));
|
log_line("probe", if outcome.ok { "live" } else { "dead" }, parsed.host_str(), Some(outcome.latency_ms));
|
||||||
|
|||||||
+52
-10
@@ -25,6 +25,12 @@ const MAX_MANIFEST: usize = 10 * 1024 * 1024; // 10 MB
|
|||||||
const MAX_SEGMENT: usize = 64 * 1024 * 1024; // 64 MB
|
const MAX_SEGMENT: usize = 64 * 1024 * 1024; // 64 MB
|
||||||
const MAX_REDIRECTS: u32 = 4;
|
const MAX_REDIRECTS: u32 = 4;
|
||||||
const UA: &str = "PonderingOrb/0.1 (+hardened-relay)";
|
const UA: &str = "PonderingOrb/0.1 (+hardened-relay)";
|
||||||
|
/// Bound connect + TLS handshake + request → response-headers. A stalling
|
||||||
|
/// upstream must never pin one of the relay's permits open indefinitely.
|
||||||
|
const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||||
|
/// Bound the body download too — the size cap stops a *large* body, but a
|
||||||
|
/// slow-drip body would otherwise hold the permit far longer than that implies.
|
||||||
|
const BODY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum RelayError {
|
pub enum RelayError {
|
||||||
@@ -36,6 +42,7 @@ pub enum RelayError {
|
|||||||
Http(String),
|
Http(String),
|
||||||
TooLarge,
|
TooLarge,
|
||||||
TooManyRedirects,
|
TooManyRedirects,
|
||||||
|
Timeout,
|
||||||
Upstream(StatusCode),
|
Upstream(StatusCode),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +57,7 @@ impl std::fmt::Display for RelayError {
|
|||||||
RelayError::Http(e) => write!(f, "http: {e}"),
|
RelayError::Http(e) => write!(f, "http: {e}"),
|
||||||
RelayError::TooLarge => write!(f, "response exceeds size cap"),
|
RelayError::TooLarge => write!(f, "response exceeds size cap"),
|
||||||
RelayError::TooManyRedirects => write!(f, "too many redirects"),
|
RelayError::TooManyRedirects => write!(f, "too many redirects"),
|
||||||
|
RelayError::Timeout => write!(f, "upstream timed out"),
|
||||||
RelayError::Upstream(s) => write!(f, "upstream status {s}"),
|
RelayError::Upstream(s) => write!(f, "upstream status {s}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,6 +113,9 @@ async fn fetch_once(
|
|||||||
.await
|
.await
|
||||||
.map_err(RelayError::Ssrf)?;
|
.map_err(RelayError::Ssrf)?;
|
||||||
|
|
||||||
|
// Bound connect → TLS → request → response-headers as one unit. A stalling
|
||||||
|
// upstream cannot hold one of the 32 relay permits open past CONNECT_TIMEOUT.
|
||||||
|
let resp = tokio::time::timeout(CONNECT_TIMEOUT, async {
|
||||||
let tcp = TcpStream::connect(addr)
|
let tcp = TcpStream::connect(addr)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RelayError::Connect(e.to_string()))?;
|
.map_err(|e| RelayError::Connect(e.to_string()))?;
|
||||||
@@ -138,10 +149,13 @@ async fn fetch_once(
|
|||||||
.body(Empty::<Bytes>::new())
|
.body(Empty::<Bytes>::new())
|
||||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
.map_err(|e| RelayError::Http(e.to_string()))?;
|
||||||
|
|
||||||
let resp = sender
|
sender
|
||||||
.send_request(req)
|
.send_request(req)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RelayError::Http(e.to_string()))?;
|
.map_err(|e| RelayError::Http(e.to_string()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| RelayError::Timeout)??;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let content_type = resp
|
let content_type = resp
|
||||||
@@ -171,18 +185,48 @@ async fn fetch_once(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let limited = Limited::new(resp.into_body(), max_bytes);
|
let limited = Limited::new(resp.into_body(), max_bytes);
|
||||||
let collected = limited.collect().await.map_err(|_| RelayError::TooLarge)?;
|
let collected = tokio::time::timeout(BODY_TIMEOUT, limited.collect())
|
||||||
|
.await
|
||||||
|
.map_err(|_| RelayError::Timeout)?
|
||||||
|
.map_err(|_| RelayError::TooLarge)?;
|
||||||
let body = collected.to_bytes();
|
let body = collected.to_bytes();
|
||||||
|
|
||||||
Ok(Upstream {
|
Ok(Upstream {
|
||||||
status,
|
status,
|
||||||
content_type,
|
content_type: sanitize_content_type(&content_type),
|
||||||
body,
|
body,
|
||||||
final_url: url.clone(),
|
final_url: url.clone(),
|
||||||
redirects: 0,
|
redirects: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clamp the upstream-declared Content-Type to a known-safe media allowlist.
|
||||||
|
/// An attacker-controlled stream origin could otherwise label segment bytes as
|
||||||
|
/// `text/html` or `application/javascript`; if a tab ever navigates straight to
|
||||||
|
/// a /relay URL, that declared type (not just sniffing, which nosniff blocks)
|
||||||
|
/// would govern interpretation. Anything off-list collapses to octet-stream.
|
||||||
|
fn sanitize_content_type(ct: &str) -> String {
|
||||||
|
// Compare on the bare type, ignoring any `; charset=`/`; codecs=` parameters.
|
||||||
|
let base = ct.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
|
||||||
|
const ALLOWED: &[&str] = &[
|
||||||
|
"application/vnd.apple.mpegurl",
|
||||||
|
"application/x-mpegurl",
|
||||||
|
"audio/mpegurl",
|
||||||
|
"audio/x-mpegurl",
|
||||||
|
"video/mp2t",
|
||||||
|
"video/mp4",
|
||||||
|
"video/iso.segment",
|
||||||
|
"audio/aac",
|
||||||
|
"audio/mp4",
|
||||||
|
"application/octet-stream",
|
||||||
|
];
|
||||||
|
if ALLOWED.contains(&base.as_str()) {
|
||||||
|
ct.to_string()
|
||||||
|
} else {
|
||||||
|
"application/octet-stream".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Public entry: validated GET with bounded, re-validated redirect following.
|
/// Public entry: validated GET with bounded, re-validated redirect following.
|
||||||
pub async fn fetch_validated(
|
pub async fn fetch_validated(
|
||||||
connector: &TlsConnector,
|
connector: &TlsConnector,
|
||||||
@@ -350,7 +394,7 @@ fn rewrite_tag_uris(line: &str, base: &Url) -> String {
|
|||||||
Ok(abs) => {
|
Ok(abs) => {
|
||||||
let mut s = String::with_capacity(line.len() + 40);
|
let mut s = String::with_capacity(line.len() + 40);
|
||||||
s.push_str(&line[..uri_start]);
|
s.push_str(&line[..uri_start]);
|
||||||
s.push_str(&wrap_raw(&abs));
|
s.push_str(&wrap(&abs));
|
||||||
s.push_str(&line[end..]);
|
s.push_str(&line[end..]);
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
@@ -358,14 +402,12 @@ fn rewrite_tag_uris(line: &str, base: &Url) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "/relay?url=<pct-encoded absolute>"
|
/// "/relay?url=<pct-encoded absolute>". The percent-encoding leaves only
|
||||||
|
/// RFC3986-unreserved bytes, so the result is safe both on a bare URI line and
|
||||||
|
/// inside an already-quoted `URI="..."` tag attribute (no `"` can appear).
|
||||||
fn wrap(abs: &Url) -> String {
|
fn wrap(abs: &Url) -> String {
|
||||||
format!("/relay?url={}", pct(abs.as_str()))
|
format!("/relay?url={}", pct(abs.as_str()))
|
||||||
}
|
}
|
||||||
fn wrap_raw(abs: &Url) -> String {
|
|
||||||
// for inside an already-quoted attribute
|
|
||||||
wrap(abs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal RFC3986 component percent-encoding (encode everything non-unreserved).
|
/// Minimal RFC3986 component percent-encoding (encode everything non-unreserved).
|
||||||
fn pct(s: &str) -> String {
|
fn pct(s: &str) -> String {
|
||||||
|
|||||||
+14
-5
@@ -7,10 +7,23 @@
|
|||||||
//! between check and connect cannot smuggle us onto an internal address.
|
//! between check and connect cannot smuggle us onto an internal address.
|
||||||
|
|
||||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
|
||||||
use hickory_resolver::TokioAsyncResolver;
|
use hickory_resolver::TokioAsyncResolver;
|
||||||
|
|
||||||
|
/// Built once on first use and reused for the process lifetime. Rebuilding the
|
||||||
|
/// resolver per request re-read /etc/resolv.conf and — worse — discarded
|
||||||
|
/// hickory's in-memory DNS cache, forcing a fresh lookup for every HLS segment.
|
||||||
|
static RESOLVER: OnceLock<TokioAsyncResolver> = OnceLock::new();
|
||||||
|
|
||||||
|
fn resolver() -> &'static TokioAsyncResolver {
|
||||||
|
RESOLVER.get_or_init(|| {
|
||||||
|
TokioAsyncResolver::tokio_from_system_conf()
|
||||||
|
.unwrap_or_else(|_| TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum SsrfError {
|
pub enum SsrfError {
|
||||||
Resolve(String),
|
Resolve(String),
|
||||||
@@ -78,11 +91,7 @@ pub async fn resolve_and_validate(host: &str, port: u16) -> Result<SocketAddr, S
|
|||||||
return Ok(SocketAddr::new(ip, port));
|
return Ok(SocketAddr::new(ip, port));
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolver = match TokioAsyncResolver::tokio_from_system_conf() {
|
let lookup = resolver()
|
||||||
Ok(r) => r,
|
|
||||||
Err(_) => TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()),
|
|
||||||
};
|
|
||||||
let lookup = resolver
|
|
||||||
.lookup_ip(host)
|
.lookup_ip(host)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SsrfError::Resolve(e.to_string()))?;
|
.map_err(|e| SsrfError::Resolve(e.to_string()))?;
|
||||||
|
|||||||
+9
-3
@@ -112,7 +112,7 @@ export class Guide {
|
|||||||
|
|
||||||
_renderRow(ch, idx) {
|
_renderRow(ch, idx) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'guide-row';
|
row.className = 'guide-row' + (idx === this.focusRow ? ' focused' : '');
|
||||||
row.style.position = 'absolute';
|
row.style.position = 'absolute';
|
||||||
row.style.top = (idx * ROW_H) + 'px';
|
row.style.top = (idx * ROW_H) + 'px';
|
||||||
row.style.left = '0'; row.style.right = '0';
|
row.style.left = '0'; row.style.right = '0';
|
||||||
@@ -189,12 +189,18 @@ export class Guide {
|
|||||||
_onKey(e) {
|
_onKey(e) {
|
||||||
const list = this.agg.channels;
|
const list = this.agg.channels;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowDown': e.preventDefault(); this.focusRow = Math.min(list.length-1, 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._scrollToFocus(); 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 'Enter': e.preventDefault(); { const ch = list[this.focusRow]; if (ch){ this.onTune(ch); this.hide(); } } break;
|
||||||
case 'Escape': e.preventDefault(); 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() {
|
_scrollToFocus() {
|
||||||
const y = this.focusRow * ROW_H;
|
const y = this.focusRow * ROW_H;
|
||||||
if (y < this.scroll.scrollTop) this.scroll.scrollTop = y;
|
if (y < this.scroll.scrollTop) this.scroll.scrollTop = y;
|
||||||
|
|||||||
+21
-1
@@ -27,6 +27,7 @@ export class Player {
|
|||||||
this._fallbackIdx = 0;
|
this._fallbackIdx = 0;
|
||||||
this._wireControls();
|
this._wireControls();
|
||||||
this._wireActivity();
|
this._wireActivity();
|
||||||
|
this._wireBuffering();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,12 +94,31 @@ export class Player {
|
|||||||
if (this.hls) { try { this.hls.destroy(); } catch {} this.hls = null; }
|
if (this.hls) { try { this.hls.destroy(); } catch {} this.hls = null; }
|
||||||
this.video.removeAttribute('src');
|
this.video.removeAttribute('src');
|
||||||
try { this.video.load(); } catch {}
|
try { this.video.load(); } catch {}
|
||||||
|
this._setBuffering(false); // never leave the indicator stuck across a zap
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() { this._teardown(); }
|
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" ----------
|
// ---------- 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; }
|
_hideNoSignal() { this.els.nosignal.hidden = true; }
|
||||||
|
|
||||||
// ---------- live-aware chrome ----------
|
// ---------- live-aware chrome ----------
|
||||||
|
|||||||
@@ -91,6 +91,19 @@ img{display:block}
|
|||||||
transition:opacity .12s linear}
|
transition:opacity .12s linear}
|
||||||
.static.show{opacity:.9}
|
.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 ===================== */
|
/* ===================== the orb clouds over ===================== */
|
||||||
.nosignal{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
.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}
|
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;
|
.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}
|
padding:0 8px;background:var(--panel-2);border-right:1px solid var(--line);cursor:pointer}
|
||||||
.guide-chcell:hover{background:var(--red-deep)}
|
.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 .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 .nm{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.guide-chcell .skull{margin-left:auto;opacity:.55;font-size:12px}
|
.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}
|
.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 ===================== */
|
/* ===================== reduced motion kill-switch ===================== */
|
||||||
@media (prefers-reduced-motion: reduce){
|
@media (prefers-reduced-motion: reduce){
|
||||||
.crt-vignette{animation:none}
|
.crt-vignette{animation:none}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
// NEVER cached — the relay marks them no-store and we bypass them here, so the SW
|
// NEVER cached — the relay marks them no-store and we bypass them here, so the SW
|
||||||
// is the sole freshness authority for metadata only.
|
// is the sole freshness authority for metadata only.
|
||||||
|
|
||||||
const SHELL = 'orb-shell-v1';
|
// Bump these on every shell change so returning visitors actually receive the
|
||||||
const DATA = 'orb-data-v1';
|
// 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 = [
|
const SHELL_ASSETS = [
|
||||||
'/', '/index.html', '/styles.css',
|
'/', '/index.html', '/styles.css',
|
||||||
|
|||||||
Reference in New Issue
Block a user