Skip to main content

brep_app/panels/
bug_report.rs

1//! In-app **Submit Bug** report.
2//!
3//! A toolbar button opens this flow. It captures, WITHOUT the report dialog in
4//! the shot:
5//!   * a **screenshot** of the whole application (egui UI + the 3D model) as it
6//!     looked the instant the button was pressed,
7//!   * the current **model** (the `.BREP.json` recipe), and
8//!   * a user **description** (+ an optional email), and
9//!   * the session's **diagnostics** — which renderer is actually in use and
10//!     what it is running on, appended to the description (see
11//!     [`compose_description`] for why they travel inside that field),
12//! then POSTs them to the public reports endpoint (`v2.brep.io/api/report`).
13//! ONE code path runs on both native and wasm.
14//!
15//! ## Screenshot-before-dialog
16//! egui/eframe captures a screenshot of the FRAME (the composited surface, so it
17//! includes the 3D viewport, which is an `egui_wgpu` paint callback into egui's
18//! frame — see [`crate::viewport`]). We must not let the dialog appear in that
19//! frame, so the flow is a small state machine:
20//!   1. Button click → snapshot the model, send `ViewportCommand::Screenshot`,
21//!      enter [`Phase::Capturing`]. The dialog is NOT drawn while capturing.
22//!   2. A later frame delivers `Event::Screenshot`; we encode it to PNG, build a
23//!      preview thumbnail, and enter [`Phase::Editing`] — only NOW is the dialog
24//!      drawn, so the captured frame(s) never contain it.
25//!   3. Submit builds a small multipart body by hand (no extra deps) and fires
26//!      it through `ehttp`; the reply marshals back over an mpsc channel +
27//!      `request_repaint`, exactly like [`crate::panels::step_parts`].
28
29use crate::automation::hit_keys::HitKeyDoc;
30use crate::diagnostics::Diagnostics;
31use std::sync::mpsc::Receiver;
32
33use brep_render::engine_state::EngineState;
34use crate::icon_text::IconTextUi as _;
35use eframe::egui;
36
37/// The public reports endpoint the button posts to (the cadDev public server,
38/// fronted by v2.brep.io). Accepts the multipart fields
39/// `description`,`email`,`model`,`screenshot`.
40const REPORT_URL: &str = "https://v2.brep.io/api/report";
41
42/// The server's cap on the `description` field, in CHARACTERS
43/// (`description.chars().take(MAX_DESC)` in the endpoint's own
44/// `routes/reports.rs`). It truncates the TAIL, and the diagnostics block is at
45/// the tail — so [`compose_description`] clamps the user's own text to leave
46/// room rather than letting a very long description silently cut the
47/// diagnostics off.
48const MAX_DESC: usize = 20_000;
49
50/// Frames to wait for the screenshot event before giving up and opening the
51/// dialog anyway (so a device that never delivers the capture can't hang the
52/// flow). ~1s at 60fps; the persistent offscreen 3D means the capture normally
53/// lands within a few frames.
54const CAPTURE_TIMEOUT_FRAMES: u32 = 60;
55
56/// Where the flow is between "button pressed" and "dialog closed".
57#[derive(Default, PartialEq)]
58enum Phase {
59    /// Nothing in progress.
60    #[default]
61    Idle,
62    /// Screenshot requested; dialog intentionally hidden so it isn't captured.
63    Capturing { frames: u32 },
64    /// Screenshot in hand; dialog open, collecting description + email.
65    Editing,
66    /// POST in flight.
67    Sending,
68}
69
70pub struct BugReportPanel {
71    phase: Phase,
72    /// The problem description (required to submit).
73    description: String,
74    /// Optional reporter email.
75    email: String,
76    /// A short status / error line under the buttons.
77    status: String,
78    /// PNG bytes of the pre-dialog screenshot (UI + 3D), if captured.
79    screenshot_png: Option<Vec<u8>>,
80    /// A preview texture of the screenshot shown in the dialog.
81    thumb: Option<egui::TextureHandle>,
82    /// The model (`.BREP.json`) snapshotted at button-press time.
83    model_json: String,
84    /// The session diagnostics block, taken from the app's ONE
85    /// [`Diagnostics`] at button-press time — the same text the Info window
86    /// shows, so a report can never describe a different machine from the one
87    /// the user was reading about.
88    diagnostics: String,
89    /// The in-flight POST reply channel (drained each frame).
90    response_rx: Option<Receiver<Result<(), String>>>,
91    /// Per-frame widget rects for the headed verifier (wasm only).
92    hits: std::collections::HashMap<String, egui::Rect>,
93}
94
95impl BugReportPanel {
96    pub fn new() -> Self {
97        Self {
98            phase: Phase::Idle,
99            description: String::new(),
100            email: String::new(),
101            status: String::new(),
102            screenshot_png: None,
103            thumb: None,
104            model_json: String::new(),
105            diagnostics: String::new(),
106            response_rx: None,
107            hits: std::collections::HashMap::new(),
108        }
109    }
110
111    /// Toolbar entry point: snapshot the model + the session diagnostics,
112    /// request a screenshot of THIS frame (before the dialog exists), and begin
113    /// capturing. Ignored if a report flow is already in progress.
114    ///
115    /// `diagnostics` is the app's ONE instance — the report renders it here
116    /// rather than collecting anything of its own, which is what keeps the
117    /// submitted text and the Info window's rows the same rows.
118    pub fn request(&mut self, ctx: &egui::Context, state: &EngineState, diagnostics: &Diagnostics) {
119        if self.phase != Phase::Idle {
120            return;
121        }
122        self.description.clear();
123        self.email.clear();
124        self.status.clear();
125        self.screenshot_png = None;
126        self.thumb = None;
127        self.response_rx = None;
128        // The model can't change while the modal is open, but snapshot it now so
129        // the report reflects exactly the state the user was looking at.
130        self.model_json = state.history_request_json();
131        self.diagnostics = diagnostics.report_text();
132        ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
133        self.phase = Phase::Capturing { frames: 0 };
134        ctx.request_repaint();
135    }
136
137    /// Draw + drive the flow. Called once per frame at ctx level (like the file
138    /// dialog). Idempotent while [`Phase::Idle`].
139    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
140        self.hits.clear();
141
142        self.poll_capture(ctx);
143        self.drain_response(state);
144
145        if !matches!(self.phase, Phase::Editing | Phase::Sending) {
146            return;
147        }
148
149        let sending = self.phase == Phase::Sending;
150        let mut submit = false;
151        let mut cancel = false;
152        let modal = egui::Modal::new(egui::Id::new("brep-bug-report")).show(ctx, |ui| {
153            ui.set_width(560.0);
154            // `icon_label`, not `heading`: U+1F41E is a catalogued COLOUR icon, so
155            // this draws the real artwork inline with the title instead of the
156            // font's monochrome outline.
157            ui.icon_label(
158                egui::RichText::new("\u{1F41E}  Submit a bug report").heading(),
159            );
160            ui.add_space(4.0);
161            ui.label(
162                "Describe what went wrong. Your current model and a screenshot of \
163                 the app (UI + 3D view) are attached automatically.",
164            );
165            ui.add_space(2.0);
166            ui.weak("Your report and its screenshot may be shown publicly on the bug list.");
167            ui.add_space(8.0);
168
169            ui.label("What happened?");
170            let desc = ui.add(
171                egui::TextEdit::multiline(&mut self.description)
172                    .desired_rows(5)
173                    .desired_width(f32::INFINITY)
174                    .hint_text("Steps, what you expected, and what actually happened"),
175            );
176            self.hit("field:description", &desc);
177
178            ui.add_space(6.0);
179            ui.label("Email (optional)");
180            let email = ui.add(
181                egui::TextEdit::singleline(&mut self.email)
182                    .desired_width(f32::INFINITY)
183                    .hint_text("so we can follow up — optional"),
184            );
185            self.hit("field:email", &email);
186
187            ui.add_space(8.0);
188            if let Some(tex) = &self.thumb {
189                ui.label("Attached screenshot:");
190                ui.add_space(2.0);
191                // Fit the preview to the dialog width, keeping aspect.
192                let size = tex.size_vec2();
193                let scale = (520.0 / size.x).min(1.0);
194                ui.add(
195                    egui::Image::new((tex.id(), size * scale))
196                        .corner_radius(4.0)
197                        .bg_fill(egui::Color32::from_gray(20)),
198                );
199            } else {
200                ui.weak("(screenshot unavailable — the model + description will still be sent)");
201            }
202
203            // What the report will carry about this machine, shown before it is
204            // sent rather than attached invisibly. Collapsed: it is for the
205            // triager, and the user has already read it in the Info window if
206            // they wanted to.
207            ui.add_space(8.0);
208            let diag = egui::CollapsingHeader::new("Diagnostics attached to this report")
209                .id_salt("brep-bug-diagnostics")
210                .show(ui, |ui| {
211                    ui.add(
212                        egui::Label::new(
213                            egui::RichText::new(&self.diagnostics).monospace().small(),
214                        )
215                        .wrap(),
216                    );
217                });
218            self.hits.insert("diagnostics".to_string(), diag.header_response.rect);
219
220            ui.add_space(10.0);
221            ui.horizontal(|ui| {
222                let can_submit = !self.description.trim().is_empty() && !sending;
223                let label = if sending { "Sending\u{2026}" } else { "Submit report" };
224                let submit_btn = ui.add_enabled(can_submit, egui::Button::new(label));
225                self.hit("submit", &submit_btn);
226                if submit_btn.clicked() {
227                    submit = true;
228                }
229                let cancel_btn = ui.add_enabled(!sending, egui::Button::new("Cancel"));
230                self.hit("cancel", &cancel_btn);
231                if cancel_btn.clicked() {
232                    cancel = true;
233                }
234            });
235            if !self.status.is_empty() {
236                ui.add_space(6.0);
237                ui.weak(&self.status);
238            }
239        });
240
241        if submit {
242            self.send(ctx);
243        } else if cancel || (modal.should_close() && !sending) {
244            self.reset();
245        }
246    }
247
248    /// While capturing, look for the delivered screenshot; time out gracefully.
249    fn poll_capture(&mut self, ctx: &egui::Context) {
250        let frames = match &mut self.phase {
251            Phase::Capturing { frames } => {
252                *frames += 1;
253                *frames
254            }
255            _ => return,
256        };
257        // eframe injects `Event::Screenshot` into the frame's raw input once the
258        // async framebuffer readback completes. Take the newest one.
259        let shot = ctx.input(|i| {
260            i.raw.events.iter().rev().find_map(|e| match e {
261                egui::Event::Screenshot { image, .. } => Some(image.clone()),
262                _ => None,
263            })
264        });
265        if let Some(img) = shot {
266            self.screenshot_png = encode_png(&img);
267            self.thumb = Some(ctx.load_texture(
268                "brep-bug-shot",
269                (*img).clone(),
270                egui::TextureOptions::LINEAR,
271            ));
272            self.phase = Phase::Editing;
273        } else if frames > CAPTURE_TIMEOUT_FRAMES {
274            self.status = "(screenshot unavailable)".into();
275            self.phase = Phase::Editing;
276        } else {
277            ctx.request_repaint();
278        }
279    }
280
281    /// Drain the POST reply: success closes the dialog with a toast; an error
282    /// stays open so the user can retry.
283    fn drain_response(&mut self, state: &mut EngineState) {
284        let Some(rx) = &self.response_rx else { return };
285        let Ok(result) = rx.try_recv() else { return };
286        self.response_rx = None;
287        match result {
288            Ok(()) => {
289                state.push_notice("Bug report submitted — thank you!".to_string());
290                self.reset();
291            }
292            Err(e) => {
293                self.status = format!("Submit failed: {e}");
294                self.phase = Phase::Editing;
295            }
296        }
297    }
298
299    /// Build the multipart body and fire the POST (native + wasm via ehttp).
300    fn send(&mut self, ctx: &egui::Context) {
301        self.status = "Submitting\u{2026}".into();
302        self.phase = Phase::Sending;
303
304        let (content_type, body) = build_multipart(
305            &compose_description(&self.description, &self.diagnostics),
306            &self.email,
307            &self.model_json,
308            self.screenshot_png.as_deref(),
309        );
310        let mut req = ehttp::Request::post(REPORT_URL, body);
311        // `Request::post` sets text/plain; replace it with our multipart type.
312        req.headers
313            .headers
314            .retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
315        req.headers.headers.push(("Content-Type".to_string(), content_type));
316
317        let (tx, rx) = std::sync::mpsc::channel();
318        let ctx = ctx.clone();
319        ehttp::fetch(req, move |result| {
320            let out = match result {
321                Ok(resp) if resp.ok => Ok(()),
322                Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
323                Err(err) => Err(err),
324            };
325            let _ = tx.send(out);
326            ctx.request_repaint();
327        });
328        self.response_rx = Some(rx);
329    }
330
331    /// Back to idle, dropping the screenshot + preview texture.
332    fn reset(&mut self) {
333        self.phase = Phase::Idle;
334        self.description.clear();
335        self.email.clear();
336        self.status.clear();
337        self.diagnostics.clear();
338        self.screenshot_png = None;
339        self.thumb = None;
340        self.response_rx = None;
341    }
342
343    /// Record a widget's screen rect for the headed verifier (wasm only; a no-op
344    /// on native).
345    fn hit(&mut self, key: &str, resp: &egui::Response) {
346        self.hits.insert(key.to_string(), resp.rect);
347    }
348
349    /// Logical state for the headed verifier: which phase + whether a shot was
350    /// captured.
351    pub fn state_json(&self) -> String {
352        let phase = match self.phase {
353            Phase::Idle => "idle",
354            Phase::Capturing { .. } => "capturing",
355            Phase::Editing => "editing",
356            Phase::Sending => "sending",
357        };
358        serde_json::json!({
359            "phase": phase,
360            "hasScreenshot": self.screenshot_png.is_some(),
361            "diagnostics": self.diagnostics,
362            "status": self.status,
363        })
364        .to_string()
365    }
366
367    /// The published widget hit-rects (egui points) for the headed verifier.
368    pub fn hits_json(&self) -> String {
369        let map: serde_json::Map<String, serde_json::Value> = self
370            .hits
371            .iter()
372            .map(|(k, r)| {
373                (
374                    k.clone(),
375                    serde_json::json!([r.center().x, r.center().y, r.width(), r.height()]),
376                )
377            })
378            .collect();
379        serde_json::Value::Object(map).to_string()
380    }
381}
382
383/// Encode an egui `ColorImage` (the screenshot) to PNG bytes via the pure-Rust
384/// `image` crate (wasm-safe). `None` on a zero-size image or encode error.
385fn encode_png(color: &egui::ColorImage) -> Option<Vec<u8>> {
386    use image::ImageEncoder;
387    let [w, h] = color.size;
388    if w == 0 || h == 0 {
389        return None;
390    }
391    let mut rgba = Vec::with_capacity(w * h * 4);
392    for px in &color.pixels {
393        // Straight (un-premultiplied) sRGBA, matching a normal PNG.
394        rgba.extend_from_slice(&px.to_srgba_unmultiplied());
395    }
396    let mut png = Vec::new();
397    image::codecs::png::PngEncoder::new(&mut png)
398        .write_image(&rgba, w as u32, h as u32, image::ExtendedColorType::Rgba8)
399        .ok()?;
400    Some(png)
401}
402
403/// The `description` the report actually submits: what the user typed, then the
404/// diagnostics block.
405///
406/// **Why inside `description` and not its own part.** The endpoint
407/// (`v2.brep.io/api/report`, served by the cadDev repo's
408/// `src/routes/reports.rs`) parses multipart into a fixed four-field record and
409/// its match ends `_ => {}` — an unknown part is accepted and silently DROPPED,
410/// never rejected. A `diagnostics` part would therefore submit cleanly and
411/// arrive nowhere, which is the worst of both: a client that looks like it
412/// reports the renderer and a server that never stores it. `description` is
413/// stored verbatim in `report.json` and is what a triager reads first, so the
414/// block goes there until the server grows a field of its own.
415///
416/// APPENDED, never prepended: the server's push notification and its public
417/// report list both summarise a report by its FIRST LINE, and a list where every
418/// row reads "--- diagnostics ---" tells a reader nothing.
419///
420/// The user's own text is clamped so the block survives the server's
421/// [`MAX_DESC`] tail truncation. A description long enough to hit that cap has
422/// already said what it has to say; the diagnostics are the part that cannot be
423/// re-derived later.
424fn compose_description(description: &str, diagnostics: &str) -> String {
425    if diagnostics.is_empty() {
426        return description.chars().take(MAX_DESC).collect();
427    }
428    const SEPARATOR: &str = "\n\n";
429    // `chars`, matching the server's own `chars().take(MAX_DESC)`.
430    let block_len = diagnostics.chars().count() + SEPARATOR.chars().count();
431    let room = MAX_DESC.saturating_sub(block_len);
432    let user: String = description.chars().take(room).collect();
433    format!("{user}{SEPARATOR}{diagnostics}")
434}
435
436/// Hand-build a `multipart/form-data` body (avoids ehttp's `multipart` feature,
437/// which pulls `rand`→`getrandom` and would need the wasm `js` feature). The
438/// boundary carries a distinctive ASCII prefix plus the payload lengths so it
439/// can't collide with the (text) model JSON or the PNG bytes.
440fn build_multipart(
441    description: &str,
442    email: &str,
443    model: &str,
444    screenshot: Option<&[u8]>,
445) -> (String, Vec<u8>) {
446    let boundary = format!(
447        "----BREPBugReport{:x}x{:x}Boundary",
448        model.len(),
449        screenshot.map(|s| s.len()).unwrap_or(0)
450    );
451    let mut body = Vec::new();
452    push_text_field(&mut body, &boundary, "description", description);
453    push_text_field(&mut body, &boundary, "email", email);
454    push_file_field(
455        &mut body,
456        &boundary,
457        "model",
458        "model.BREP.json",
459        "application/json",
460        model.as_bytes(),
461    );
462    if let Some(png) = screenshot {
463        push_file_field(&mut body, &boundary, "screenshot", "screenshot.png", "image/png", png);
464    }
465    body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
466    (format!("multipart/form-data; boundary={boundary}"), body)
467}
468
469fn push_text_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
470    body.extend_from_slice(
471        format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n")
472            .as_bytes(),
473    );
474    body.extend_from_slice(value.as_bytes());
475    body.extend_from_slice(b"\r\n");
476}
477
478fn push_file_field(
479    body: &mut Vec<u8>,
480    boundary: &str,
481    name: &str,
482    filename: &str,
483    content_type: &str,
484    bytes: &[u8],
485) {
486    body.extend_from_slice(
487        format!(
488            "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; \
489             filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n"
490        )
491        .as_bytes(),
492    );
493    body.extend_from_slice(bytes);
494    body.extend_from_slice(b"\r\n");
495}
496
497/// The hit keys this panel publishes (see `automation::hit_keys`).
498pub static HIT_KEYS: &[HitKeyDoc] = &[
499    HitKeyDoc { panel: "bug", prefix: "field:description", meaning: "the description field", command: None },
500    HitKeyDoc { panel: "bug", prefix: "field:email", meaning: "the email field", command: None },
501    HitKeyDoc { panel: "bug", prefix: "diagnostics", meaning: "expand the diagnostics block the report will carry (the `diagnostics` command returns the same rows, but expanding a header is not what it does)", command: None },
502    HitKeyDoc { panel: "bug", prefix: "submit", meaning: "submit the report", command: None },
503    HitKeyDoc { panel: "bug", prefix: "cancel", meaning: "close the report", command: None },
504];
505
506// BREP private tests: 3c7e5b1a80d4f296