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
3030(each over the last) picks one, and one that fails to start falls back to the
3131default and says so. Choosing another in Options starts it at once: the renderer and
3232surface 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.
3339`commands.rs` is the one table of commands: each one's title, its chords on
3440macOS and elsewhere, when it is enabled or checked, and what it does. The
3541keyboard, 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"
5757wasm-bindgen = "0.2"
5858wasm-bindgen-futures = "0.4"
5959js-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
6262[target.'cfg(target_os = "macos")'.dependencies]
6363libc = "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>) {
488488 remove();
489489 Ok(())
490490 });
491 crate::platform::confirm(
491 crate::confirm(
492492 "Uninstall Snowbound?",
493493 &detail,
494494 "Cancel",
crates/snowbound/src/dialog_linux.rs+31-22
......@@ -11,11 +11,10 @@ use winit::keyboard::NamedKey;
1111pub enum Ask {
1212 /// A message with an OK button.
1313 Message,
14 /// Whether to go ahead: buttons for `cancel` and for `action`.
15 Question {
16 cancel: String,
17 action: String,
18 reply: Reply<()>,
14 /// A button for each of `buttons`, the last the safe answer; replies with the one pressed.
15 Choice {
16 buttons: Vec<String>,
17 reply: Reply<usize>,
1918 },
2019 /// A line of text, starting as `text`.
2120 Entry { text: String, reply: Reply<String> },
......@@ -109,6 +108,8 @@ fn field() -> Id {
109108enum Answer {
110109 Cancel,
111110 Accept,
111 /// A choice's button.
112 Pressed(usize),
112113 /// A file chooser moves to this folder.
113114 Open(PathBuf),
114115 /// A file chooser's file to open, or the name to save as.
......@@ -182,10 +183,10 @@ impl State {
182183 pad: [6.0, 0.0],
183184 ..Spec::default()
184185 };
185 // Return goes ahead, but for a question, whose safe answer is the default as
186 // AppKit's and Windows' are.
187 let mut answer = entered.then_some(match dialog.ask {
188 Ask::Question { .. } => Answer::Cancel,
186 // Return goes ahead, but for a choice, whose safe answer is the default as AppKit's
187 // and Windows' are.
188 let mut answer = entered.then_some(match &dialog.ask {
189 Ask::Choice { buttons, .. } => Answer::Pressed(buttons.len() - 1),
189190 _ => Answer::Accept,
190191 });
191192 match &mut dialog.ask {
......@@ -260,7 +261,7 @@ impl State {
260261 crate::name(ui, field(), "Name");
261262 }
262263 }
263 Ask::Message | Ask::Question { .. } => {}
264 Ask::Message | Ask::Choice { .. } => {}
264265 }
265266 ui.open(
266267 "buttons",
......@@ -284,18 +285,22 @@ impl State {
284285 ..Spec::default()
285286 },
286287 );
287 let (cancel, action) = match &dialog.ask {
288 Ask::Message => (None, "OK"),
289 Ask::Question { cancel, action, .. } => (Some(cancel.as_str()), action.as_str()),
290 Ask::Entry { .. } => (Some("Cancel"), "OK"),
291 Ask::File { save: false, .. } => (Some("Cancel"), "Open"),
292 Ask::File { save: true, .. } => (Some("Cancel"), "Save"),
288 // Left to right, the safe answer first, as GNOME and KDE place Cancel.
289 let buttons: Vec<(&str, Answer)> = match &dialog.ask {
290 Ask::Message => vec![("OK", Answer::Accept)],
291 Ask::Choice { buttons, .. } => (buttons.iter().enumerate().rev())
292 .map(|(index, button)| (button.as_str(), Answer::Pressed(index)))
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 ],
293299 };
294 if cancel.is_some_and(|cancel| ui::button(ui, "cancel", cancel).clicked) {
295 answer = Some(Answer::Cancel);
296 }
297 if ui::button(ui, "action", action).clicked {
298 answer = Some(Answer::Accept);
300 for (index, (button, pressed)) in buttons.into_iter().enumerate() {
301 if ui::button(ui, ("button", index), button).clicked {
302 answer = Some(pressed);
303 }
299304 }
300305 ui.close();
301306 ui.close();
......@@ -343,7 +348,11 @@ impl State {
343348 }
344349 match dialog.ask {
345350 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 }
347356 Ask::Entry { text, reply } => reply.send(text),
348357 Ask::File {
349358 folder,
crates/snowbound/src/history.rs+1-1
......@@ -208,7 +208,7 @@ impl State {
208208 Scope::Notebook => ("notebook", session.library.name.clone()),
209209 };
210210 let asked = Arc::clone(&session.library);
211 platform::confirm(
211 crate::confirm(
212212 &format!("Do you want to delete all page versions in the {container} \"{name}\"?"),
213213 "You can't restore these versions afterward.",
214214 "Cancel",
crates/snowbound/src/library.rs+2
......@@ -341,6 +341,7 @@ pub struct Keys(Mutex<std::collections::HashMap<[u8; 16], (Key, Instant)>>);
341341impl Library {
342342 /// `location` named `name`, with no notebook read, server, sync or kept sections.
343343 fn new(location: &str, name: String, cache: &Path) -> Self {
344 crate::crash::conceal(&name);
344345 Self {
345346 location: location.to_owned(),
346347 name,
......@@ -807,6 +808,7 @@ impl Library {
807808 notify: Box<dyn Fn() + Send>,
808809 deadline: Instant,
809810 ) -> Result<Section, Box<dyn Error>> {
811 crate::crash::conceal(path);
810812 let notify = Arc::new(Mutex::new(notify));
811813 let notifier = || {
812814 let notify = Arc::clone(&notify);
crates/snowbound/src/linux.rs+22-24
......@@ -828,15 +828,18 @@ fn log_crashes() {
828828 let field =
829829 |field: &[libc::c_char]| unsafe { CStr::from_ptr(field.as_ptr()) }.to_string_lossy();
830830 let session = |name| std::env::var(name).unwrap_or_default();
831 let _ = writeln!(
832 log,
833 "Snowbound {} on Linux {} {}, {} {}",
834 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
831 let described = format!(
832 "Linux {} {}, {} {}",
835833 field(&system.release),
836834 field(&system.machine),
837835 session("XDG_SESSION_TYPE"),
838836 session("XDG_CURRENT_DESKTOP"),
839837 );
838 let _ = writeln!(
839 log,
840 "Snowbound {} on {described}",
841 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
842 );
840843 let mut stderr: libc::stat = unsafe { std::mem::zeroed() };
841844 let seen = unsafe { libc::isatty(2) } == 1
842845 || unsafe { libc::fstat(2, &mut stderr) } == 0
......@@ -848,15 +851,9 @@ fn log_crashes() {
848851 None
849852 };
850853 let _ = LOG.set((path, copy));
851 std::panic::set_hook(Box::new(|info| {
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 );
854 crate::crash::hook(described, |report| {
858855 crashed(|fd| write_all(fd, report.as_bytes()));
859 }));
856 });
860857 if let Ok(path) = crate::loader::executable()
861858 && let Ok(path) = CString::new(path.into_os_string().into_vec())
862859 {
......@@ -1595,7 +1592,7 @@ pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) {
15951592 options.insert("filters", filters(&patterns, &globs));
15961593 }
15971594 let types = types.iter().map(|kind| kind.to_string()).collect();
1598 choose(
1595 pick(
15991596 title,
16001597 "OpenFile",
16011598 options,
......@@ -1618,7 +1615,7 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> {
16181615
16191616/// Replies with the file the file chooser portal answers `method` with, titled `title`, or
16201617/// where no portal answers, Snowbound's `own`.
1621fn choose(
1618fn pick(
16221619 title: &str,
16231620 method: &'static str,
16241621 mut options: HashMap<&'static str, Value<'static>>,
......@@ -1747,14 +1744,15 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
17471744 }
17481745}
17491746
1750/// Asks whether to go ahead with `action`, offering `cancel` first.
1751pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {
1752 let ask = Ask::Question {
1753 cancel: cancel.into(),
1754 action: action.into(),
1755 reply,
1756 };
1757 crate::dialog::show(message.into(), detail.into(), ask);
1747/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies
1748/// with the one pressed.
1749pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply<usize>) {
1750 let buttons = buttons.iter().map(|&button| button.to_owned()).collect();
1751 crate::dialog::show(
1752 message.into(),
1753 detail.into(),
1754 Ask::Choice { buttons, reply },
1755 );
17581756}
17591757
17601758/// 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>) {
17621760 let name = "OneNote notebooks, sections and packages";
17631761 let types = ["onetoc2", "one", "onepkg"];
17641762 let globs = types.map(|kind| format!("*.{kind}"));
1765 choose(
1763 pick(
17661764 title,
17671765 "OpenFile",
17681766 HashMap::from([("filters", filters(name, &globs))]),
......@@ -1800,7 +1798,7 @@ pub fn pick_new(
18001798 name.to_owned(),
18011799 folder.map_or_else(crate::dialog::start_folder, Into::into),
18021800 );
1803 choose(
1801 pick(
18041802 title,
18051803 "SaveFile",
18061804 options,
crates/snowbound/src/macos.rs+23-12
......@@ -9,10 +9,9 @@ use objc2::{
99 sel,
1010};
1111use objc2_app_kit::{
12 NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor,
13 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent,
14 NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel,
15 NSModalResponseOK, NSSavePanel, NSWindow,
12 NSAlert, NSAlertFirstButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker,
13 NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventSubtype, NSEventType, NSMenu,
14 NSMenuItem, NSModalResponse, NSModalResponseCancel, NSModalResponseOK, NSSavePanel, NSWindow,
1615};
1716use objc2_foundation::{
1817 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
......@@ -33,7 +32,14 @@ use winit::{
3332/// Runs `run` inside an autorelease pool. 10.6 has none outside NSApplication's run loop,
3433/// and quitting releases the windows after it returns.
3534pub 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 })
3743}
3844
3945pub 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>) {
16481654 }
16491655}
16501656
1651/// Asks whether to go ahead with `action`, offering `cancel` first.
1652pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {
1657/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies
1658/// with the one pressed. AppKit places the last first, on Return.
1659pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply<usize>) {
16531660 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
1661 let count = buttons.len();
16541662 unsafe {
16551663 let alert = NSAlert::new(mtm);
16561664 alert.setMessageText(&NSString::from_str(message));
16571665 alert.setInformativeText(&NSString::from_str(detail));
1658 alert.addButtonWithTitle(&NSString::from_str(cancel));
1659 alert.addButtonWithTitle(&NSString::from_str(action));
1660 begin_alert(&alert, |response| {
1661 if response == NSAlertSecondButtonReturn {
1662 reply.send(());
1666 for button in buttons.iter().rev() {
1667 alert.addButtonWithTitle(&NSString::from_str(button));
1668 }
1669 begin_alert(&alert, move |response| {
1670 if let Ok(added) = usize::try_from(response - NSAlertFirstButtonReturn)
1671 && added < count
1672 {
1673 reply.send(count - 1 - added);
16631674 }
16641675 });
16651676 }
crates/snowbound/src/main.rs+29-3
......@@ -9,6 +9,7 @@ mod background;
99mod commands;
1010#[cfg(all(test, feature = "wgpu", not(windows)))]
1111mod conflict_render;
12mod crash;
1213#[cfg(target_os = "linux")]
1314#[path = "desktop_linux.rs"]
1415mod desktop;
......@@ -265,6 +266,16 @@ impl<T: Send + 'static> Reply<T> {
265266 }
266267}
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
268279enum UserEvent {
269280 Quit,
270281 /// Quits without asking, as after the user agreed to discard a temporary page.
......@@ -1035,6 +1046,9 @@ async fn start_surface(
10351046 match surface::Surface::new(window.clone(), backdrop, backend).await {
10361047 Ok((surface, renderer, adapter)) => {
10371048 note(&format!("Drawing with {} ({adapter})", backend.label()));
1049 if let Ok(mut renderer) = crash::RENDERER.lock() {
1050 *renderer = format!("{} ({adapter})", backend.label());
1051 }
10381052 unavailable.retain(|(failed, _)| *failed != backend);
10391053 return Ok((surface, renderer, (backend, adapter)));
10401054 }
......@@ -6351,7 +6365,7 @@ impl App {
63516365 }) else {
63526366 return self.exit(event_loop);
63536367 };
6354 platform::confirm(
6368 crate::confirm(
63556369 "Discard this page?",
63566370 "This temporary page has no saved copy. Closing it will discard your edits.",
63576371 "Keep Editing",
......@@ -6436,6 +6450,14 @@ impl ApplicationHandler<UserEvent> for App {
64366450 for path in self.opening.drain(..) {
64376451 state.open_path(&path);
64386452 }
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 }
64396461 self.state = Some(state);
64406462 }
64416463 },
......@@ -6948,12 +6970,16 @@ fn launch() -> Result<(), Box<dyn Error>> {
69486970 event_loop.create_proxy(),
69496971 )?;
69506972 }
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 }
69516978 let mut app = App {
69526979 proxy: event_loop.create_proxy(),
69536980 input: Some(input),
6954 // A screenshot leaves the settings as it found them.
69556981 launch: Some(settings::Launch {
6956 file: settings_file.filter(|_| screenshot.is_none()),
6982 file: settings_file,
69576983 saved,
69586984 renderer,
69596985 cache,
crates/snowbound/src/manage.rs+1-1
......@@ -593,7 +593,7 @@ impl State {
593593 };
594594 detail += &format!(" {changes} not yet saved to its files will be lost.");
595595 }
596 platform::confirm(
596 crate::confirm(
597597 &format!("Delete “{}”?", library.name),
598598 &detail,
599599 "Cancel",
crates/snowbound/src/menus.rs+2-2
......@@ -867,7 +867,7 @@ impl State {
867867 }
868868 (Target::Section { library, path }, Action::Delete) => {
869869 let name = crate::library::entry_name(&path).to_owned();
870 platform::confirm(
870 crate::confirm(
871871 "Are you sure you want to move this section to this notebook's Recycle Bin?",
872872 &name,
873873 "Cancel",
......@@ -882,7 +882,7 @@ impl State {
882882 }
883883 (Target::Group { library, path }, Action::Delete) => {
884884 let name = crate::library::entry_name(&path).to_owned();
885 platform::confirm(
885 crate::confirm(
886886 "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?",
887887 &name,
888888 "Cancel",
crates/snowbound/src/recycle.rs+2-2
......@@ -76,7 +76,7 @@ impl State {
7676
7777 /// Empty Recycle Bin on `library`, once confirmed as OneNote asks.
7878 pub(crate) fn empty_recycle_bin(&mut self, library: Arc<Library>) {
79 crate::platform::confirm(
79 crate::confirm(
8080 "Are you sure you want to empty the Recycle Bin for this notebook?",
8181 "Its pages and sections are deleted for good.",
8282 "Cancel",
......@@ -95,7 +95,7 @@ impl State {
9595 return self.recycle_now(request);
9696 };
9797 let spaces = spaces.clone();
98 crate::platform::confirm(
98 crate::confirm(
9999 "Are you sure you want to delete this page for good?",
100100 "It can't be restored.",
101101 "Cancel",
crates/snowbound/src/update.rs+32-20
......@@ -47,7 +47,7 @@ fn running() -> Option<Version> {
4747
4848/// This build's platform, as `latest.json` and `build.json` key their entries; Apple
4949/// silicon's for an Intel build Rosetta runs.
50fn platform() -> String {
50pub(crate) fn platform() -> String {
5151 if translated() {
5252 "macos-aarch64".to_owned()
5353 } else if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") {
......@@ -561,6 +561,24 @@ fn download(_: &str, _: u64) -> Result<Vec<u8>, String> {
561561/// The `Fetch` the app uses.
562562#[cfg(not(target_arch = "wasm32"))]
563563fn 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 {
564582 let system = rustls_native_certs::load_native_certs().certs;
565583 let bundled = webpki_root_certs::TLS_SERVER_ROOT_CERTS;
566584 let roots = (system.iter().chain(bundled))
......@@ -570,7 +588,10 @@ fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {
570588 // environment's.
571589 #[cfg(feature = "live")]
572590 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());
574595 notebook::live::proxy::for_host(host.unwrap_or_default(), true).and_then(|proxy| {
575596 let credentials = (proxy.credentials.as_ref())
576597 .map_or_else(String::new, |(name, password)| {
......@@ -580,30 +601,21 @@ fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> {
580601 })
581602 };
582603 #[cfg(not(feature = "live"))]
583 let proxy = ureq::Proxy::try_from_env();
584 let agent: ureq::Agent = ureq::Agent::config_builder()
604 let proxy = {
605 let _ = address;
606 ureq::Proxy::try_from_env()
607 };
608 ureq::Agent::config_builder()
585609 .proxy(proxy)
586610 .tls_config(
587611 TlsConfig::builder()
588612 .root_certs(RootCerts::Specific(Arc::new(roots)))
589613 .build(),
590614 )
591 .timeout_global(Some(Duration::from_secs(10 * 60)))
615 .timeout_global(Some(timeout))
592616 .timeout_connect(Some(Duration::from_secs(30)))
593617 .build()
594 .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())
618 .into()
607619}
608620
609621#[derive(Default)]
......@@ -744,7 +756,7 @@ impl State {
744756 return;
745757 };
746758 match status {
747 Status::Ready(version, _, changes) => platform::confirm(
759 Status::Ready(version, _, changes) => crate::confirm(
748760 "Update ready",
749761 &described(
750762 format!("Snowbound {version} is ready to install."),
......@@ -757,7 +769,7 @@ impl State {
757769 Ok(())
758770 }),
759771 ),
760 Status::Available(version, changes) => platform::confirm(
772 Status::Available(version, changes) => crate::confirm(
761773 "Update available",
762774 &described(
763775 format!("Download Snowbound {version} from its build folder."),
crates/snowbound/src/web.rs+20-14
......@@ -60,9 +60,9 @@ extern "C" {
6060 fn date_strings(ms: f64) -> Vec<String>;
6161 #[wasm_bindgen(js_name = shortDate)]
6262 fn short_date_string(ms: f64) -> String;
63 /// Resolves to whether the user chose `action` over `cancel`.
64 #[wasm_bindgen(js_name = askConfirm)]
65 fn ask_confirm(message: &str, detail: &str, cancel: &str, action: &str) -> js_sys::Promise;
63 /// Resolves to the index of the button in `buttons` pressed, or -1.
64 #[wasm_bindgen(js_name = askChoice)]
65 fn ask_choice(message: &str, detail: &str, buttons: Vec<String>) -> js_sys::Promise;
6666 /// Resolves to the text the user enters, starting as `value`; undefined when cancelled.
6767 #[wasm_bindgen(js_name = askText)]
6868 fn ask_text(message: &str, value: &str) -> js_sys::Promise;
......@@ -70,6 +70,9 @@ extern "C" {
7070 fn tell(message: &str, detail: &str);
7171 #[wasm_bindgen(js_name = openLink)]
7272 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);
7376 /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length,
7477 /// [[offset, bytes], ...]]` a file's new length and the ranges that changed; with a
7578 /// 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
813816 });
814817}
815818
816pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {
817 answered(
818 ask_confirm(message, detail, cancel, action),
819 move |chosen| {
820 if chosen.is_truthy() {
821 reply.send(());
822 }
823 },
824 );
819/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies
820/// with the one pressed.
821pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply<usize>) {
822 let buttons = buttons.iter().map(|&button| button.to_owned()).collect();
823 answered(ask_choice(message, detail, buttons), move |pressed| {
824 if let Some(pressed) = pressed.as_f64().filter(|&pressed| pressed >= 0.0) {
825 reply.send(pressed as usize);
826 }
827 });
825828}
826829
827830pub fn alert(message: &str, detail: &str) {
......@@ -932,9 +935,11 @@ pub async fn start(
932935 fonts: Vec<js_sys::Uint8Array>,
933936 dictionaries: Vec<String>,
934937) -> Result<(), JsValue> {
935 std::panic::set_hook(Box::new(|info| report(info)));
936938 let window = web_sys::window().ok_or("No window")?;
937939 let navigator = window.navigator();
940 crate::crash::hook(navigator.user_agent().unwrap_or_default(), |text| {
941 report(text)
942 });
938943 MAC.set(navigator.platform().is_ok_and(|platform| {
939944 ["Mac", "iPhone", "iPad"]
940945 .iter()
......@@ -977,6 +982,7 @@ pub async fn start(
977982 JsValue::from_str(&error.to_string())
978983 })?;
979984 state.surface.show();
985 state.offer_crash_report();
980986 // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases
981987 // the rest in.
982988 state.full_page = true;
......@@ -1631,7 +1637,7 @@ pub fn check_for_update(asked: bool) {
16311637 update_now(state);
16321638 Ok(())
16331639 });
1634 confirm(
1640 crate::confirm(
16351641 "Update ready",
16361642 "Reload to use the newest Snowbound.",
16371643 "Later",
crates/snowbound/src/windows.rs+27-31
......@@ -942,13 +942,8 @@ pub fn with_pool(
942942 option_env!("SNOWBOUND_BUILD").unwrap_or("development")
943943 );
944944 let shown = details.clone();
945 std::panic::set_hook(Box::new(move |info| {
946 let thread = std::thread::current();
947 let backtrace = std::backtrace::Backtrace::force_capture();
948 eprintln!(
949 "Thread {:?} {info}\n{backtrace}",
950 thread.name().unwrap_or("")
951 );
945 crate::crash::hook(format!("Windows {major}.{minor}.{build}"), move |report| {
946 eprint!("{report}");
952947 // The panic aborts the process once this returns, taking an alert's thread with it.
953948 if cfg!(panic = "abort")
954949 && let Some(details) = &shown
......@@ -962,7 +957,7 @@ pub fn with_pool(
962957 wm::MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), style)
963958 };
964959 }
965 }));
960 });
966961 unsafe {
967962 windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler(0, Some(fault))
968963 };
......@@ -1759,15 +1754,17 @@ pub fn alert(message: &str, detail: &str) {
17591754 });
17601755}
17611756
1762/// Asks whether to go ahead with `action`, offering `cancel` too.
1763pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {
1764 let [message, detail, cancel, action] = [message, detail, cancel, action].map(str::to_owned);
1765 reply.after(move || ask(&message, &detail, &cancel, &action).then_some(()));
1757/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies
1758/// with the one pressed.
1759pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply<usize>) {
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));
17661763}
17671764
1768/// Whether the user goes ahead with `action`: a task dialog with buttons for it and `cancel`
1769/// where the common controls have one, as from Windows Vista, else OK and Cancel.
1770fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
1765/// The button pressed: a task dialog with `buttons` left to right where the common controls
1766/// have one, as from Windows Vista, else OK, the first, and Cancel.
1767fn ask(message: &str, detail: &str, buttons: &[String]) -> Option<usize> {
17711768 use windows_sys::Win32::UI::Controls as controls;
17721769 type Indirect = unsafe extern "system" fn(
17731770 *const controls::TASKDIALOGCONFIG,
......@@ -1775,20 +1772,17 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
17751772 *mut i32,
17761773 *mut BOOL,
17771774 ) -> i32;
1778 let [title, instruction, content, cancel_text, action_text] =
1779 ["Snowbound", message, detail, cancel, action].map(wide);
1775 let [title, instruction, content] = ["Snowbound", message, detail].map(wide);
17801776 if let Some(indirect) = function::<Indirect>("comctl32.dll", c"TaskDialogIndirect") {
1781 const ACTION: i32 = 100;
1782 let buttons = [
1783 controls::TASKDIALOG_BUTTON {
1784 nButtonID: ACTION,
1785 pszButtonText: action_text.as_ptr(),
1786 },
1787 controls::TASKDIALOG_BUTTON {
1788 nButtonID: wm::IDCANCEL,
1789 pszButtonText: cancel_text.as_ptr(),
1790 },
1791 ];
1777 const FIRST: i32 = 100;
1778 let texts: Vec<_> = buttons.iter().map(|button| wide(button.as_str())).collect();
1779 let buttons: Vec<_> = (FIRST..)
1780 .zip(&texts)
1781 .map(|(id, text)| controls::TASKDIALOG_BUTTON {
1782 nButtonID: id,
1783 pszButtonText: text.as_ptr(),
1784 })
1785 .collect();
17921786 let mut config: controls::TASKDIALOGCONFIG = unsafe { std::mem::zeroed() };
17931787 config.cbSize = size_of::<controls::TASKDIALOGCONFIG>() as u32;
17941788 config.hwndParent = owner();
......@@ -1798,7 +1792,7 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
17981792 config.pszContent = content.as_ptr();
17991793 config.cButtons = buttons.len() as u32;
18001794 config.pButtons = buttons.as_ptr();
1801 config.nDefaultButton = wm::IDCANCEL;
1795 config.nDefaultButton = FIRST + buttons.len() as i32 - 1;
18021796 let mut pressed = 0;
18031797 let shown = unsafe {
18041798 indirect(
......@@ -1809,7 +1803,9 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
18091803 )
18101804 };
18111805 if shown >= 0 {
1812 return pressed == ACTION;
1806 return usize::try_from(pressed - FIRST)
1807 .ok()
1808 .filter(|&pressed| pressed < buttons.len());
18131809 }
18141810 }
18151811 let text = wide(format!("{message}\n\n{detail}"));
......@@ -1821,7 +1817,7 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
18211817 wm::MB_OKCANCEL | wm::MB_ICONQUESTION | wm::MB_DEFBUTTON2,
18221818 )
18231819 };
1824 answer == wm::IDOK
1820 (answer == wm::IDOK).then_some(0)
18251821}
18261822
18271823/// 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) {
472472 );
473473}
474474
475export function askConfirm(message, detail, cancel, action) {
476 return ask(message, detail, [action, cancel]).then(([pressed]) => pressed === 0);
475export function askChoice(message, detail, buttons) {
476 return ask(message, detail, buttons).then(([pressed]) => pressed);
477477}
478478
479479export function askText(message, value) {
......@@ -486,6 +486,11 @@ export function tell(message, detail) {
486486 ask(message, detail, ["OK"]);
487487}
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
489494export function openLink(url) {
490495 open(url, "_blank", "noopener");
491496}