From 427db6cd178a3f49f79533cbb2978c251cacf84b Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 21 Jul 2026 16:43:35 +0300 Subject: [PATCH] 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 --- Cargo.lock | 1 + Cargo.toml | 9 ++++ src/main.rs | 5 +++ src/sys.rs | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index d55a1e113..c009dd86b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,7 @@ dependencies = [ "image", "inventory", "js-sys", + "log", "lyon_tessellation", "lzma-sys", "ocs_plugin_api", diff --git a/Cargo.toml b/Cargo.toml index 609809da3..49f28486a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/src/main.rs b/src/main.rs index 1da70e81e..1b1c737a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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"))] diff --git a/src/sys.rs b/src/sys.rs index da4f9d72c..203246d63 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -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( + "
OpenCADStudio renderer error — please copy \ + this into a bug report: \ + \ +
\ +
",
+                );
+                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);
+        }
+    }
+}