forked from 1b-agent/fieldcraft-upstream
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:
1 parent
cb7e3a2711
commit
e06c85eb9e
9 files changed
+503
-8
No files matched your search
@@ -190,6 +190,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
recent_path: None,
|
||||
// Preferences last for the page's run.
|
||||
prefs_path: None,
|
||||
// egui opens links itself in the browser.
|
||||
open_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
mod control_server;
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use fieldcraft_engine::autosave::{self, Autosaver};
|
||||
use fieldcraft_engine::{Session, crash, logfile};
|
||||
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
|
||||
|
||||
const USAGE: &str =
|
||||
@@ -47,6 +47,8 @@ struct App {
|
||||
autosaver: Option<Autosaver>,
|
||||
/// App time of the last autosave check.
|
||||
autosaved_at: f64,
|
||||
/// The first frame was drawn (the crash journal says the app is up).
|
||||
running: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -61,6 +63,10 @@ impl App {
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if !self.running {
|
||||
self.running = true;
|
||||
crash::activity("running");
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
|
||||
self.autosaved_at = self.app.now;
|
||||
@@ -76,6 +82,8 @@ impl eframe::App for App {
|
||||
{
|
||||
log::warn!("autosave: {e}");
|
||||
}
|
||||
log::info!("FieldCraft closed");
|
||||
crash::end_session();
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
@@ -96,9 +104,55 @@ fn services() -> Services {
|
||||
// Beside the recovery folder in the per-user data folder.
|
||||
recent_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))),
|
||||
prefs_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("prefs.json"))),
|
||||
open_url: Some(Box::new(open_url)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a web link in the default browser, with the system's own opener: `open` (macOS),
|
||||
/// `xdg-open` (Linux and the BSDs), `explorer` (Windows). The link is one argument, never
|
||||
/// passed through a shell. Only https links are opened.
|
||||
fn open_url(url: &str) -> Result<(), String> {
|
||||
if !url.starts_with("https://") {
|
||||
return Err(format!("not a web link: {}", url.chars().take(80).collect::<String>()));
|
||||
}
|
||||
let opener = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else if cfg!(windows) {
|
||||
"explorer"
|
||||
} else {
|
||||
"xdg-open"
|
||||
};
|
||||
let mut child = std::process::Command::new(opener).arg(url).spawn().map_err(|e| format!("{opener}: {e}"))?;
|
||||
// Reaped off the window thread; its exit status says nothing (`explorer` exits with 1 even
|
||||
// when it opened the link).
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Crash reports and the log file (docs/crash-reports.md): reports of earlier runs that crashed
|
||||
/// come back, for the window to offer. Both live in the app's data folder (a temporary one in
|
||||
/// runs driven by automation); `FIELDCRAFT_CRASH_DIR` moves the crash folder.
|
||||
fn start_reporting() -> Vec<crash::Summary> {
|
||||
// First the crash reports: an unclean exit's report takes the dead run's lines from the log.
|
||||
let crashed = crash::default_dir().map(|dir| crash::install(dir, crash::Build::current())).unwrap_or_default();
|
||||
match fieldcraft_io::recovery::data_dir().map(|d| logfile::install(&d.join("logs"), log::LevelFilter::Info)) {
|
||||
Some(Ok(path)) => eprintln!("fieldcraft: log {}", path.display()),
|
||||
Some(Err(e)) => eprintln!("fieldcraft: no log file: {e}"),
|
||||
None => {}
|
||||
}
|
||||
let b = crash::Build::current();
|
||||
log::info!(
|
||||
"FieldCraft {} started ({}), crash reports in {}",
|
||||
b.version,
|
||||
b.version_line(),
|
||||
crash::default_dir().map(|d| d.display().to_string()).unwrap_or_default()
|
||||
);
|
||||
crash::activity("startup");
|
||||
crashed
|
||||
}
|
||||
|
||||
/// Command-line options.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Args {
|
||||
@@ -152,9 +206,10 @@ fn main() -> eframe::Result {
|
||||
};
|
||||
if args.control.is_some() {
|
||||
// A run driven by automation keeps its autosaves (and recent files and preferences, which
|
||||
// live beside them) in a temporary folder, never the user's.
|
||||
// live beside them), its crash reports and its log in a temporary folder, never the user's.
|
||||
fieldcraft_io::recovery::use_automation_dir();
|
||||
}
|
||||
let crashed = start_reporting();
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("FieldCraft")
|
||||
@@ -177,12 +232,15 @@ fn main() -> eframe::Result {
|
||||
"FieldCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = ModelApp::new(Session::default()).with_services(services());
|
||||
let mut session = Session::default();
|
||||
// The window's model: its commands go into the crash journal.
|
||||
session.crash_journal = crash::enabled();
|
||||
let mut app = ModelApp::new(session).with_services(services());
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let info = rs.adapter.get_info();
|
||||
eprintln!("fieldcraft: graphics {:?} {}, MSAA {}x (requested {MSAA}x)", info.backend, info.name, rs.msaa_samples);
|
||||
log::info!("graphics {:?} {} ({}), MSAA {}x (requested {MSAA}x)", info.backend, info.name, info.driver, rs.msaa_samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
|
||||
}
|
||||
if let Some(port) = args.control {
|
||||
@@ -202,7 +260,8 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok()
|
||||
});
|
||||
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0 }))
|
||||
app.announce_crashes(&crashed);
|
||||
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, running: false }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -255,6 +255,18 @@ pub fn draft(input: &Input, version: &str) -> Draft {
|
||||
Draft { title, fields, url, attachments, clipboard }
|
||||
}
|
||||
|
||||
/// Write a draft's attachments to `bug-reports/<time>/` in the app's data folder (the user
|
||||
/// attaches them from there); returns the folder.
|
||||
pub fn save_attachments(draft: &Draft) -> Result<std::path::PathBuf, String> {
|
||||
let root = fieldcraft_io::recovery::data_dir().ok_or("no data folder on this system")?;
|
||||
let dir = root.join("bug-reports").join(crate::crash::stamp_now());
|
||||
fieldcraft_io::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
for a in &draft.attachments {
|
||||
fieldcraft_io::fs::write_atomic(&dir.join(&a.name), &a.bytes, false).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The fields a link carries (for the review, when a field had to be cut).
|
||||
fn decode_fields(url: &str) -> Vec<(&'static str, String)> {
|
||||
let query = url.split_once('?').map_or("", |(_, q)| q);
|
||||
|
||||
@@ -240,6 +240,7 @@ impl Session {
|
||||
opened.history = std::mem::take(&mut self.history);
|
||||
opened.limits = self.limits;
|
||||
opened.file_access = std::mem::take(&mut self.file_access);
|
||||
opened.crash_journal = self.crash_journal;
|
||||
opened.clear_history();
|
||||
opened.mark_opened();
|
||||
*self = opened;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user