use std::cell::RefCell;
use maud::html;
const CAP: usize = 30;
thread_local! {
static CRUMBS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
static PANIC_TOKEN: RefCell<Option<String>> = const { RefCell::new(None) };
}
pub(crate) fn set_panic_token(token: String) {
PANIC_TOKEN.with(|t| *t.borrow_mut() = Some(token));
}
fn send_panic_beacon(msg: &str, crumb_tail: &[String]) {
if !crate::app::telemetry::enabled() {
return;
}
let Some(token) = PANIC_TOKEN.with(|t| t.try_borrow().ok().and_then(|v| v.clone())) else {
return;
};
let title: String = msg.lines().next().unwrap_or("panic").chars().take(160).collect();
let fp: String = msg.chars().filter(|c| c.is_ascii_alphanumeric()).take(40).collect();
let body = crate::app::telemetry::clamp(format!(
"{}\n\nlast steps:\n· {}\n\napp: v{}",
crate::app::telemetry::redact(msg),
crumb_tail.join("\n· "),
env!("CARGO_PKG_VERSION"),
));
let payload = serde_json::json!({
"kind": "panic",
"title": crate::app::telemetry::redact(&title),
"signature": format!("panic-{fp}"),
"body": body,
"auth": token,
})
.to_string();
let url = format!(
"{}/api/telemetry",
crate::registry::CREDIT_PROXY_URL.trim_end_matches('/')
);
if let Some(nav) = web_sys::window().map(|w| w.navigator()) {
let _ = nav.send_beacon_with_opt_str(&url, Some(&payload));
}
}
pub(crate) fn log(msg: &str) {
web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!("[lh] {msg}")));
CRUMBS.with(|c| {
let mut v = c.borrow_mut();
v.push(msg.to_string());
if v.len() > CAP {
let drop = v.len() - CAP;
v.drain(..drop);
}
});
if overlay_enabled() {
paint_overlay();
}
}
fn crumbs_snapshot() -> Vec<String> {
CRUMBS.with(|c| c.try_borrow().map(|v| v.clone()).unwrap_or_default())
}
fn overlay_enabled() -> bool {
web_sys::window()
.and_then(|w| w.location().search().ok())
.map(|s| s.contains("debug=1"))
.unwrap_or(false)
}
fn paint_overlay() {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let crumbs = crumbs_snapshot();
let markup = html! {
div id="lh-debug" style="position:fixed;left:0;right:0;bottom:0;z-index:2147483646;\
background:rgba(0,0,0,0.92);color:#9a9a9a;border-top:1px solid #444;\
font:10px/1.5 monospace;padding:6px 10px;max-height:38vh;overflow-y:auto;\
pointer-events:none;white-space:pre-wrap;" {
@for line in &crumbs {
div { (line) }
}
}
};
if let Some(el) = doc.get_element_by_id("lh-debug") {
el.set_outer_html(&markup.into_string());
} else if let Some(body) = doc.body() {
let _ = body.insert_adjacent_html("beforeend", &markup.into_string());
}
}
pub(crate) fn install_panic_banner() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
prev(info);
let msg = info.to_string();
let crumbs = crumbs_snapshot();
let tail_start = crumbs.len().saturating_sub(8);
send_panic_beacon(&msg, &crumbs[tail_start..]);
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let markup = html! {
div id="lh-panic-banner" style="position:fixed;top:0;left:0;right:0;\
z-index:2147483647;background:#000;color:#fff;border-bottom:1px solid #fff;\
font:11px/1.5 monospace;padding:12px 14px;max-height:50vh;overflow-y:auto;\
white-space:pre-wrap;" {
div { "⚠ the app crashed — screenshot this and send it:" }
div style="margin-top:6px" { (msg) }
@if !crumbs.is_empty() {
div style="margin-top:6px;color:#9a9a9a" { "last steps:" }
@for line in &crumbs[tail_start..] {
div style="color:#9a9a9a" { "· " (line) }
}
}
}
};
if let Some(el) = doc.get_element_by_id("lh-panic-banner") {
el.set_outer_html(&markup.into_string());
} else if let Some(body) = doc.body() {
let _ = body.insert_adjacent_html("afterbegin", &markup.into_string());
}
}));
}