forked from 1b-agent/opencadstudio-upstream
feat(web): copyable on-page banner for renderer errors (#414)
wgpu/naga report pipeline and shader failures through the log facade, then leave the canvas empty - with no logger installed on wasm the message was lost, so a broken GPU path looked like "draws nothing" with a clean console. Install a web logger that mirrors Error-level records to the browser console and into a fixed DOM overlay with selectable text, a Copy button and repeat-collapsing; panics land in the same banner via a chained hook. Windows Chrome/Edge reporters can now paste the exact ANGLE/D3D11 error instead of a screenshot of a blank canvas. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
77535f7c68
commit
427db6cd17
4 files changed
+131
No files matched your search
Generated
+1
@@ -31,6 +31,7 @@ dependencies = [
|
||||
"image",
|
||||
"inventory",
|
||||
"js-sys",
|
||||
"log",
|
||||
"lyon_tessellation",
|
||||
"lzma-sys",
|
||||
"ocs_plugin_api",
|
||||
|
||||
@@ -109,6 +109,10 @@ ureq = { version = "3", default-features = false, features = ["rustls"] }
|
||||
# Web gets the dependency-free manifest/ribbon contract only (no plugin host).
|
||||
ocs_plugin_api = { path = "crates/ocs_plugin_api" }
|
||||
console_error_panic_hook = "0.1"
|
||||
# Log facade (already in the tree via wgpu/iced): lets the web build install a
|
||||
# logger that mirrors wgpu/naga pipeline errors into a copyable on-page banner —
|
||||
# a WebGL2 pipeline that fails to build otherwise just leaves the canvas empty.
|
||||
log = "0.4"
|
||||
# ahash (via acadrust) pulls getrandom 0.3, which needs the `wasm_js` backend
|
||||
# enabled to support wasm32-unknown-unknown (plus the matching rustflag in
|
||||
# .cargo/config.toml).
|
||||
@@ -132,6 +136,11 @@ web-sys = { version = "0.3", features = [
|
||||
# Async clipboard read for paste into the text/MText editors (iced's own
|
||||
# clipboard read is a no-op on the web).
|
||||
"Clipboard",
|
||||
# Error banner (web_diag): forward wgpu/naga errors to the browser console
|
||||
# and inject a copyable DOM overlay (document.body → append_child).
|
||||
"console",
|
||||
"Node",
|
||||
"HtmlElement",
|
||||
] }
|
||||
# Browsers without WebGPU (e.g. Firefox by default) need wgpu's WebGL2 backend;
|
||||
# `webgl` enables it. `fira-sans` embeds the default UI font — the web has no
|
||||
|
||||
@@ -28,6 +28,11 @@ fn main() -> iced::Result {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
console_error_panic_hook::set_once();
|
||||
// After the console hook so the chained panic mirror keeps it; also
|
||||
// installs the log-facade listener that surfaces wgpu/naga errors as
|
||||
// a copyable on-page banner (#414 — an empty canvas otherwise gives
|
||||
// reporters nothing to paste).
|
||||
sys::web_diag::init();
|
||||
return app::run_web();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
+116
@@ -124,3 +124,119 @@ pub fn percent_encode(s: &str) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Web renderer-error surface (#414): wgpu / naga report pipeline and shader
|
||||
/// failures through the `log` facade and then leave the canvas empty — with no
|
||||
/// logger installed the message is lost, so a broken GPU path looks like "the
|
||||
/// app draws nothing" with a clean console. Mirror every Error-level record to
|
||||
/// the browser console AND into a fixed DOM banner whose text is selectable
|
||||
/// (the canvas UI is not), with a one-click Copy button, so a failing user can
|
||||
/// paste the exact error into a bug report. Panics land in the same banner via
|
||||
/// a chained hook.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod web_diag {
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Cap on banner entries so a per-frame error can't grow the DOM forever.
|
||||
const MAX_LINES: u32 = 12;
|
||||
|
||||
/// Last banner message + repeat count, for collapsing a hot error loop
|
||||
/// into one line with an `(xN)` suffix.
|
||||
static LAST: Mutex<(String, u32)> = Mutex::new((String::new(), 0));
|
||||
|
||||
struct BannerLogger;
|
||||
|
||||
impl log::Log for BannerLogger {
|
||||
fn enabled(&self, meta: &log::Metadata) -> bool {
|
||||
meta.level() <= log::Level::Error
|
||||
}
|
||||
fn log(&self, record: &log::Record) {
|
||||
if record.level() > log::Level::Error {
|
||||
return;
|
||||
}
|
||||
let msg = format!("[{}] {}", record.target(), record.args());
|
||||
web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&msg));
|
||||
show_banner(&msg);
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger + panic mirror. Call once at web startup, AFTER
|
||||
/// `console_error_panic_hook::set_once` so the chained hook keeps the
|
||||
/// console stack trace.
|
||||
pub fn init() {
|
||||
let console_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
show_banner(&info.to_string());
|
||||
console_hook(info);
|
||||
}));
|
||||
if log::set_boxed_logger(Box::new(BannerLogger)).is_ok() {
|
||||
log::set_max_level(log::LevelFilter::Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `msg` to the on-page banner, creating the overlay on first use.
|
||||
fn show_banner(msg: &str) {
|
||||
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
|
||||
return;
|
||||
};
|
||||
let pre = match doc.get_element_by_id("ocs-err-text") {
|
||||
Some(pre) => pre,
|
||||
None => {
|
||||
let Some(body) = doc.body() else { return };
|
||||
let Ok(overlay) = doc.create_element("div") else {
|
||||
return;
|
||||
};
|
||||
overlay.set_id("ocs-err");
|
||||
let _ = overlay.set_attribute(
|
||||
"style",
|
||||
"position:fixed;top:0;left:0;right:0;z-index:2147483647;\
|
||||
background:#5c1a1a;color:#ffdddd;font:12px monospace;\
|
||||
padding:8px 12px;max-height:40vh;overflow:auto;\
|
||||
user-select:text;cursor:text;",
|
||||
);
|
||||
// Inline `onclick` keeps this dependency-free (no JS closures):
|
||||
// Copy puts the full error text on the clipboard; Dismiss
|
||||
// removes the overlay so the app stays usable underneath.
|
||||
overlay.set_inner_html(
|
||||
"<div><b>OpenCADStudio renderer error</b> — please copy \
|
||||
this into a bug report: \
|
||||
<button style=\"margin-left:8px\" onclick=\"navigator.clipboard.writeText(\
|
||||
document.getElementById('ocs-err-text').innerText)\">Copy</button> \
|
||||
<button onclick=\"document.getElementById('ocs-err').remove()\">\
|
||||
Dismiss</button></div>\
|
||||
<pre id=\"ocs-err-text\" style=\"margin:6px 0 0;\
|
||||
white-space:pre-wrap;user-select:text;\"></pre>",
|
||||
);
|
||||
let _ = body.append_child(&overlay);
|
||||
match doc.get_element_by_id("ocs-err-text") {
|
||||
Some(pre) => pre,
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
};
|
||||
// Collapse repeats: an error thrown every frame becomes one line with
|
||||
// a running (xN) counter instead of MAX_LINES copies of itself.
|
||||
let mut last = match LAST.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
if last.0 == msg {
|
||||
last.1 += 1;
|
||||
if let Some(line) = pre.last_element_child() {
|
||||
line.set_text_content(Some(&format!("{msg} (x{})", last.1)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
*last = (msg.to_string(), 1);
|
||||
if pre.child_element_count() >= MAX_LINES {
|
||||
if let Some(first) = pre.first_element_child() {
|
||||
first.remove();
|
||||
}
|
||||
}
|
||||
if let Ok(line) = doc.create_element("div") {
|
||||
line.set_text_content(Some(msg));
|
||||
let _ = pre.append_child(&line);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user