authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 15:40:04-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 15:45:52-07:00
log8e66d05cf42adad1486ec803fbc07da2bd880289
tree8e569d7f4da4d3394dc66b6a1382166006c59d14
parentfcec92e2decf2a994e4c6754a59ff7cfb4e25004
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: a crash leaves a report the next launch offers to send

A panic writes the build, system, renderer, thread, uptime, message and backtrace beside the settings, paths and the open notebooks' and sections' names hidden; the browser keeps it in local storage. The next launch asks Send Report, Show Report or Don't Send, and posts to the site's /crash only on Send, through the updater's proxy and roots. Each platform's dialogs gain a choice of buttons, which confirm now rides on. Debug builds crash on SNOWBOUND_CRASH and send to SNOWBOUND_CRASH_SITE. Assisted-by: claude-opus-5.5

17 files changed, 622 insertions(+), 136 deletions(-)

arc/platforms.md+6
...@@ -30,6 +30,12 @@ Options' Renderer, the settings' `renderer`, `SNOWBOUND_RENDERER` or `--renderer...@@ -30,6 +30,12 @@ Options' Renderer, the settings' `renderer`, `SNOWBOUND_RENDERER` or `--renderer
30(each over the last) picks one, and one that fails to start falls back to the30(each over the last) picks one, and one that fails to start falls back to the
31default and says so. Choosing another in Options starts it at once: the renderer and31default and says so. Choosing another in Options starts it at once: the renderer and
32surface go and new ones begin, the window and everything in it staying as they are.32surface go and new ones begin, the window and everything in it staying as they are.
33A panic writes a crash report beside the settings before the process ends (`crash.rs`):
34the build, system, renderer, thread, uptime, panic and backtrace, with paths and the
35open notebooks' and sections' names hidden; the browser keeps it in local storage. The next
36launch asks whether to send it to the site's `POST /crash`, shows the exact text on Show
37Report, and sends nothing unless Send Report is chosen; either answer deletes it. A run
38with no settings of its own, as a screenshot or replay, writes and reads none.
33`commands.rs` is the one table of commands: each one's title, its chords on39`commands.rs` is the one table of commands: each one's title, its chords on
34macOS and elsewhere, when it is enabled or checked, and what it does. The40macOS and elsewhere, when it is enabled or checked, and what it does. The
35keyboard, the toolbar and the macOS menu bar all run commands from it.41keyboard, the toolbar and the macOS menu bar all run commands from it.
crates/snowbound/Cargo.toml+1-1
...@@ -57,7 +57,7 @@ spellbook = "0.4"...@@ -57,7 +57,7 @@ spellbook = "0.4"
57wasm-bindgen = "0.2"57wasm-bindgen = "0.2"
58wasm-bindgen-futures = "0.4"58wasm-bindgen-futures = "0.4"
59js-sys = "0.3"59js-sys = "0.3"
60web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Node", "Window"] }60web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Node", "Storage", "Window"] }
6161
62[target.'cfg(target_os = "macos")'.dependencies]62[target.'cfg(target_os = "macos")'.dependencies]
63libc = "0.2"63libc = "0.2"
crates/snowbound/src/crash.rs created+415
...@@ -0,0 +1,415 @@
1//! Crash reports. A panic writes a report beside the settings before the process ends; the
2//! next launch asks whether to send it to the site's `POST /crash`, and sends nothing unless
3//! the person chooses Send Report. A report holds the build, the system, the renderer, the
4//! panic and its backtrace, with paths and the names of notebooks and sections hidden.
5
6use std::path::PathBuf;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Mutex, OnceLock};
9use web_time::Instant;
10
11/// Where a panic writes its report: beside the settings file the launch saves, so runs with
12/// settings of their own, and automated runs, never touch the account's.
13pub static REPORT: OnceLock<PathBuf> = OnceLock::new();
14/// The backend drawing and its adapter, as "Metal (Apple M2)".
15pub static RENDERER: Mutex<String> = Mutex::new(String::new());
16/// The notebooks, section groups and sections opened this run, which a report hides.
17static NAMES: Mutex<Vec<String>> = Mutex::new(Vec::new());
18static STARTED: OnceLock<Instant> = OnceLock::new();
19/// The first panic's report is kept; the ones it sets off would only hide it.
20static KEPT: AtomicBool = AtomicBool::new(false);
21
22const ADDRESS: &str = "https://snowbound.paperclover.net/crash";
23/// Bounds on what a panic adds, so a report stays well under what the site takes.
24const MESSAGE: usize = 2 << 10;
25const FRAMES: usize = 48;
26const BACKTRACE: usize = 32 << 10;
27const NAMED: usize = 256;
28
29/// Reports each panic on `system`, then hands the report to `then` to log.
30pub fn hook(system: String, then: impl Fn(&str) + Send + Sync + 'static) {
31 STARTED.get_or_init(Instant::now);
32 let home = home();
33 std::panic::set_hook(Box::new(move |info| {
34 let message = info
35 .payload_as_str()
36 .unwrap_or("Box<dyn Any>")
37 .chars()
38 .take(MESSAGE)
39 .collect::<String>();
40 let at = info.location().map(ToString::to_string).unwrap_or_default();
41 // Another thread may hold the lock, or this one may have panicked holding it.
42 let names = NAMES
43 .try_lock()
44 .map(|names| names.clone())
45 .unwrap_or_default();
46 let renderer = RENDERER
47 .try_lock()
48 .map(|name| name.clone())
49 .unwrap_or_default();
50 let thread = std::thread::current();
51 let report = format!(
52 "Snowbound {}, {}\nSystem: {system}\nRenderer: {renderer}\nThread: {}\nUptime: {} s\n\
53 Panic: {}\nAt: {}\n\nBacktrace:\n{}",
54 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
55 crate::update::platform(),
56 thread.name().unwrap_or("unnamed"),
57 STARTED
58 .get()
59 .map_or(0, |started| started.elapsed().as_secs()),
60 scrub(&message, &home, &names),
61 scrub(&at, &home, &[]),
62 scrub(&frames(&backtrace()), &home, &[]),
63 );
64 if !KEPT.swap(true, Ordering::Relaxed) {
65 keep(&report);
66 }
67 then(&report);
68 }));
69}
70
71/// Hides `path`'s names, a notebook's or a section's within it, in reports from now on.
72pub fn conceal(path: &str) {
73 let Ok(mut names) = NAMES.lock() else {
74 return;
75 };
76 for name in path.split(['/', '\\']) {
77 let name = [".onetoc2", ".onepkg", ".one"]
78 .iter()
79 .find_map(|extension| name.strip_suffix(extension))
80 .unwrap_or(name);
81 // Shorter names would hide parts of ordinary words.
82 if name.chars().count() >= 3 && names.len() < NAMED && !names.iter().any(|n| n == name) {
83 names.push(name.to_owned());
84 }
85 }
86 // Longer first, so a name holding another is hidden whole.
87 names.sort_by_key(|name| std::cmp::Reverse(name.len()));
88}
89
90/// `text` with `home` as `~`, `names` as `<name>`, and every path but a source file's as
91/// `<path>`.
92fn scrub(text: &str, home: &str, names: &[String]) -> String {
93 let mut text = if home.len() > 1 {
94 text.replace(home, "~")
95 } else {
96 text.to_owned()
97 };
98 for name in names {
99 text = text.replace(name.as_str(), "<name>");
100 }
101 hide_paths(&text)
102}
103
104fn hide_paths(text: &str) -> String {
105 let mut hidden = String::with_capacity(text.len());
106 let mut rest = text;
107 let mut previous = None;
108 while let Some(next) = rest.chars().next() {
109 let begins = matches!(
110 previous,
111 None | Some(' ' | '\t' | '\n' | '"' | '\'' | '`' | '(' | '[' | '{' | '=' | ',' | ':')
112 ) && (rest.starts_with('/')
113 || rest.starts_with("~/")
114 || rest.starts_with("~\\")
115 || rest.starts_with("\\\\")
116 || rest.get(1..3) == Some(":\\") && next.is_ascii_alphabetic());
117 if !begins {
118 hidden.push(next);
119 previous = Some(next);
120 rest = &rest[next.len_utf8()..];
121 continue;
122 }
123 let word = &rest[..rest.find(char::is_whitespace).unwrap_or(rest.len())];
124 if word
125 .trim_end_matches(|c: char| c.is_ascii_digit() || matches!(c, ':' | ',' | ')'))
126 .ends_with(".rs")
127 {
128 hidden.push_str(word);
129 previous = word.chars().last();
130 rest = &rest[word.len()..];
131 continue;
132 }
133 // A quoted path runs to its quote, spaces and all; another to a break in the sentence.
134 let end = match previous {
135 Some(quote @ ('"' | '\'' | '`')) => rest.find([quote, '\n']),
136 _ => [": ", ", ", ")", "\n"]
137 .iter()
138 .filter_map(|stop| rest.find(stop))
139 .min(),
140 };
141 hidden.push_str("<path>");
142 previous = Some('>');
143 rest = &rest[end.unwrap_or(rest.len())..];
144 }
145 hidden
146}
147
148/// `backtrace`'s frames after the panic machinery's, at most `FRAMES`.
149fn frames(backtrace: &str) -> String {
150 let starts = |line: &str| {
151 let line = line.trim_start();
152 line.split_once(": ").is_some_and(|(number, _)| {
153 !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit())
154 })
155 };
156 let lines: Vec<&str> = backtrace.lines().collect();
157 let panicking = lines
158 .iter()
159 .rposition(|line| starts(line) && line.contains("panicking::"));
160 let mut kept = String::new();
161 let mut count = 0;
162 for line in &lines[panicking.map_or(0, |at| at + 1)..] {
163 if starts(line) {
164 // A system library's frame, which says nothing without its symbols.
165 if line.ends_with(": <unknown>") {
166 continue;
167 }
168 count += 1;
169 if count > FRAMES || kept.len() > BACKTRACE {
170 kept.push_str(" …\n");
171 break;
172 }
173 } else if count == 0 {
174 continue;
175 }
176 kept.push_str(line);
177 kept.push('\n');
178 }
179 kept
180}
181
182#[cfg(not(target_arch = "wasm32"))]
183fn backtrace() -> String {
184 std::backtrace::Backtrace::force_capture().to_string()
185}
186
187/// The browser's stack, as wasm32-unknown-unknown's std has no backtrace.
188#[cfg(target_arch = "wasm32")]
189fn backtrace() -> String {
190 let error = js_sys::Error::new("");
191 js_sys::Reflect::get(&error, &"stack".into())
192 .ok()
193 .and_then(|stack| stack.as_string())
194 .unwrap_or_default()
195 .lines()
196 .enumerate()
197 .map(|(number, line)| format!("{number:4}: {}\n", line.trim()))
198 .collect()
199}
200
201#[cfg(not(target_arch = "wasm32"))]
202fn home() -> String {
203 let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" });
204 home.map(|home| home.to_string_lossy().into_owned())
205 .unwrap_or_default()
206}
207
208#[cfg(target_arch = "wasm32")]
209fn home() -> String {
210 String::new()
211}
212
213#[cfg(not(target_arch = "wasm32"))]
214fn keep(report: &str) {
215 if let Some(path) = REPORT.get() {
216 if let Some(folder) = path.parent() {
217 let _ = std::fs::create_dir_all(folder);
218 }
219 let _ = std::fs::write(path, report);
220 }
221}
222
223#[cfg(not(target_arch = "wasm32"))]
224fn kept() -> Option<String> {
225 std::fs::read_to_string(REPORT.get()?).ok()
226}
227
228#[cfg(not(target_arch = "wasm32"))]
229fn forget() {
230 if let Some(path) = REPORT.get() {
231 let _ = std::fs::remove_file(path);
232 }
233}
234
235#[cfg(not(target_arch = "wasm32"))]
236fn send(report: String) {
237 // A development build may send to a site run locally.
238 let address = std::env::var("SNOWBOUND_CRASH_SITE")
239 .ok()
240 .filter(|_| cfg!(debug_assertions))
241 .unwrap_or_else(|| ADDRESS.to_owned());
242 std::thread::spawn(move || {
243 let sent = crate::update::agent(&address, std::time::Duration::from_secs(60))
244 .post(&address)
245 .header("Content-Type", "text/plain; charset=utf-8")
246 .send(report.as_bytes());
247 if let Err(error) = sent {
248 eprintln!("Cannot send the crash report: {error}");
249 }
250 });
251}
252
253/// The browser keeps the report in local storage, which a panic can still reach.
254#[cfg(target_arch = "wasm32")]
255const STORED: &str = "snowbound-crash";
256
257#[cfg(target_arch = "wasm32")]
258fn storage() -> Option<web_sys::Storage> {
259 web_sys::window()?.local_storage().ok()?
260}
261
262#[cfg(target_arch = "wasm32")]
263fn keep(report: &str) {
264 if let Some(storage) = storage() {
265 let _ = storage.set_item(STORED, report);
266 }
267}
268
269#[cfg(target_arch = "wasm32")]
270fn kept() -> Option<String> {
271 storage()?.get_item(STORED).ok()?
272}
273
274#[cfg(target_arch = "wasm32")]
275fn forget() {
276 if let Some(storage) = storage() {
277 let _ = storage.remove_item(STORED);
278 }
279}
280
281#[cfg(target_arch = "wasm32")]
282fn send(report: String) {
283 crate::platform::send_crash(&report);
284}
285
286impl crate::State {
287 /// Asks whether to send the report the last run left, if it left one.
288 pub(crate) fn offer_crash_report(&self) {
289 let Some(report) = kept() else {
290 return;
291 };
292 let shown = report.clone();
293 let reply = self.reply(move |state, pressed| {
294 match pressed {
295 0 => send(report),
296 1 => state.show_crash_report(shown),
297 _ => {}
298 }
299 if pressed != 1 {
300 forget();
301 }
302 Ok(())
303 });
304 crate::platform::choose(
305 "Snowbound quit unexpectedly last time.",
306 "Send a report to help fix the problem. It holds no notes or file names.",
307 &["Send Report", "Show Report", "Don't Send"],
308 reply,
309 );
310 }
311
312 fn show_crash_report(&self, report: String) {
313 let shown = report.clone();
314 let reply = self.reply(move |_, pressed| {
315 if pressed == 0 {
316 send(report);
317 }
318 forget();
319 Ok(())
320 });
321 crate::platform::choose(
322 "Crash report",
323 &shown,
324 &["Send Report", "Don't Send"],
325 reply,
326 );
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn reports_hide_the_home_folder_paths_and_names() {
336 let names = vec!["Work Notes".to_owned(), "Meetings".to_owned()];
337 let scrub = |text| scrub(text, "/Users/ada", &names);
338 assert_eq!(
339 scrub(
340 r#"called `Result::unwrap()` on an `Err` value: Io { path: "/Users/ada/OneNote Notebooks/Work Notes/To Do.one", kind: NotFound }"#
341 ),
342 r#"called `Result::unwrap()` on an `Err` value: Io { path: "<path>", kind: NotFound }"#
343 );
344 assert_eq!(
345 scrub("Cannot read /Volumes/Share/Shared Notes/a.one: denied"),
346 "Cannot read <path>: denied"
347 );
348 assert_eq!(
349 scrub(r"Cannot read C:\Users\ada\Notes\b.one, retrying"),
350 "Cannot read <path>, retrying"
351 );
352 assert_eq!(scrub("opening smb://nas/notes/x.one"), "opening smb:<path>");
353 assert_eq!(
354 scrub("section Meetings of Work Notes is locked"),
355 "section <name> of <name> is locked"
356 );
357 // Sources stay, the home folder as ~.
358 assert_eq!(
359 scrub("at /Users/ada/.cargo/registry/src/winit-0.30/src/lib.rs:12:5"),
360 "at ~/.cargo/registry/src/winit-0.30/src/lib.rs:12:5"
361 );
362 assert_eq!(
363 scrub("index out of bounds: the len is 3 but the index is 5"),
364 "index out of bounds: the len is 3 but the index is 5"
365 );
366 }
367
368 #[test]
369 fn names_are_kept_from_a_sections_path() {
370 conceal("Projects/Snow Plan.one");
371 let names = NAMES.lock().unwrap().clone();
372 assert!(names.contains(&"Projects".to_owned()), "{names:?}");
373 assert!(names.contains(&"Snow Plan".to_owned()), "{names:?}");
374 assert!(!names.iter().any(|name| name.ends_with(".one")));
375 }
376
377 #[test]
378 fn backtraces_start_past_the_panic() {
379 let backtrace = " 0: std::backtrace::Backtrace::force_capture
380 1: snowbound::crash::hook::{{closure}}
381 at ./src/crash.rs:30:9
382 2: std::panicking::rust_panic_with_hook
383 3: core::panicking::panic_fmt
384 4: snowbound::State::frame
385 at ./src/main.rs:1500:13
386 5: <unknown>
387 6: main
388";
389 assert_eq!(
390 frames(backtrace),
391 " 4: snowbound::State::frame\n at ./src/main.rs:1500:13\n 6: main\n"
392 );
393 let deep: String = (0..100)
394 .map(|frame| format!("{frame:4}: f{frame}\n"))
395 .collect();
396 let kept = frames(&deep);
397 assert_eq!(kept.lines().count(), FRAMES + 1);
398 assert!(kept.ends_with("…\n"));
399 }
400
401 /// A report written by a panic is what the next launch finds, until it is forgotten.
402 #[test]
403 fn a_report_outlives_the_run_until_answered() {
404 let folder = std::env::temp_dir().join(format!("snowbound-crash-{}", std::process::id()));
405 let _ = REPORT.set(folder.join("crash.txt"));
406 let report = REPORT.get().unwrap();
407 let _ = std::fs::remove_file(report);
408 assert_eq!(kept(), None);
409 keep("Snowbound development\nPanic: x");
410 assert_eq!(kept().as_deref(), Some("Snowbound development\nPanic: x"));
411 forget();
412 assert_eq!(kept(), None);
413 std::fs::remove_dir_all(folder).unwrap();
414 }
415}
crates/snowbound/src/desktop_linux.rs+1-1
...@@ -488,7 +488,7 @@ pub fn uninstall(proxy: &winit::event_loop::EventLoopProxy<crate::UserEvent>) {...@@ -488,7 +488,7 @@ pub fn uninstall(proxy: &winit::event_loop::EventLoopProxy<crate::UserEvent>) {
488 remove();488 remove();
489 Ok(())489 Ok(())
490 });490 });
491 crate::platform::confirm(491 crate::confirm(
492 "Uninstall Snowbound?",492 "Uninstall Snowbound?",
493 &detail,493 &detail,
494 "Cancel",494 "Cancel",
crates/snowbound/src/dialog_linux.rs+31-22
...@@ -11,11 +11,10 @@ use winit::keyboard::NamedKey;...@@ -11,11 +11,10 @@ use winit::keyboard::NamedKey;
11pub enum Ask {11pub enum Ask {
12 /// A message with an OK button.12 /// A message with an OK button.
13 Message,13 Message,
14 /// Whether to go ahead: buttons for `cancel` and for `action`.14 /// A button for each of `buttons`, the last the safe answer; replies with the one pressed.
15 Question {15 Choice {
16 cancel: String,16 buttons: Vec<String>,
17 action: String,17 reply: Reply<usize>,
18 reply: Reply<()>,
19 },18 },
20 /// A line of text, starting as `text`.19 /// A line of text, starting as `text`.
21 Entry { text: String, reply: Reply<String> },20 Entry { text: String, reply: Reply<String> },
...@@ -109,6 +108,8 @@ fn field() -> Id {...@@ -109,6 +108,8 @@ fn field() -> Id {
109enum Answer {108enum Answer {
110 Cancel,109 Cancel,
111 Accept,110 Accept,
111 /// A choice's button.
112 Pressed(usize),
112 /// A file chooser moves to this folder.113 /// A file chooser moves to this folder.
113 Open(PathBuf),114 Open(PathBuf),
114 /// A file chooser's file to open, or the name to save as.115 /// A file chooser's file to open, or the name to save as.
...@@ -182,10 +183,10 @@ impl State {...@@ -182,10 +183,10 @@ impl State {
182 pad: [6.0, 0.0],183 pad: [6.0, 0.0],
183 ..Spec::default()184 ..Spec::default()
184 };185 };
185 // Return goes ahead, but for a question, whose safe answer is the default as186 // Return goes ahead, but for a choice, whose safe answer is the default as AppKit's
186 // AppKit's and Windows' are.187 // and Windows' are.
187 let mut answer = entered.then_some(match dialog.ask {188 let mut answer = entered.then_some(match &dialog.ask {
188 Ask::Question { .. } => Answer::Cancel,189 Ask::Choice { buttons, .. } => Answer::Pressed(buttons.len() - 1),
189 _ => Answer::Accept,190 _ => Answer::Accept,
190 });191 });
191 match &mut dialog.ask {192 match &mut dialog.ask {
...@@ -260,7 +261,7 @@ impl State {...@@ -260,7 +261,7 @@ impl State {
260 crate::name(ui, field(), "Name");261 crate::name(ui, field(), "Name");
261 }262 }
262 }263 }
263 Ask::Message | Ask::Question { .. } => {}264 Ask::Message | Ask::Choice { .. } => {}
264 }265 }
265 ui.open(266 ui.open(
266 "buttons",267 "buttons",
...@@ -284,18 +285,22 @@ impl State {...@@ -284,18 +285,22 @@ impl State {
284 ..Spec::default()285 ..Spec::default()
285 },286 },
286 );287 );
287 let (cancel, action) = match &dialog.ask {288 // Left to right, the safe answer first, as GNOME and KDE place Cancel.
288 Ask::Message => (None, "OK"),289 let buttons: Vec<(&str, Answer)> = match &dialog.ask {
289 Ask::Question { cancel, action, .. } => (Some(cancel.as_str()), action.as_str()),290 Ask::Message => vec![("OK", Answer::Accept)],
290 Ask::Entry { .. } => (Some("Cancel"), "OK"),291 Ask::Choice { buttons, .. } => (buttons.iter().enumerate().rev())
291 Ask::File { save: false, .. } => (Some("Cancel"), "Open"),292 .map(|(index, button)| (button.as_str(), Answer::Pressed(index)))
292 Ask::File { save: true, .. } => (Some("Cancel"), "Save"),293 .collect(),
294 Ask::Entry { .. } => vec![("Cancel", Answer::Cancel), ("OK", Answer::Accept)],
295 Ask::File { save, .. } => vec![
296 ("Cancel", Answer::Cancel),
297 (if *save { "Save" } else { "Open" }, Answer::Accept),
298 ],
293 };299 };
294 if cancel.is_some_and(|cancel| ui::button(ui, "cancel", cancel).clicked) {300 for (index, (button, pressed)) in buttons.into_iter().enumerate() {
295 answer = Some(Answer::Cancel);301 if ui::button(ui, ("button", index), button).clicked {
296 }302 answer = Some(pressed);
297 if ui::button(ui, "action", action).clicked {303 }
298 answer = Some(Answer::Accept);
299 }304 }
300 ui.close();305 ui.close();
301 ui.close();306 ui.close();
...@@ -343,7 +348,11 @@ impl State {...@@ -343,7 +348,11 @@ impl State {
343 }348 }
344 match dialog.ask {349 match dialog.ask {
345 Ask::Message => {}350 Ask::Message => {}
346 Ask::Question { reply, .. } => reply.send(()),351 Ask::Choice { reply, .. } => {
352 if let Answer::Pressed(index) = answer {
353 reply.send(index);
354 }
355 }
347 Ask::Entry { text, reply } => reply.send(text),356 Ask::Entry { text, reply } => reply.send(text),
348 Ask::File {357 Ask::File {
349 folder,358 folder,
crates/snowbound/src/history.rs+1-1
...@@ -208,7 +208,7 @@ impl State {...@@ -208,7 +208,7 @@ impl State {
208 Scope::Notebook => ("notebook", session.library.name.clone()),208 Scope::Notebook => ("notebook", session.library.name.clone()),
209 };209 };
210 let asked = Arc::clone(&session.library);210 let asked = Arc::clone(&session.library);
211 platform::confirm(211 crate::confirm(
212 &format!("Do you want to delete all page versions in the {container} \"{name}\"?"),212 &format!("Do you want to delete all page versions in the {container} \"{name}\"?"),
213 "You can't restore these versions afterward.",213 "You can't restore these versions afterward.",
214 "Cancel",214 "Cancel",
crates/snowbound/src/library.rs+2
...@@ -341,6 +341,7 @@ pub struct Keys(Mutex<std::collections::HashMap<[u8; 16], (Key, Instant)>>);...@@ -341,6 +341,7 @@ pub struct Keys(Mutex<std::collections::HashMap<[u8; 16], (Key, Instant)>>);
341impl Library {341impl Library {
342 /// `location` named `name`, with no notebook read, server, sync or kept sections.342 /// `location` named `name`, with no notebook read, server, sync or kept sections.
343 fn new(location: &str, name: String, cache: &Path) -> Self {343 fn new(location: &str, name: String, cache: &Path) -> Self {
344 crate::crash::conceal(&name);
344 Self {345 Self {
345 location: location.to_owned(),346 location: location.to_owned(),
346 name,347 name,
...@@ -807,6 +808,7 @@ impl Library {...@@ -807,6 +808,7 @@ impl Library {
807 notify: Box<dyn Fn() + Send>,808 notify: Box<dyn Fn() + Send>,
808 deadline: Instant,809 deadline: Instant,
809 ) -> Result<Section, Box<dyn Error>> {810 ) -> Result<Section, Box<dyn Error>> {
811 crate::crash::conceal(path);
810 let notify = Arc::new(Mutex::new(notify));812 let notify = Arc::new(Mutex::new(notify));
811 let notifier = || {813 let notifier = || {
812 let notify = Arc::clone(&notify);814 let notify = Arc::clone(&notify);
crates/snowbound/src/linux.rs+22-24
...@@ -828,15 +828,18 @@ fn log_crashes() {...@@ -828,15 +828,18 @@ fn log_crashes() {
828 let field =828 let field =
829 |field: &[libc::c_char]| unsafe { CStr::from_ptr(field.as_ptr()) }.to_string_lossy();829 |field: &[libc::c_char]| unsafe { CStr::from_ptr(field.as_ptr()) }.to_string_lossy();
830 let session = |name| std::env::var(name).unwrap_or_default();830 let session = |name| std::env::var(name).unwrap_or_default();
831 let _ = writeln!(831 let described = format!(
832 log,832 "Linux {} {}, {} {}",
833 "Snowbound {} on Linux {} {}, {} {}",
834 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
835 field(&system.release),833 field(&system.release),
836 field(&system.machine),834 field(&system.machine),
837 session("XDG_SESSION_TYPE"),835 session("XDG_SESSION_TYPE"),
838 session("XDG_CURRENT_DESKTOP"),836 session("XDG_CURRENT_DESKTOP"),
839 );837 );
838 let _ = writeln!(
839 log,
840 "Snowbound {} on {described}",
841 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
842 );
840 let mut stderr: libc::stat = unsafe { std::mem::zeroed() };843 let mut stderr: libc::stat = unsafe { std::mem::zeroed() };
841 let seen = unsafe { libc::isatty(2) } == 1844 let seen = unsafe { libc::isatty(2) } == 1
842 || unsafe { libc::fstat(2, &mut stderr) } == 0845 || unsafe { libc::fstat(2, &mut stderr) } == 0
...@@ -848,15 +851,9 @@ fn log_crashes() {...@@ -848,15 +851,9 @@ fn log_crashes() {
848 None851 None
849 };852 };
850 let _ = LOG.set((path, copy));853 let _ = LOG.set((path, copy));
851 std::panic::set_hook(Box::new(|info| {854 crate::crash::hook(described, |report| {
852 let thread = std::thread::current();
853 let backtrace = std::backtrace::Backtrace::force_capture();
854 let report = format!(
855 "Thread {:?} {info}\n{backtrace}\n",
856 thread.name().unwrap_or("")
857 );
858 crashed(|fd| write_all(fd, report.as_bytes()));855 crashed(|fd| write_all(fd, report.as_bytes()));
859 }));856 });
860 if let Ok(path) = crate::loader::executable()857 if let Ok(path) = crate::loader::executable()
861 && let Ok(path) = CString::new(path.into_os_string().into_vec())858 && let Ok(path) = CString::new(path.into_os_string().into_vec())
862 {859 {
...@@ -1595,7 +1592,7 @@ pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) {...@@ -1595,7 +1592,7 @@ pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) {
1595 options.insert("filters", filters(&patterns, &globs));1592 options.insert("filters", filters(&patterns, &globs));
1596 }1593 }
1597 let types = types.iter().map(|kind| kind.to_string()).collect();1594 let types = types.iter().map(|kind| kind.to_string()).collect();
1598 choose(1595 pick(
1599 title,1596 title,
1600 "OpenFile",1597 "OpenFile",
1601 options,1598 options,
...@@ -1618,7 +1615,7 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> {...@@ -1618,7 +1615,7 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> {
16181615
1619/// Replies with the file the file chooser portal answers `method` with, titled `title`, or1616/// Replies with the file the file chooser portal answers `method` with, titled `title`, or
1620/// where no portal answers, Snowbound's `own`.1617/// where no portal answers, Snowbound's `own`.
1621fn choose(1618fn pick(
1622 title: &str,1619 title: &str,
1623 method: &'static str,1620 method: &'static str,
1624 mut options: HashMap<&'static str, Value<'static>>,1621 mut options: HashMap<&'static str, Value<'static>>,
...@@ -1747,14 +1744,15 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {...@@ -1747,14 +1744,15 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
1747 }1744 }
1748}1745}
17491746
1750/// Asks whether to go ahead with `action`, offering `cancel` first.1747/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies
1751pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {1748/// with the one pressed.
1752 let ask = Ask::Question {1749pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply<usize>) {
1753 cancel: cancel.into(),1750 let buttons = buttons.iter().map(|&button| button.to_owned()).collect();
1754 action: action.into(),1751 crate::dialog::show(
1755 reply,1752 message.into(),
1756 };1753 detail.into(),
1757 crate::dialog::show(message.into(), detail.into(), ask);1754 Ask::Choice { buttons, reply },
1755 );
1758}1756}
17591757
1760/// Asks for a notebook's table of contents or a section file, titled `title`.1758/// Asks for a notebook's table of contents or a section file, titled `title`.
...@@ -1762,7 +1760,7 @@ pub fn pick_notebook(title: &str, reply: Reply<PathBuf>) {...@@ -1762,7 +1760,7 @@ pub fn pick_notebook(title: &str, reply: Reply<PathBuf>) {
1762 let name = "OneNote notebooks, sections and packages";1760 let name = "OneNote notebooks, sections and packages";
1763 let types = ["onetoc2", "one", "onepkg"];1761 let types = ["onetoc2", "one", "onepkg"];
1764 let globs = types.map(|kind| format!("*.{kind}"));1762 let globs = types.map(|kind| format!("*.{kind}"));
1765 choose(1763 pick(
1766 title,1764 title,
1767 "OpenFile",1765 "OpenFile",
1768 HashMap::from([("filters", filters(name, &globs))]),1766 HashMap::from([("filters", filters(name, &globs))]),
...@@ -1800,7 +1798,7 @@ pub fn pick_new(...@@ -1800,7 +1798,7 @@ pub fn pick_new(
1800 name.to_owned(),1798 name.to_owned(),
1801 folder.map_or_else(crate::dialog::start_folder, Into::into),1799 folder.map_or_else(crate::dialog::start_folder, Into::into),
1802 );1800 );
1803 choose(1801 pick(
1804 title,1802 title,
1805 "SaveFile",1803 "SaveFile",
1806 options,1804 options,
crates/snowbound/src/macos.rs+23-12
...@@ -9,10 +9,9 @@ use objc2::{...@@ -9,10 +9,9 @@ use objc2::{
9 sel,9 sel,
10};10};
11use objc2_app_kit::{11use objc2_app_kit::{
12 NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor,12 NSAlert, NSAlertFirstButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker,
13 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent,13 NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventSubtype, NSEventType, NSMenu,
14 NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel,14 NSMenuItem, NSModalResponse, NSModalResponseCancel, NSModalResponseOK, NSSavePanel, NSWindow,
15 NSModalResponseOK, NSSavePanel, NSWindow,
16};15};
17use objc2_foundation::{16use objc2_foundation::{
18 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,17 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
...@@ -33,7 +32,14 @@ use winit::{...@@ -33,7 +32,14 @@ use winit::{
33/// Runs `run` inside an autorelease pool. 10.6 has none outside NSApplication's run loop,32/// Runs `run` inside an autorelease pool. 10.6 has none outside NSApplication's run loop,
34/// and quitting releases the windows after it returns.33/// and quitting releases the windows after it returns.
35pub fn with_pool<R>(run: impl FnOnce() -> R) -> R {34pub fn with_pool<R>(run: impl FnOnce() -> R) -> R {
36 objc2::rc::autoreleasepool(|_| run())35 objc2::rc::autoreleasepool(|_| {
36 let version: Retained<NSString> = unsafe {
37 let info: Retained<AnyObject> = msg_send_id![class!(NSProcessInfo), processInfo];
38 msg_send_id![&info, operatingSystemVersionString]
39 };
40 crate::crash::hook(format!("macOS {version}"), |report| eprint!("{report}"));
41 run()
42 })
37}43}
3844
39pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface};45pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface};
...@@ -1648,18 +1654,23 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {...@@ -1648,18 +1654,23 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
1648 }1654 }
1649}1655}
16501656
1651/// Asks whether to go ahead with `action`, offering `cancel` first.1657/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies
1652pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {1658/// with the one pressed. AppKit places the last first, on Return.
1659pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply<usize>) {
1653 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");1660 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
1661 let count = buttons.len();
1654 unsafe {1662 unsafe {
1655 let alert = NSAlert::new(mtm);1663 let alert = NSAlert::new(mtm);
1656 alert.setMessageText(&NSString::from_str(message));1664 alert.setMessageText(&NSString::from_str(message));
1657 alert.setInformativeText(&NSString::from_str(detail));1665 alert.setInformativeText(&NSString::from_str(detail));
1658 alert.addButtonWithTitle(&NSString::from_str(cancel));1666 for button in buttons.iter().rev() {
1659 alert.addButtonWithTitle(&NSString::from_str(action));1667 alert.addButtonWithTitle(&NSString::from_str(button));
1660 begin_alert(&alert, |response| {1668 }
1661 if response == NSAlertSecondButtonReturn {1669 begin_alert(&alert, move |response| {
1662 reply.send(());1670 if let Ok(added) = usize::try_from(response - NSAlertFirstButtonReturn)
1671 && added < count
1672 {
1673 reply.send(count - 1 - added);
1663 }1674 }
1664 });1675 });
1665 }1676 }
crates/snowbound/src/main.rs+29-3
...@@ -9,6 +9,7 @@ mod background;...@@ -9,6 +9,7 @@ mod background;
9mod commands;9mod commands;
10#[cfg(all(test, feature = "wgpu", not(windows)))]10#[cfg(all(test, feature = "wgpu", not(windows)))]
11mod conflict_render;11mod conflict_render;
12mod crash;
12#[cfg(target_os = "linux")]13#[cfg(target_os = "linux")]
13#[path = "desktop_linux.rs"]14#[path = "desktop_linux.rs"]
14mod desktop;15mod desktop;
...@@ -265,6 +266,16 @@ impl<T: Send + 'static> Reply<T> {...@@ -265,6 +266,16 @@ impl<T: Send + 'static> Reply<T> {
265 }266 }
266}267}
267268
269/// Asks whether to go ahead with `action`, offering `cancel`, the default, too.
270fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {
271 let pressed = Reply(Box::new(move |pressed| {
272 if pressed == 0 {
273 reply.send(());
274 }
275 }));
276 platform::choose(message, detail, &[action, cancel], pressed);
277}
278
268enum UserEvent {279enum UserEvent {
269 Quit,280 Quit,
270 /// Quits without asking, as after the user agreed to discard a temporary page.281 /// Quits without asking, as after the user agreed to discard a temporary page.
...@@ -1035,6 +1046,9 @@ async fn start_surface(...@@ -1035,6 +1046,9 @@ async fn start_surface(
1035 match surface::Surface::new(window.clone(), backdrop, backend).await {1046 match surface::Surface::new(window.clone(), backdrop, backend).await {
1036 Ok((surface, renderer, adapter)) => {1047 Ok((surface, renderer, adapter)) => {
1037 note(&format!("Drawing with {} ({adapter})", backend.label()));1048 note(&format!("Drawing with {} ({adapter})", backend.label()));
1049 if let Ok(mut renderer) = crash::RENDERER.lock() {
1050 *renderer = format!("{} ({adapter})", backend.label());
1051 }
1038 unavailable.retain(|(failed, _)| *failed != backend);1052 unavailable.retain(|(failed, _)| *failed != backend);
1039 return Ok((surface, renderer, (backend, adapter)));1053 return Ok((surface, renderer, (backend, adapter)));
1040 }1054 }
...@@ -6351,7 +6365,7 @@ impl App {...@@ -6351,7 +6365,7 @@ impl App {
6351 }) else {6365 }) else {
6352 return self.exit(event_loop);6366 return self.exit(event_loop);
6353 };6367 };
6354 platform::confirm(6368 crate::confirm(
6355 "Discard this page?",6369 "Discard this page?",
6356 "This temporary page has no saved copy. Closing it will discard your edits.",6370 "This temporary page has no saved copy. Closing it will discard your edits.",
6357 "Keep Editing",6371 "Keep Editing",
...@@ -6436,6 +6450,14 @@ impl ApplicationHandler<UserEvent> for App {...@@ -6436,6 +6450,14 @@ impl ApplicationHandler<UserEvent> for App {
6436 for path in self.opening.drain(..) {6450 for path in self.opening.drain(..) {
6437 state.open_path(&path);6451 state.open_path(&path);
6438 }6452 }
6453 state.offer_crash_report();
6454 if cfg!(debug_assertions) && std::env::var_os("SNOWBOUND_CRASH").is_some() {
6455 let open = state.session.as_ref().map(|session| &session.library);
6456 panic!(
6457 "SNOWBOUND_CRASH asked to crash with {:?} open",
6458 open.map(|library| &library.location)
6459 );
6460 }
6439 self.state = Some(state);6461 self.state = Some(state);
6440 }6462 }
6441 },6463 },
...@@ -6948,12 +6970,16 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -6948,12 +6970,16 @@ fn launch() -> Result<(), Box<dyn Error>> {
6948 event_loop.create_proxy(),6970 event_loop.create_proxy(),
6949 )?;6971 )?;
6950 }6972 }
6973 // A screenshot leaves the settings as it found them.
6974 let settings_file = settings_file.filter(|_| screenshot.is_none());
6975 if let Some(file) = &settings_file {
6976 let _ = crash::REPORT.set(file.with_file_name("crash.txt"));
6977 }
6951 let mut app = App {6978 let mut app = App {
6952 proxy: event_loop.create_proxy(),6979 proxy: event_loop.create_proxy(),
6953 input: Some(input),6980 input: Some(input),
6954 // A screenshot leaves the settings as it found them.
6955 launch: Some(settings::Launch {6981 launch: Some(settings::Launch {
6956 file: settings_file.filter(|_| screenshot.is_none()),6982 file: settings_file,
6957 saved,6983 saved,
6958 renderer,6984 renderer,
6959 cache,6985 cache,
crates/snowbound/src/manage.rs+1-1
...@@ -593,7 +593,7 @@ impl State {...@@ -593,7 +593,7 @@ impl State {
593 };593 };
594 detail += &format!(" {changes} not yet saved to its files will be lost.");594 detail += &format!(" {changes} not yet saved to its files will be lost.");
595 }595 }
596 platform::confirm(596 crate::confirm(
597 &format!("Delete “{}”?", library.name),597 &format!("Delete “{}”?", library.name),
598 &detail,598 &detail,
599 "Cancel",599 "Cancel",
crates/snowbound/src/menus.rs+2-2
...@@ -867,7 +867,7 @@ impl State {...@@ -867,7 +867,7 @@ impl State {
867 }867 }
868 (Target::Section { library, path }, Action::Delete) => {868 (Target::Section { library, path }, Action::Delete) => {
869 let name = crate::library::entry_name(&path).to_owned();869 let name = crate::library::entry_name(&path).to_owned();
870 platform::confirm(870 crate::confirm(
871 "Are you sure you want to move this section to this notebook's Recycle Bin?",871 "Are you sure you want to move this section to this notebook's Recycle Bin?",
872 &name,872 &name,
873 "Cancel",873 "Cancel",
...@@ -882,7 +882,7 @@ impl State {...@@ -882,7 +882,7 @@ impl State {
882 }882 }
883 (Target::Group { library, path }, Action::Delete) => {883 (Target::Group { library, path }, Action::Delete) => {
884 let name = crate::library::entry_name(&path).to_owned();884 let name = crate::library::entry_name(&path).to_owned();
885 platform::confirm(885 crate::confirm(
886 "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?",886 "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?",
887 &name,887 &name,
888 "Cancel",888 "Cancel",
crates/snowbound/src/recycle.rs+2-2
...@@ -76,7 +76,7 @@ impl State {...@@ -76,7 +76,7 @@ impl State {
7676
77 /// Empty Recycle Bin on `library`, once confirmed as OneNote asks.77 /// Empty Recycle Bin on `library`, once confirmed as OneNote asks.
78 pub(crate) fn empty_recycle_bin(&mut self, library: Arc<Library>) {78 pub(crate) fn empty_recycle_bin(&mut self, library: Arc<Library>) {
79 crate::platform::confirm(79 crate::confirm(
80 "Are you sure you want to empty the Recycle Bin for this notebook?",80 "Are you sure you want to empty the Recycle Bin for this notebook?",
81 "Its pages and sections are deleted for good.",81 "Its pages and sections are deleted for good.",
82 "Cancel",82 "Cancel",
...@@ -95,7 +95,7 @@ impl State {...@@ -95,7 +95,7 @@ impl State {
95 return self.recycle_now(request);95 return self.recycle_now(request);
96 };96 };
97 let spaces = spaces.clone();97 let spaces = spaces.clone();
98 crate::platform::confirm(98 crate::confirm(
99 "Are you sure you want to delete this page for good?",99 "Are you sure you want to delete this page for good?",
100 "It can't be restored.",100 "It can't be restored.",
101 "Cancel",101 "Cancel",
crates/snowbound/src/update.rs+32-20
...@@ -47,7 +47,7 @@ fn running() -> Option<Version> {...@@ -47,7 +47,7 @@ fn running() -> Option<Version> {
4747
48/// This build's platform, as `latest.json` and `build.json` key their entries; Apple48/// This build's platform, as `latest.json` and `build.json` key their entries; Apple
49/// silicon's for an Intel build Rosetta runs.49/// silicon's for an Intel build Rosetta runs.
50fn platform() -> String {50pub(crate) fn platform() -> String {
51 if translated() {51 if translated() {
52 "macos-aarch64".to_owned()52 "macos-aarch64".to_owned()
53 } else if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") {53 } else if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") {
...@@ -561,6 +561,24 @@ fn download(_: &str, _: u64) -> Result<Vec<u8>, String> {...@@ -561,6 +561,24 @@ fn download(_: &str, _: u64) -> Result<Vec<u8>, String> {
561/// The `Fetch` the app uses.561/// The `Fetch` the app uses.
562#[cfg(not(target_arch = "wasm32"))]562#[cfg(not(target_arch = "wasm32"))]
563fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {563fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {
564 agent(BASE, Duration::from_secs(10 * 60))
565 .get(&format!("{BASE}{path}"))
566 .call()
567 .and_then(|mut response| {
568 // ureq refuses a body that reaches its limit, so an exact size needs one byte more.
569 response
570 .body_mut()
571 .with_config()
572 .limit(limit + 1)
573 .read_to_vec()
574 })
575 .map_err(|error| error.to_string())
576}
577
578/// An HTTP client for `address`, through the proxy the system names for it, trusting the
579/// system's certificate authorities and Mozilla's, giving up after `timeout`.
580#[cfg(not(target_arch = "wasm32"))]
581pub(crate) fn agent(address: &str, timeout: Duration) -> ureq::Agent {
564 let system = rustls_native_certs::load_native_certs().certs;582 let system = rustls_native_certs::load_native_certs().certs;
565 let bundled = webpki_root_certs::TLS_SERVER_ROOT_CERTS;583 let bundled = webpki_root_certs::TLS_SERVER_ROOT_CERTS;
566 let roots = (system.iter().chain(bundled))584 let roots = (system.iter().chain(bundled))
...@@ -570,7 +588,10 @@ fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {...@@ -570,7 +588,10 @@ fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {
570 // environment's.588 // environment's.
571 #[cfg(feature = "live")]589 #[cfg(feature = "live")]
572 let proxy = {590 let proxy = {
573 let host = BASE.trim_start_matches("https://").split('/').next();591 let host = address
592 .split("://")
593 .nth(1)
594 .and_then(|rest| rest.split(['/', ':']).next());
574 notebook::live::proxy::for_host(host.unwrap_or_default(), true).and_then(|proxy| {595 notebook::live::proxy::for_host(host.unwrap_or_default(), true).and_then(|proxy| {
575 let credentials = (proxy.credentials.as_ref())596 let credentials = (proxy.credentials.as_ref())
576 .map_or_else(String::new, |(name, password)| {597 .map_or_else(String::new, |(name, password)| {
...@@ -580,30 +601,21 @@ fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {...@@ -580,30 +601,21 @@ fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {
580 })601 })
581 };602 };
582 #[cfg(not(feature = "live"))]603 #[cfg(not(feature = "live"))]
583 let proxy = ureq::Proxy::try_from_env();604 let proxy = {
584 let agent: ureq::Agent = ureq::Agent::config_builder()605 let _ = address;
606 ureq::Proxy::try_from_env()
607 };
608 ureq::Agent::config_builder()
585 .proxy(proxy)609 .proxy(proxy)
586 .tls_config(610 .tls_config(
587 TlsConfig::builder()611 TlsConfig::builder()
588 .root_certs(RootCerts::Specific(Arc::new(roots)))612 .root_certs(RootCerts::Specific(Arc::new(roots)))
589 .build(),613 .build(),
590 )614 )
591 .timeout_global(Some(Duration::from_secs(10 * 60)))615 .timeout_global(Some(timeout))
592 .timeout_connect(Some(Duration::from_secs(30)))616 .timeout_connect(Some(Duration::from_secs(30)))
593 .build()617 .build()
594 .into();618 .into()
595 agent
596 .get(&format!("{BASE}{path}"))
597 .call()
598 .and_then(|mut response| {
599 // ureq refuses a body that reaches its limit, so an exact size needs one byte more.
600 response
601 .body_mut()
602 .with_config()
603 .limit(limit + 1)
604 .read_to_vec()
605 })
606 .map_err(|error| error.to_string())
607}619}
608620
609#[derive(Default)]621#[derive(Default)]
...@@ -744,7 +756,7 @@ impl State {...@@ -744,7 +756,7 @@ impl State {
744 return;756 return;
745 };757 };
746 match status {758 match status {
747 Status::Ready(version, _, changes) => platform::confirm(759 Status::Ready(version, _, changes) => crate::confirm(
748 "Update ready",760 "Update ready",
749 &described(761 &described(
750 format!("Snowbound {version} is ready to install."),762 format!("Snowbound {version} is ready to install."),
...@@ -757,7 +769,7 @@ impl State {...@@ -757,7 +769,7 @@ impl State {
757 Ok(())769 Ok(())
758 }),770 }),
759 ),771 ),
760 Status::Available(version, changes) => platform::confirm(772 Status::Available(version, changes) => crate::confirm(
761 "Update available",773 "Update available",
762 &described(774 &described(
763 format!("Download Snowbound {version} from its build folder."),775 format!("Download Snowbound {version} from its build folder."),
crates/snowbound/src/web.rs+20-14
...@@ -60,9 +60,9 @@ extern "C" {...@@ -60,9 +60,9 @@ extern "C" {
60 fn date_strings(ms: f64) -> Vec<String>;60 fn date_strings(ms: f64) -> Vec<String>;
61 #[wasm_bindgen(js_name = shortDate)]61 #[wasm_bindgen(js_name = shortDate)]
62 fn short_date_string(ms: f64) -> String;62 fn short_date_string(ms: f64) -> String;
63 /// Resolves to whether the user chose `action` over `cancel`.63 /// Resolves to the index of the button in `buttons` pressed, or -1.
64 #[wasm_bindgen(js_name = askConfirm)]64 #[wasm_bindgen(js_name = askChoice)]
65 fn ask_confirm(message: &str, detail: &str, cancel: &str, action: &str) -> js_sys::Promise;65 fn ask_choice(message: &str, detail: &str, buttons: Vec<String>) -> js_sys::Promise;
66 /// Resolves to the text the user enters, starting as `value`; undefined when cancelled.66 /// Resolves to the text the user enters, starting as `value`; undefined when cancelled.
67 #[wasm_bindgen(js_name = askText)]67 #[wasm_bindgen(js_name = askText)]
68 fn ask_text(message: &str, value: &str) -> js_sys::Promise;68 fn ask_text(message: &str, value: &str) -> js_sys::Promise;
...@@ -70,6 +70,9 @@ extern "C" {...@@ -70,6 +70,9 @@ extern "C" {
70 fn tell(message: &str, detail: &str);70 fn tell(message: &str, detail: &str);
71 #[wasm_bindgen(js_name = openLink)]71 #[wasm_bindgen(js_name = openLink)]
72 fn open_link(url: &str);72 fn open_link(url: &str);
73 /// Posts a crash report to the site the page came from.
74 #[wasm_bindgen(js_name = sendCrash)]
75 pub fn send_crash(report: &str);
73 /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length,76 /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length,
74 /// [[offset, bytes], ...]]` a file's new length and the ranges that changed; with a77 /// [[offset, bytes], ...]]` a file's new length and the ranges that changed; with a
75 /// fourth `true`, a section committed under a folder of the user's, written only where78 /// fourth `true`, a section committed under a folder of the user's, written only where
...@@ -813,15 +816,15 @@ pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>, reply: Reply...@@ -813,15 +816,15 @@ pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>, reply: Reply
813 });816 });
814}817}
815818
816pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {819/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies
817 answered(820/// with the one pressed.
818 ask_confirm(message, detail, cancel, action),821pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply<usize>) {
819 move |chosen| {822 let buttons = buttons.iter().map(|&button| button.to_owned()).collect();
820 if chosen.is_truthy() {823 answered(ask_choice(message, detail, buttons), move |pressed| {
821 reply.send(());824 if let Some(pressed) = pressed.as_f64().filter(|&pressed| pressed >= 0.0) {
822 }825 reply.send(pressed as usize);
823 },826 }
824 );827 });
825}828}
826829
827pub fn alert(message: &str, detail: &str) {830pub fn alert(message: &str, detail: &str) {
...@@ -932,9 +935,11 @@ pub async fn start(...@@ -932,9 +935,11 @@ pub async fn start(
932 fonts: Vec<js_sys::Uint8Array>,935 fonts: Vec<js_sys::Uint8Array>,
933 dictionaries: Vec<String>,936 dictionaries: Vec<String>,
934) -> Result<(), JsValue> {937) -> Result<(), JsValue> {
935 std::panic::set_hook(Box::new(|info| report(info)));
936 let window = web_sys::window().ok_or("No window")?;938 let window = web_sys::window().ok_or("No window")?;
937 let navigator = window.navigator();939 let navigator = window.navigator();
940 crate::crash::hook(navigator.user_agent().unwrap_or_default(), |text| {
941 report(text)
942 });
938 MAC.set(navigator.platform().is_ok_and(|platform| {943 MAC.set(navigator.platform().is_ok_and(|platform| {
939 ["Mac", "iPhone", "iPad"]944 ["Mac", "iPhone", "iPad"]
940 .iter()945 .iter()
...@@ -977,6 +982,7 @@ pub async fn start(...@@ -977,6 +982,7 @@ pub async fn start(
977 JsValue::from_str(&error.to_string())982 JsValue::from_str(&error.to_string())
978 })?;983 })?;
979 state.surface.show();984 state.surface.show();
985 state.offer_crash_report();
980 // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases986 // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases
981 // the rest in.987 // the rest in.
982 state.full_page = true;988 state.full_page = true;
...@@ -1631,7 +1637,7 @@ pub fn check_for_update(asked: bool) {...@@ -1631,7 +1637,7 @@ pub fn check_for_update(asked: bool) {
1631 update_now(state);1637 update_now(state);
1632 Ok(())1638 Ok(())
1633 });1639 });
1634 confirm(1640 crate::confirm(
1635 "Update ready",1641 "Update ready",
1636 "Reload to use the newest Snowbound.",1642 "Reload to use the newest Snowbound.",
1637 "Later",1643 "Later",
crates/snowbound/src/windows.rs+27-31
...@@ -942,13 +942,8 @@ pub fn with_pool(...@@ -942,13 +942,8 @@ pub fn with_pool(
942 option_env!("SNOWBOUND_BUILD").unwrap_or("development")942 option_env!("SNOWBOUND_BUILD").unwrap_or("development")
943 );943 );
944 let shown = details.clone();944 let shown = details.clone();
945 std::panic::set_hook(Box::new(move |info| {945 crate::crash::hook(format!("Windows {major}.{minor}.{build}"), move |report| {
946 let thread = std::thread::current();946 eprint!("{report}");
947 let backtrace = std::backtrace::Backtrace::force_capture();
948 eprintln!(
949 "Thread {:?} {info}\n{backtrace}",
950 thread.name().unwrap_or("")
951 );
952 // The panic aborts the process once this returns, taking an alert's thread with it.947 // The panic aborts the process once this returns, taking an alert's thread with it.
953 if cfg!(panic = "abort")948 if cfg!(panic = "abort")
954 && let Some(details) = &shown949 && let Some(details) = &shown
...@@ -962,7 +957,7 @@ pub fn with_pool(...@@ -962,7 +957,7 @@ pub fn with_pool(
962 wm::MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), style)957 wm::MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), style)
963 };958 };
964 }959 }
965 }));960 });
966 unsafe {961 unsafe {
967 windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler(0, Some(fault))962 windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler(0, Some(fault))
968 };963 };
...@@ -1759,15 +1754,17 @@ pub fn alert(message: &str, detail: &str) {...@@ -1759,15 +1754,17 @@ pub fn alert(message: &str, detail: &str) {
1759 });1754 });
1760}1755}
17611756
1762/// Asks whether to go ahead with `action`, offering `cancel` too.1757/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies
1763pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {1758/// with the one pressed.
1764 let [message, detail, cancel, action] = [message, detail, cancel, action].map(str::to_owned);1759pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply<usize>) {
1765 reply.after(move || ask(&message, &detail, &cancel, &action).then_some(()));1760 let [message, detail] = [message, detail].map(str::to_owned);
1761 let buttons: Vec<String> = buttons.iter().map(|&button| button.to_owned()).collect();
1762 reply.after(move || ask(&message, &detail, &buttons));
1766}1763}
17671764
1768/// Whether the user goes ahead with `action`: a task dialog with buttons for it and `cancel`1765/// The button pressed: a task dialog with `buttons` left to right where the common controls
1769/// where the common controls have one, as from Windows Vista, else OK and Cancel.1766/// have one, as from Windows Vista, else OK, the first, and Cancel.
1770fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {1767fn ask(message: &str, detail: &str, buttons: &[String]) -> Option<usize> {
1771 use windows_sys::Win32::UI::Controls as controls;1768 use windows_sys::Win32::UI::Controls as controls;
1772 type Indirect = unsafe extern "system" fn(1769 type Indirect = unsafe extern "system" fn(
1773 *const controls::TASKDIALOGCONFIG,1770 *const controls::TASKDIALOGCONFIG,
...@@ -1775,20 +1772,17 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {...@@ -1775,20 +1772,17 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
1775 *mut i32,1772 *mut i32,
1776 *mut BOOL,1773 *mut BOOL,
1777 ) -> i32;1774 ) -> i32;
1778 let [title, instruction, content, cancel_text, action_text] =1775 let [title, instruction, content] = ["Snowbound", message, detail].map(wide);
1779 ["Snowbound", message, detail, cancel, action].map(wide);
1780 if let Some(indirect) = function::<Indirect>("comctl32.dll", c"TaskDialogIndirect") {1776 if let Some(indirect) = function::<Indirect>("comctl32.dll", c"TaskDialogIndirect") {
1781 const ACTION: i32 = 100;1777 const FIRST: i32 = 100;
1782 let buttons = [1778 let texts: Vec<_> = buttons.iter().map(|button| wide(button.as_str())).collect();
1783 controls::TASKDIALOG_BUTTON {1779 let buttons: Vec<_> = (FIRST..)
1784 nButtonID: ACTION,1780 .zip(&texts)
1785 pszButtonText: action_text.as_ptr(),1781 .map(|(id, text)| controls::TASKDIALOG_BUTTON {
1786 },1782 nButtonID: id,
1787 controls::TASKDIALOG_BUTTON {1783 pszButtonText: text.as_ptr(),
1788 nButtonID: wm::IDCANCEL,1784 })
1789 pszButtonText: cancel_text.as_ptr(),1785 .collect();
1790 },
1791 ];
1792 let mut config: controls::TASKDIALOGCONFIG = unsafe { std::mem::zeroed() };1786 let mut config: controls::TASKDIALOGCONFIG = unsafe { std::mem::zeroed() };
1793 config.cbSize = size_of::<controls::TASKDIALOGCONFIG>() as u32;1787 config.cbSize = size_of::<controls::TASKDIALOGCONFIG>() as u32;
1794 config.hwndParent = owner();1788 config.hwndParent = owner();
...@@ -1798,7 +1792,7 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {...@@ -1798,7 +1792,7 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
1798 config.pszContent = content.as_ptr();1792 config.pszContent = content.as_ptr();
1799 config.cButtons = buttons.len() as u32;1793 config.cButtons = buttons.len() as u32;
1800 config.pButtons = buttons.as_ptr();1794 config.pButtons = buttons.as_ptr();
1801 config.nDefaultButton = wm::IDCANCEL;1795 config.nDefaultButton = FIRST + buttons.len() as i32 - 1;
1802 let mut pressed = 0;1796 let mut pressed = 0;
1803 let shown = unsafe {1797 let shown = unsafe {
1804 indirect(1798 indirect(
...@@ -1809,7 +1803,9 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {...@@ -1809,7 +1803,9 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
1809 )1803 )
1810 };1804 };
1811 if shown >= 0 {1805 if shown >= 0 {
1812 return pressed == ACTION;1806 return usize::try_from(pressed - FIRST)
1807 .ok()
1808 .filter(|&pressed| pressed < buttons.len());
1813 }1809 }
1814 }1810 }
1815 let text = wide(format!("{message}\n\n{detail}"));1811 let text = wide(format!("{message}\n\n{detail}"));
...@@ -1821,7 +1817,7 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {...@@ -1821,7 +1817,7 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
1821 wm::MB_OKCANCEL | wm::MB_ICONQUESTION | wm::MB_DEFBUTTON2,1817 wm::MB_OKCANCEL | wm::MB_ICONQUESTION | wm::MB_DEFBUTTON2,
1822 )1818 )
1823 };1819 };
1824 answer == wm::IDOK1820 (answer == wm::IDOK).then_some(0)
1825}1821}
18261822
1827/// The common controls' date and time picker in a dialog of its own.1823/// The common controls' date and time picker in a dialog of its own.
crates/snowbound/web/glue.js+7-2
...@@ -472,8 +472,8 @@ function ask(message, detail, buttons, value) {...@@ -472,8 +472,8 @@ function ask(message, detail, buttons, value) {
472 );472 );
473}473}
474474
475export function askConfirm(message, detail, cancel, action) {475export function askChoice(message, detail, buttons) {
476 return ask(message, detail, [action, cancel]).then(([pressed]) => pressed === 0);476 return ask(message, detail, buttons).then(([pressed]) => pressed);
477}477}
478478
479export function askText(message, value) {479export function askText(message, value) {
...@@ -486,6 +486,11 @@ export function tell(message, detail) {...@@ -486,6 +486,11 @@ export function tell(message, detail) {
486 ask(message, detail, ["OK"]);486 ask(message, detail, ["OK"]);
487}487}
488488
489export function sendCrash(report) {
490 fetch("/crash", { method: "POST", headers: { "Content-Type": "text/plain; charset=utf-8" }, body: report })
491 .catch((error) => console.error("Cannot send the crash report", error));
492}
493
489export function openLink(url) {494export function openLink(url) {
490 open(url, "_blank", "noopener");495 open(url, "_blank", "noopener");
491}496}