crates/veilvoice-gui/src/crashreport.rs
what this file is for · veilvoice-gui · 289 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Offering the report from the last crash, on the run after it.
//!
//! # What was wrong with where this used to be
//!
//! A report has been written to disk since v0.1.10, and the interface
//! mentioned it: one line on the About tab saying the previous run ended
//! unexpectedly and where the file is.
//!
//! About is the last tab somebody who has just had a crash will open. It is
//! where you go to read version numbers. The person this notice is for
//! restarted the application to get back to what they were doing, landed on
//! the tab they were using, and saw nothing. So the report existed, was
//! accurate, was written for a person to read, and was never read by one.
//!
//! It is now shown above whichever tab you land on, once, on the first run
//! after a crash. That is the panel the application already uses for anything
//! it needs somebody to see whatever they are looking at.
//!
//! # Why it is offered rather than sent
//!
//! Nothing is transmitted, and nothing here could transmit it: this project
//! contains no network client and CI fails the build if one enters the
//! dependency graph. A crash reporter that uploads is the ordinary shape of
//! this feature and it is the wrong shape for this program, because a report
//! from a tool people use to protect themselves is a report about a person who
//! was being careful.
//!
//! So the report is shown, in full, in the window. The whole text, not a
//! summary of it, because "would you like to send this" is only a real
//! question if you can read what "this" is. Then two buttons: copy it, and
//! open the issue tracker in a browser. What happens after that is the
//! person's decision and their clipboard.
//!
//! # And it says what is in it, without overpromising
//!
//! The report carries the version, the operating system and processor, when it
//! happened, the panic message and the source location. VeilVoice puts in no
//! file names, no settings, no passphrase and nothing about the audio.
//!
//! The panel used to say flatly that the report *contains* no file names. That
//! is a promise about the panic message, and the panic message is the one part
//! nobody writes in advance. No panic in VeilVoice's own code formats a path --
//! there is a test for that, in `crashlog`, and it reads the source rather than
//! trusting the claim -- but a decoder or a toolkit it depends on could put one
//! there, and this program cannot promise on their behalf.
//!
//! So the panel says what VeilVoice puts in, names the error message as the
//! part that is not written in advance, and offers the whole text to read. A
//! promise that cannot be kept is worse than an accurate description with a
//! button next to it.
use crate::theme::palette as p;
use egui::{RichText, Ui};
/// Where a report goes, if the person wants to file one.
///
/// A new issue rather than the issue list: somebody arriving from a crash has
/// a report in their clipboard and wants a box to put it in, not a search.
pub const NEW_ISSUE: &str = "https://github.com/tilas01/veilvoice/issues/new";
/// The issue tracker itself, linked from About whether or not anything crashed.
pub const ISSUES: &str = "https://github.com/tilas01/veilvoice/issues";
/// How long the copy button says it copied, in seconds.
const COPIED_FOR: f64 = 2.5;
/// What the panel is showing, kept across frames.
#[derive(Default)]
pub struct Offer {
/// The report, read once. `None` means there was none, or it was dismissed.
report: Option<(String, String)>,
/// Whether the file's whole contents are open.
reading: bool,
/// When the copy button was last pressed.
copied: Option<f64>,
/// Set once the file has been looked for, so a missing report is not
/// re-read from disk on every frame of every launch.
looked: bool,
}
impl Offer {
/// Read the previous report, if there is one. Cheap after the first call.
pub fn look(&mut self) {
if self.looked {
return;
}
self.looked = true;
self.report =
crate::crashlog::previous().map(|(path, text)| (path.display().to_string(), text));
}
/// Whether there is anything to show.
pub fn waiting(&self) -> bool {
self.report.is_some()
}
/// Draw the offer. Returns true when it has been dealt with and the panel
/// should go away.
///
/// Dismissing deletes the file. That is deliberate and it is the reason
/// the whole text is on screen first: a notice that keeps coming back is a
/// notice people learn to close without reading, and the report is of no
/// use to anybody once its owner has decided not to file it.
pub fn panel(&mut self, ui: &mut Ui) -> bool {
let Some((path, text)) = self.report.clone() else {
return false;
};
let now = ui.input(|i| i.time);
let mut finished = false;
ui.label(
RichText::new("The previous run ended unexpectedly.")
.color(p::yellow())
.strong(),
);
ui.label(
RichText::new(format!(
"A report was written to {path}. It was written on this machine and \
has been sent nowhere: VeilVoice has no network code at all, and \
nothing here can send it. If you would like the fault fixed, the \
whole of it is below to copy into an issue."
))
.color(p::muted())
.size(12.0),
);
ui.add_space(6.0);
ui.label(
RichText::new(
"VeilVoice puts four things in it: the version, this operating \
system and processor, when it happened, and the error. It puts in \
no file names, no settings, no passphrase and nothing about any \
audio.",
)
.color(p::fg())
.size(12.0),
);
ui.label(
RichText::new(
"The error message is the one part not written in advance. \
VeilVoice's own never carry a file name, and a library it uses \
could still put one there. That is why the whole thing is here to \
read before you send it anywhere.",
)
.color(p::muted())
.size(12.0),
);
ui.add_space(8.0);
let just_copied = self.copied.is_some_and(|at| now - at < COPIED_FOR);
ui.horizontal_wrapped(|ui| {
if ui.button("copy the report").clicked() {
ui.ctx().copy_text(text.clone());
self.copied = Some(now);
}
ui.hyperlink_to("open a new issue", NEW_ISSUE);
let label = if self.reading {
"hide what it says"
} else {
"read what it says"
};
if ui.button(label).clicked() {
self.reading = !self.reading;
}
if ui.button("dismiss and delete it").clicked() {
crate::crashlog::clear();
self.report = None;
finished = true;
}
if just_copied {
ui.label(
RichText::new("copied, ready to paste")
.color(p::green())
.small(),
);
}
});
if self.reading {
ui.add_space(6.0);
// Scrolled and bounded: a panic message can carry a formatted
// value of any size, and this sits above the tab somebody is
// trying to use.
egui::ScrollArea::vertical()
.max_height(220.0)
.show(ui, |ui| {
ui.label(RichText::new(text).color(p::fg()).monospace().size(12.0));
});
}
if just_copied {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
}
finished
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The source of this file, up to its tests, so a test can check the code
/// rather than matching its own string literals.
fn source() -> &'static str {
let whole = include_str!("crashreport.rs");
whole.split("\n#[cfg(test)]").next().unwrap_or(whole)
}
/// The same, with comments removed.
///
/// The scan below looks for the machinery of sending something. The
/// comments at the top of this file explain at length why there is none,
/// and to do that they have to use the words: the first run of that test
/// failed on the sentence "a crash reporter that uploads". A check that
/// forbids describing the thing it forbids is a check that punishes
/// writing the explanation down.
fn code() -> String {
source()
.lines()
.filter(|line| {
let trimmed = line.trim_start();
!trimmed.starts_with("//")
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn nothing_is_offered_when_nothing_crashed() {
let mut offer = Offer::default();
assert!(!offer.waiting(), "a fresh offer has nothing to show");
// `look` is what reads the disk; before it, `waiting` must still be
// honest rather than optimistic.
offer.looked = true;
assert!(!offer.waiting());
}
#[test]
fn the_disk_is_read_once_and_not_every_frame() {
let mut offer = Offer::default();
offer.look();
assert!(offer.looked, "the first look must record that it looked");
// A second look changes nothing, which is what stops a launch with no
// report from stat-ing a file sixty times a second.
let before = offer.report.is_some();
offer.look();
assert_eq!(offer.report.is_some(), before);
}
#[test]
fn the_offer_names_the_project_and_not_a_third_party() {
// A crash reporter that points somewhere other than this repository is
// sending somebody's report to a stranger.
assert!(NEW_ISSUE.starts_with("https://github.com/tilas01/veilvoice/"));
assert!(ISSUES.starts_with("https://github.com/tilas01/veilvoice/"));
}
#[test]
fn nothing_here_transmits_anything() {
// The panel offers a clipboard and a browser link. If a request ever
// appears in this file it is a change to what the project claims about
// itself, and it should fail here first.
let text = code();
for forbidden in ["reqwest", "ureq", "TcpStream", "http://", "post(", "upload"] {
assert!(
!text.contains(forbidden),
"this file must not learn to send anything: found {forbidden}"
);
}
}
#[test]
fn the_panel_says_what_the_report_holds_before_offering_it() {
// "Would you like to send this" is only a real question if the person
// can see what "this" is, so the list of contents and the way to read
// the whole file are both required.
let text = source();
assert!(text.contains("no passphrase"), "must say what is not in it");
assert!(
text.contains("read what it says"),
"must offer the whole text"
);
assert!(
text.contains("has been sent nowhere"),
"must say it was not sent"
);
}
}