App: crash reports and a log file; Help › Report a Bug (#107)

The desktop app installs the crash reporter and a log file at start
(both in a temporary folder in --control and CI runs, #266), journals
the window's commands (background jobs included) and every control
request's method, and ends the journal on a clean exit. A start after a
crash asks: "FieldCraft closed unexpectedly", Report It… or Not Now.

Help › Report a Bug (also Tools › Help and ui.start
diagnostics.bug_report) shows exactly what the GitHub bug form will be
filled with: the version, the OS, the last error, and for a crash where
it happened. Share my session adds the replayable commands, Include the
model file the model. Open GitHub Issue opens the form in the browser
through the system's opener; files to attach are saved to
bug-reports/<time>/ and a long session goes on the clipboard. Nothing is
sent without that click and the user's submit on GitHub.

The control channel answers the new prompts (crashed: report, close;
report_bug: set, close) and ui.inspect shows the draft; it never opens
the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 00:36:18 -04:00
1 parent cb7e3a2711
commit e06c85eb9e
9 files changed
+503 -8

No files matched your search

+7
View File
@@ -336,7 +336,14 @@ fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) {
ui.label("Left drag orbits (or pans or zooms: the navigation bar), right or middle drag pans, the wheel zooms, F or a double click fits.");
ui.label(format!("S or {} searches every command; the command line at the bottom runs `id {{json}}`.", shortcut("K")));
ui.separator();
let report = ui
.button("Report a Bug…")
.on_hover_text("Draft a GitHub issue with the version, the OS and the last error; nothing is sent until you submit it")
.clicked();
ui.label(egui::RichText::new(format!("FieldCraft {}", crate::VERSION)).color(t.text_dim));
if report {
app.report_bug(None);
}
if let Some(n) = open {
app.ui.menu = None;
app.open_sample(n);
+20 -1
View File
@@ -164,6 +164,8 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
let mods = || egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() };
// A crash nothing catches (#248) still says which request came last.
fieldcraft_engine::crash::activity(&format!("control {}", req.method));
match req.method.as_str() {
"engine.execute" => {
let Some(id) = s("command") else { return err("missing `command`") };
@@ -202,6 +204,8 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
crate::files::Prompt::Design(p) => json!({"design": p}),
crate::files::Prompt::ReplaceMesh(r) => json!({"replace_mesh": r.question, "warnings": r.warnings}),
crate::files::Prompt::Delete(d) => json!({"delete": d.question(), "kind": d.kind, "name": d.name, "users": d.users, "allowed": d.allowed()}),
crate::files::Prompt::Crashed(r) => json!({"crashed": r}),
crate::files::Prompt::ReportBug => json!({"report_bug": app.report.as_ref().and_then(|f| f.drafted_json())}),
}),
"recent": app.recent.paths,
"version": crate::VERSION,
@@ -374,6 +378,21 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
app.prompt = None;
ok(Value::Null)
}
(crate::files::Prompt::Crashed(r), Some("report")) => {
app.report_bug(r.first().map(|c| c.id.clone()));
ok(json!({"prompt": "report_bug"}))
}
(crate::files::Prompt::Crashed(_), Some("close" | "cancel")) => {
app.prompt = None;
ok(Value::Null)
}
// The form's fields; sending opens the browser, which only a click does.
(crate::files::Prompt::ReportBug, Some("set")) => wrap(app.set_report(p)),
(crate::files::Prompt::ReportBug, Some("close" | "cancel")) => {
app.prompt = None;
app.report = None;
ok(Value::Null)
}
(crate::files::Prompt::Recover, Some("recover")) => match s("id") {
Some(id) => {
app.recover(id);
@@ -382,7 +401,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
None => err("missing `id` (from file.recovery.list)"),
},
(_, a) => err(format!(
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close)"
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close; crashed: report, close; report_bug: set with title/observed/share_session/include_model, close)"
)),
}
}
+20 -2
View File
@@ -33,6 +33,8 @@ pub struct Filter {
pub type PickOpen = Box<dyn Fn(&[Filter]) -> Option<String>>;
/// The host picks where to save, starting from a file name; `None` when the user cancelled.
pub type PickSave = Box<dyn Fn(&str, &[Filter]) -> Option<String>>;
/// The host opens a web link in the user's browser.
pub type OpenUrl = Box<dyn Fn(&str) -> Result<(), String>>;
/// What the host provides: native file pickers and where the recent-files list is kept. The
/// desktop app installs them; tests answer the pickers with closures.
@@ -44,6 +46,9 @@ pub struct Services {
pub recent_path: Option<PathBuf>,
/// The user's preferences file (JSON, [`Prefs`]); `None` keeps them for this run only.
pub prefs_path: Option<PathBuf>,
/// Opens a link in the browser (Help › Report a Bug); without it, egui's own
/// `open_url` (the browser build opens the link itself).
pub open_url: Option<OpenUrl>,
}
const MODEL: Filter = Filter { name: "FieldCraft model", extensions: &["fieldcraft"] };
@@ -275,6 +280,10 @@ pub enum Prompt {
/// Delete a definition (the item menu's Delete…, the Delete key): what uses it is listed, and
/// the deletion refused or (a datum's partitions) done with it.
Delete(crate::menu::DeleteAsk),
/// The last run crashed (its new crash reports, newest first): report it, or not now.
Crashed(Vec<fieldcraft_engine::crash::Summary>),
/// Help › Report a Bug ([`crate::report`]; the form is [`ModelApp::report`]).
ReportBug,
}
/// The answer to [`Prompt::Design`].
@@ -376,7 +385,14 @@ impl ModelApp {
if self.ensure_idle().is_err() {
return false;
}
let base = if fresh { Session::default() } else { self.session.clone() };
// A fresh session is the app's own model too (its commands go into the crash journal).
let base = if fresh {
let mut s = Session::default();
s.crash_journal = self.session.crash_journal;
s
} else {
self.session.clone()
};
let (rx, pending) = spawn(base, command, params);
self.set_status(format!("{title}…"), false);
self.loading = Some(Loading { title, done, recent, form: None, rx, pending, shown: false });
@@ -730,6 +746,7 @@ impl ModelApp {
}
"file.recover" | "file.recovery.list" => self.prompt = Some(Prompt::Recover),
"file.sample" => self.prompt = Some(Prompt::Samples),
"diagnostics.bug_report" | "diagnostics.crash_reports" | "diagnostics.crash_report" => self.report_bug(None),
"solve.run" => self.solve_all(),
"deck.write" if can_save => match preset.get("format").and_then(Value::as_str) {
Some(f) => self.write_deck(f),
@@ -989,7 +1006,8 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
app.prompt = None;
}
}
None => {}
// Drawn by `crate::report::show`.
Some(Prompt::Crashed(_) | Prompt::ReportBug) | None => {}
}
}
+9
View File
@@ -40,6 +40,7 @@ pub mod menu;
pub mod navigation;
pub mod palette;
pub mod reply;
pub mod report;
pub mod results;
pub mod results_panel;
pub mod samples;
@@ -390,6 +391,10 @@ pub struct ModelApp {
pub queued: std::collections::VecDeque<String>,
/// The question the window is asking (unsaved changes, recover).
pub prompt: Option<files::Prompt>,
/// Help › Report a Bug's form, while open ([`files::Prompt::ReportBug`]).
pub report: Option<report::ReportForm>,
/// The last error the status bar showed (what Report a Bug starts from).
pub last_error: Option<String>,
/// A design opened over a model of another part, waiting for [`files::Prompt::Design`]'s answer.
pub held: Option<files::Opened>,
/// The window draws its own title bar and caption buttons (Windows, Linux).
@@ -468,6 +473,8 @@ impl ModelApp {
loading: None,
queued: std::collections::VecDeque::new(),
prompt: None,
report: None,
last_error: None,
held: None,
custom_titlebar: false,
integrated_titlebar: false,
@@ -783,6 +790,7 @@ impl ModelApp {
let text = text.into();
if error {
log::warn!("{text}");
self.last_error = Some(text.clone());
}
self.status = Some(Status { text, error });
}
@@ -1442,6 +1450,7 @@ impl ModelApp {
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| viewport::show(self, ui));
palette::popup(self, ui.ctx());
files::show(self, ui.ctx());
report::show(self, ui.ctx());
if self.custom_titlebar {
titlebar::resize_zones(ui);
}
+368
View File
@@ -0,0 +1,368 @@
//! Help › Report a Bug, and the question the window asks after a crash ("FieldCraft closed
//! unexpectedly — Report it").
//!
//! The form shows exactly what the GitHub issue form will be filled with
//! ([`fieldcraft_engine::bug_report`]): the version, the OS, what happened (the last error by
//! default) and, after a crash, where it happened. **Share my session** adds the commands that
//! built the model, **Include the model file** the model as well. Nothing leaves the computer
//! until the user clicks **Open GitHub Issue**, which opens the filled form in their browser (they
//! submit it there, or not); files too big for the link are saved to a folder to attach from, and
//! a long session is also put on the clipboard.
use egui::RichText;
use fieldcraft_engine::bug_report::{self, Draft, Input};
use fieldcraft_engine::crash::{self, Summary};
use serde_json::{Value, json};
use crate::ModelApp;
use crate::files::Prompt;
/// The Report a Bug form.
#[derive(Debug, Default)]
pub struct ReportForm {
pub title: String,
pub observed: String,
/// The crash report being reported (its id).
pub crash: Option<String>,
pub share_session: bool,
pub include_model: bool,
/// What the draft is built from (the session's commands and model, or the crash report's),
/// read when the form opens and when the model is first included.
input: Input,
model_read: bool,
/// The draft, and the form state it was made for.
draft: Option<Draft>,
drafted: Option<Key>,
/// Why the input could not be read.
pub problem: Option<String>,
}
type Key = (String, String, bool, bool);
impl ReportForm {
/// The form and its draft as last made (for `ui.inspect`).
pub fn drafted_json(&self) -> Option<Value> {
let mut v = self.draft.as_ref()?.to_json();
v["share_session"] = json!(self.share_session);
v["include_model"] = json!(self.include_model);
v["crash"] = json!(self.crash);
Some(v)
}
/// The draft for the form as it is now (made again only when something changed).
pub fn draft(&mut self) -> &Draft {
let key = (self.title.clone(), self.observed.clone(), self.share_session, self.include_model);
if self.drafted.as_ref() != Some(&key) {
self.draft = None;
self.drafted = Some(key);
}
self.draft.get_or_insert_with(|| {
let mut input = self.input.clone();
input.title = self.title.clone();
input.observed = self.observed.clone();
input.share_session = self.share_session;
input.include_model = self.include_model;
bug_report::draft(&input, &crash::Build::current().version_line())
})
}
}
/// The input for `form` from the session (or its crash report).
fn read_input(app: &ModelApp, form: &ReportForm) -> Result<Input, String> {
let mut p = json!({"share_session": true, "include_model": form.include_model});
if let Some(id) = &form.crash {
p["crash"] = json!(id);
}
fieldcraft_engine::cmd::diagnostics::bug_report_input(&app.session, &p).map_err(|e| e.to_string())
}
impl ModelApp {
/// Open Help › Report a Bug, for `crash` (a report's id) or what happened last.
pub fn report_bug(&mut self, crash: Option<String>) {
let mut form = ReportForm {
observed: if crash.is_some() { String::new() } else { self.last_error.clone().unwrap_or_default() },
crash,
..Default::default()
};
match read_input(self, &form) {
Ok(input) => form.input = input,
Err(e) => form.problem = Some(e),
}
self.report = Some(form);
self.prompt = Some(Prompt::ReportBug);
self.ui.menu = None;
}
/// The host found these crash reports at startup: ask about the real crashes.
pub fn announce_crashes(&mut self, fresh: &[Summary]) {
let crashes: Vec<Summary> = fresh.iter().filter(|r| r.kind != "recovered_panic").cloned().collect();
if crashes.is_empty() {
return;
}
log::warn!(
"{} crash report(s) since the last start: {}",
crashes.len(),
crashes.iter().map(|c| c.id.as_str()).collect::<Vec<_>>().join(", ")
);
if self.prompt.is_none() {
self.prompt = Some(Prompt::Crashed(crashes));
}
}
/// Change the open form (`ui.prompt` `set`): any of title, observed, share_session,
/// include_model.
pub fn set_report(&mut self, p: &Value) -> Result<Value, String> {
let mut form = self.report.take().ok_or("Report a Bug is not open")?;
if let Some(t) = p.get("title").and_then(Value::as_str) {
form.title = t.chars().take(500).collect();
}
if let Some(t) = p.get("observed").and_then(Value::as_str) {
form.observed = t.chars().take(20_000).collect();
}
if let Some(b) = p.get("share_session").and_then(Value::as_bool) {
form.share_session = b;
}
if let Some(b) = p.get("include_model").and_then(Value::as_bool) {
form.include_model = b;
}
self.include_model_if_asked(&mut form);
let draft = form.draft().to_json();
self.report = Some(form);
Ok(draft)
}
/// Read the model once it is first included.
fn include_model_if_asked(&mut self, form: &mut ReportForm) {
if form.include_model && !form.model_read {
form.model_read = true;
match read_input(self, form) {
Ok(input) => form.input = input,
Err(e) => form.problem = Some(e),
}
}
}
/// Open the draft in the browser: attachments saved to a folder of their own, the session on
/// the clipboard when it is too long for the link.
fn send_report(&mut self, ctx: &egui::Context, draft: &Draft) {
let mut saved = None;
if !draft.attachments.is_empty() {
match bug_report::save_attachments(draft) {
Ok(dir) => saved = Some(dir),
Err(e) => {
self.set_status(format!("Report a Bug: could not save the files to attach: {e}"), true);
return;
}
}
}
if let Some(text) = &draft.clipboard {
ctx.copy_text(text.clone());
}
match &self.services.open_url {
Some(open) => {
if let Err(e) = open(&draft.url) {
self.set_status(format!("Report a Bug: could not open the browser: {e}"), true);
return;
}
}
None => ctx.open_url(egui::OpenUrl::new_tab(draft.url.clone())),
}
log::info!("bug report opened in the browser ({} bytes, {} attachment(s))", draft.url.len(), draft.attachments.len());
let text = match saved {
Some(dir) => format!("The issue form opened in your browser: attach the files in {} and submit it there.", dir.display()),
None => "The issue form opened in your browser: review it and submit it there.".to_string(),
};
self.set_status(text, false);
self.prompt = None;
self.report = None;
}
}
fn size(bytes: usize) -> String {
if bytes < 1024 { format!("{bytes} B") } else { format!("{} KB", bytes.div_ceil(1024)) }
}
fn kind_label(kind: &str) -> &'static str {
match kind {
"unclean_exit" => "FieldCraft closed unexpectedly",
"command_panic" => "A command hit an internal error",
_ => "FieldCraft hit an internal error",
}
}
/// The crash question and the Report a Bug form, when open.
pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let t = crate::theme::Tokens::get(app.ui.dark);
match app.prompt.clone() {
Some(Prompt::Crashed(reports)) => {
let Some(first) = reports.first() else {
app.prompt = None;
return;
};
let (mut report, mut close) = (false, false);
crate::frame::window(ctx, kind_label(&first.kind), crate::frame::Width::Normal).id(egui::Id::new("fc_crashed")).show(ctx, |ui| {
ui.label(match first.kind.as_str() {
"unclean_exit" => "FieldCraft closed unexpectedly the last time it ran.",
_ => "FieldCraft hit an internal error the last time it ran.",
});
ui.label(RichText::new(&first.message).color(t.text_dim));
if reports.len() > 1 {
ui.label(RichText::new(format!("And {} more since.", reports.len() - 1)).color(t.text_dim));
}
ui.add_space(4.0);
ui.label(
RichText::new(
"A crash report was saved on this computer. Reporting it opens a GitHub issue you review first; nothing is sent until you submit it.",
)
.color(t.text_dim),
);
ui.add_space(6.0);
ui.horizontal(|ui| {
report = ui.button("Report It…").clicked();
close = ui.button("Not Now").clicked();
});
});
if report {
app.report_bug(Some(first.id.clone()));
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
}
}
Some(Prompt::ReportBug) => {
let Some(mut form) = app.report.take() else {
app.prompt = None;
return;
};
let (mut send, mut close) = (false, false);
let commands = form.input.commands.len();
crate::frame::window(ctx, "Report a Bug", crate::frame::Width::Wide).id(egui::Id::new("fc_report_bug")).show(ctx, |ui| {
ui.label(
RichText::new(
"This opens a GitHub issue in your browser, filled in with what is shown below. Nothing is sent until you submit it there.",
)
.color(t.text_dim),
);
if let Some(p) = &form.problem {
ui.label(RichText::new(p).color(t.error));
}
ui.add_space(4.0);
ui.label("Title");
let hint = form.draft().title.clone();
ui.add(egui::TextEdit::singleline(&mut form.title).hint_text(hint).desired_width(f32::INFINITY));
ui.label("What happened");
ui.add(
egui::TextEdit::multiline(&mut form.observed)
.hint_text("What you did, and what went wrong")
.desired_rows(3)
.desired_width(f32::INFINITY),
);
ui.add_space(4.0);
ui.checkbox(&mut form.share_session, "Share my session")
.on_hover_text("Attach the commands that built the model, to replay it. They can name your files.");
ui.label(
RichText::new(format!("The {commands} command(s) that built the model, the version and the OS; they can name your files."))
.color(t.text_dim)
.size(11.0),
);
ui.add_enabled_ui(form.share_session, |ui| {
ui.checkbox(&mut form.include_model, "Include the model file")
.on_hover_text("Attach the model too (you attach the file yourself).");
});
if !form.share_session {
form.include_model = false;
}
app.include_model_if_asked(&mut form);
let draft = form.draft().clone();
ui.add_space(6.0);
ui.horizontal(|ui| {
send = ui.button("Open GitHub Issue").clicked();
close = ui.button("Cancel").clicked();
});
ui.add_space(4.0);
egui::CollapsingHeader::new("What is sent").id_salt("fc_report_sent").default_open(true).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
ui.label(RichText::new(format!("Title: {}", draft.title)).monospace().size(11.0));
for (k, v) in &draft.fields {
if !v.is_empty() {
ui.label(RichText::new(format!("{k}:")).strong().size(11.0));
ui.label(RichText::new(v).monospace().size(11.0));
}
}
for a in &draft.attachments {
ui.label(RichText::new(format!("Attach {} ({}): {}", a.name, size(a.bytes.len()), a.what)).size(11.0).color(t.accent));
}
if draft.clipboard.is_some() {
ui.label(RichText::new("The session goes on the clipboard too, to paste.").size(11.0).color(t.text_dim));
}
});
});
});
let draft = form.draft().clone();
app.report = Some(form);
if send {
app.send_report(ctx, &draft);
} else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
app.report = None;
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use fieldcraft_engine::Session;
#[test]
fn report_a_bug_shows_the_last_error_and_shares_only_when_ticked() {
let mut app = ModelApp::new(Session::default());
app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
let _ = app.run("mesh.node.remove", json!({"label": 99}));
let error = app.last_error.clone().expect("the failure is the last error");
app.report_bug(None);
assert_eq!(app.prompt, Some(Prompt::ReportBug));
let form = app.report.as_mut().unwrap();
assert_eq!(form.observed, error);
let d = form.draft().clone();
assert!(d.attachments.is_empty() && !bug_report_text(&d).contains("mesh.node.add"), "no session unless shared");
let d = app.set_report(&json!({"share_session": true})).unwrap();
assert!(d["url"].as_str().unwrap().contains("mesh.node.add"), "{d}");
let d = app.set_report(&json!({"include_model": true})).unwrap();
assert_eq!(d["attachments"][0]["name"], "model.fieldcraft", "{d}");
// The model goes only with the session.
let d = app.set_report(&json!({"share_session": false})).unwrap();
assert_eq!(d["attachments"], json!([]), "{d}");
}
fn bug_report_text(d: &Draft) -> String {
d.fields.iter().map(|(_, v)| v.as_str()).collect::<Vec<_>>().join("\n")
}
#[test]
fn a_crash_at_startup_asks_and_report_it_opens_the_form() {
let mut app = ModelApp::new(Session::default());
let quiet = Summary {
id: "20261014-101500-0000000000000001".into(),
kind: "recovered_panic".into(),
time: String::new(),
message: String::new(),
command: None,
count: 1,
version: String::new(),
path: String::new(),
replayable: false,
};
app.announce_crashes(std::slice::from_ref(&quiet));
assert_eq!(app.prompt, None, "recovered panics do not interrupt");
let crashed = Summary { kind: "unclean_exit".into(), message: "FieldCraft ended without a clean exit".into(), ..quiet };
app.announce_crashes(std::slice::from_ref(&crashed));
assert!(matches!(&app.prompt, Some(Prompt::Crashed(r)) if r.len() == 1));
app.report_bug(Some(crashed.id.clone()));
let form = app.report.as_ref().unwrap();
assert_eq!(form.crash.as_deref(), Some(crashed.id.as_str()));
// The report is not in this test's crash folder: the form says so and still drafts.
assert!(form.problem.as_deref().is_some_and(|p| p.contains("no crash report")), "{:?}", form.problem);
}
}