diff --git a/arc/platforms.md b/arc/platforms.md index a6b1921c6d994ed5b9b0d1b9b091c5cad62b7f39..1565a69d970bbea08b7943c7beef654a0740d0e4 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -30,6 +30,12 @@ Options' Renderer, the settings' `renderer`, `SNOWBOUND_RENDERER` or `--renderer (each over the last) picks one, and one that fails to start falls back to the default and says so. Choosing another in Options starts it at once: the renderer and surface go and new ones begin, the window and everything in it staying as they are. +A panic writes a crash report beside the settings before the process ends (`crash.rs`): +the build, system, renderer, thread, uptime, panic and backtrace, with paths and the +open notebooks' and sections' names hidden; the browser keeps it in local storage. The next +launch asks whether to send it to the site's `POST /crash`, shows the exact text on Show +Report, and sends nothing unless Send Report is chosen; either answer deletes it. A run +with no settings of its own, as a screenshot or replay, writes and reads none. `commands.rs` is the one table of commands: each one's title, its chords on macOS and elsewhere, when it is enabled or checked, and what it does. The keyboard, the toolbar and the macOS menu bar all run commands from it. diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 9dfa3777ec0e949d487809aa37d3f475da247b2c..3218e1b35d104a0d7c912b0ecafb5ccde8014078 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -57,7 +57,7 @@ spellbook = "0.4" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Node", "Window"] } +web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Node", "Storage", "Window"] } [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" diff --git a/crates/snowbound/src/crash.rs b/crates/snowbound/src/crash.rs new file mode 100644 index 0000000000000000000000000000000000000000..33261889bb32492391e4b36a4634ea3da633413e --- /dev/null +++ b/crates/snowbound/src/crash.rs @@ -0,0 +1,415 @@ +//! Crash reports. A panic writes a report beside the settings before the process ends; the +//! next launch asks whether to send it to the site's `POST /crash`, and sends nothing unless +//! the person chooses Send Report. A report holds the build, the system, the renderer, the +//! panic and its backtrace, with paths and the names of notebooks and sections hidden. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; +use web_time::Instant; + +/// Where a panic writes its report: beside the settings file the launch saves, so runs with +/// settings of their own, and automated runs, never touch the account's. +pub static REPORT: OnceLock = OnceLock::new(); +/// The backend drawing and its adapter, as "Metal (Apple M2)". +pub static RENDERER: Mutex = Mutex::new(String::new()); +/// The notebooks, section groups and sections opened this run, which a report hides. +static NAMES: Mutex> = Mutex::new(Vec::new()); +static STARTED: OnceLock = OnceLock::new(); +/// The first panic's report is kept; the ones it sets off would only hide it. +static KEPT: AtomicBool = AtomicBool::new(false); + +const ADDRESS: &str = "https://snowbound.paperclover.net/crash"; +/// Bounds on what a panic adds, so a report stays well under what the site takes. +const MESSAGE: usize = 2 << 10; +const FRAMES: usize = 48; +const BACKTRACE: usize = 32 << 10; +const NAMED: usize = 256; + +/// Reports each panic on `system`, then hands the report to `then` to log. +pub fn hook(system: String, then: impl Fn(&str) + Send + Sync + 'static) { + STARTED.get_or_init(Instant::now); + let home = home(); + std::panic::set_hook(Box::new(move |info| { + let message = info + .payload_as_str() + .unwrap_or("Box") + .chars() + .take(MESSAGE) + .collect::(); + let at = info.location().map(ToString::to_string).unwrap_or_default(); + // Another thread may hold the lock, or this one may have panicked holding it. + let names = NAMES + .try_lock() + .map(|names| names.clone()) + .unwrap_or_default(); + let renderer = RENDERER + .try_lock() + .map(|name| name.clone()) + .unwrap_or_default(); + let thread = std::thread::current(); + let report = format!( + "Snowbound {}, {}\nSystem: {system}\nRenderer: {renderer}\nThread: {}\nUptime: {} s\n\ + Panic: {}\nAt: {}\n\nBacktrace:\n{}", + option_env!("SNOWBOUND_BUILD").unwrap_or("development"), + crate::update::platform(), + thread.name().unwrap_or("unnamed"), + STARTED + .get() + .map_or(0, |started| started.elapsed().as_secs()), + scrub(&message, &home, &names), + scrub(&at, &home, &[]), + scrub(&frames(&backtrace()), &home, &[]), + ); + if !KEPT.swap(true, Ordering::Relaxed) { + keep(&report); + } + then(&report); + })); +} + +/// Hides `path`'s names, a notebook's or a section's within it, in reports from now on. +pub fn conceal(path: &str) { + let Ok(mut names) = NAMES.lock() else { + return; + }; + for name in path.split(['/', '\\']) { + let name = [".onetoc2", ".onepkg", ".one"] + .iter() + .find_map(|extension| name.strip_suffix(extension)) + .unwrap_or(name); + // Shorter names would hide parts of ordinary words. + if name.chars().count() >= 3 && names.len() < NAMED && !names.iter().any(|n| n == name) { + names.push(name.to_owned()); + } + } + // Longer first, so a name holding another is hidden whole. + names.sort_by_key(|name| std::cmp::Reverse(name.len())); +} + +/// `text` with `home` as `~`, `names` as ``, and every path but a source file's as +/// ``. +fn scrub(text: &str, home: &str, names: &[String]) -> String { + let mut text = if home.len() > 1 { + text.replace(home, "~") + } else { + text.to_owned() + }; + for name in names { + text = text.replace(name.as_str(), ""); + } + hide_paths(&text) +} + +fn hide_paths(text: &str) -> String { + let mut hidden = String::with_capacity(text.len()); + let mut rest = text; + let mut previous = None; + while let Some(next) = rest.chars().next() { + let begins = matches!( + previous, + None | Some(' ' | '\t' | '\n' | '"' | '\'' | '`' | '(' | '[' | '{' | '=' | ',' | ':') + ) && (rest.starts_with('/') + || rest.starts_with("~/") + || rest.starts_with("~\\") + || rest.starts_with("\\\\") + || rest.get(1..3) == Some(":\\") && next.is_ascii_alphabetic()); + if !begins { + hidden.push(next); + previous = Some(next); + rest = &rest[next.len_utf8()..]; + continue; + } + let word = &rest[..rest.find(char::is_whitespace).unwrap_or(rest.len())]; + if word + .trim_end_matches(|c: char| c.is_ascii_digit() || matches!(c, ':' | ',' | ')')) + .ends_with(".rs") + { + hidden.push_str(word); + previous = word.chars().last(); + rest = &rest[word.len()..]; + continue; + } + // A quoted path runs to its quote, spaces and all; another to a break in the sentence. + let end = match previous { + Some(quote @ ('"' | '\'' | '`')) => rest.find([quote, '\n']), + _ => [": ", ", ", ")", "\n"] + .iter() + .filter_map(|stop| rest.find(stop)) + .min(), + }; + hidden.push_str(""); + previous = Some('>'); + rest = &rest[end.unwrap_or(rest.len())..]; + } + hidden +} + +/// `backtrace`'s frames after the panic machinery's, at most `FRAMES`. +fn frames(backtrace: &str) -> String { + let starts = |line: &str| { + let line = line.trim_start(); + line.split_once(": ").is_some_and(|(number, _)| { + !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit()) + }) + }; + let lines: Vec<&str> = backtrace.lines().collect(); + let panicking = lines + .iter() + .rposition(|line| starts(line) && line.contains("panicking::")); + let mut kept = String::new(); + let mut count = 0; + for line in &lines[panicking.map_or(0, |at| at + 1)..] { + if starts(line) { + // A system library's frame, which says nothing without its symbols. + if line.ends_with(": ") { + continue; + } + count += 1; + if count > FRAMES || kept.len() > BACKTRACE { + kept.push_str(" …\n"); + break; + } + } else if count == 0 { + continue; + } + kept.push_str(line); + kept.push('\n'); + } + kept +} + +#[cfg(not(target_arch = "wasm32"))] +fn backtrace() -> String { + std::backtrace::Backtrace::force_capture().to_string() +} + +/// The browser's stack, as wasm32-unknown-unknown's std has no backtrace. +#[cfg(target_arch = "wasm32")] +fn backtrace() -> String { + let error = js_sys::Error::new(""); + js_sys::Reflect::get(&error, &"stack".into()) + .ok() + .and_then(|stack| stack.as_string()) + .unwrap_or_default() + .lines() + .enumerate() + .map(|(number, line)| format!("{number:4}: {}\n", line.trim())) + .collect() +} + +#[cfg(not(target_arch = "wasm32"))] +fn home() -> String { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }); + home.map(|home| home.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +#[cfg(target_arch = "wasm32")] +fn home() -> String { + String::new() +} + +#[cfg(not(target_arch = "wasm32"))] +fn keep(report: &str) { + if let Some(path) = REPORT.get() { + if let Some(folder) = path.parent() { + let _ = std::fs::create_dir_all(folder); + } + let _ = std::fs::write(path, report); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn kept() -> Option { + std::fs::read_to_string(REPORT.get()?).ok() +} + +#[cfg(not(target_arch = "wasm32"))] +fn forget() { + if let Some(path) = REPORT.get() { + let _ = std::fs::remove_file(path); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn send(report: String) { + // A development build may send to a site run locally. + let address = std::env::var("SNOWBOUND_CRASH_SITE") + .ok() + .filter(|_| cfg!(debug_assertions)) + .unwrap_or_else(|| ADDRESS.to_owned()); + std::thread::spawn(move || { + let sent = crate::update::agent(&address, std::time::Duration::from_secs(60)) + .post(&address) + .header("Content-Type", "text/plain; charset=utf-8") + .send(report.as_bytes()); + if let Err(error) = sent { + eprintln!("Cannot send the crash report: {error}"); + } + }); +} + +/// The browser keeps the report in local storage, which a panic can still reach. +#[cfg(target_arch = "wasm32")] +const STORED: &str = "snowbound-crash"; + +#[cfg(target_arch = "wasm32")] +fn storage() -> Option { + web_sys::window()?.local_storage().ok()? +} + +#[cfg(target_arch = "wasm32")] +fn keep(report: &str) { + if let Some(storage) = storage() { + let _ = storage.set_item(STORED, report); + } +} + +#[cfg(target_arch = "wasm32")] +fn kept() -> Option { + storage()?.get_item(STORED).ok()? +} + +#[cfg(target_arch = "wasm32")] +fn forget() { + if let Some(storage) = storage() { + let _ = storage.remove_item(STORED); + } +} + +#[cfg(target_arch = "wasm32")] +fn send(report: String) { + crate::platform::send_crash(&report); +} + +impl crate::State { + /// Asks whether to send the report the last run left, if it left one. + pub(crate) fn offer_crash_report(&self) { + let Some(report) = kept() else { + return; + }; + let shown = report.clone(); + let reply = self.reply(move |state, pressed| { + match pressed { + 0 => send(report), + 1 => state.show_crash_report(shown), + _ => {} + } + if pressed != 1 { + forget(); + } + Ok(()) + }); + crate::platform::choose( + "Snowbound quit unexpectedly last time.", + "Send a report to help fix the problem. It holds no notes or file names.", + &["Send Report", "Show Report", "Don't Send"], + reply, + ); + } + + fn show_crash_report(&self, report: String) { + let shown = report.clone(); + let reply = self.reply(move |_, pressed| { + if pressed == 0 { + send(report); + } + forget(); + Ok(()) + }); + crate::platform::choose( + "Crash report", + &shown, + &["Send Report", "Don't Send"], + reply, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_hide_the_home_folder_paths_and_names() { + let names = vec!["Work Notes".to_owned(), "Meetings".to_owned()]; + let scrub = |text| scrub(text, "/Users/ada", &names); + assert_eq!( + scrub( + r#"called `Result::unwrap()` on an `Err` value: Io { path: "/Users/ada/OneNote Notebooks/Work Notes/To Do.one", kind: NotFound }"# + ), + r#"called `Result::unwrap()` on an `Err` value: Io { path: "", kind: NotFound }"# + ); + assert_eq!( + scrub("Cannot read /Volumes/Share/Shared Notes/a.one: denied"), + "Cannot read : denied" + ); + assert_eq!( + scrub(r"Cannot read C:\Users\ada\Notes\b.one, retrying"), + "Cannot read , retrying" + ); + assert_eq!(scrub("opening smb://nas/notes/x.one"), "opening smb:"); + assert_eq!( + scrub("section Meetings of Work Notes is locked"), + "section of is locked" + ); + // Sources stay, the home folder as ~. + assert_eq!( + scrub("at /Users/ada/.cargo/registry/src/winit-0.30/src/lib.rs:12:5"), + "at ~/.cargo/registry/src/winit-0.30/src/lib.rs:12:5" + ); + assert_eq!( + scrub("index out of bounds: the len is 3 but the index is 5"), + "index out of bounds: the len is 3 but the index is 5" + ); + } + + #[test] + fn names_are_kept_from_a_sections_path() { + conceal("Projects/Snow Plan.one"); + let names = NAMES.lock().unwrap().clone(); + assert!(names.contains(&"Projects".to_owned()), "{names:?}"); + assert!(names.contains(&"Snow Plan".to_owned()), "{names:?}"); + assert!(!names.iter().any(|name| name.ends_with(".one"))); + } + + #[test] + fn backtraces_start_past_the_panic() { + let backtrace = " 0: std::backtrace::Backtrace::force_capture + 1: snowbound::crash::hook::{{closure}} + at ./src/crash.rs:30:9 + 2: std::panicking::rust_panic_with_hook + 3: core::panicking::panic_fmt + 4: snowbound::State::frame + at ./src/main.rs:1500:13 + 5: + 6: main +"; + assert_eq!( + frames(backtrace), + " 4: snowbound::State::frame\n at ./src/main.rs:1500:13\n 6: main\n" + ); + let deep: String = (0..100) + .map(|frame| format!("{frame:4}: f{frame}\n")) + .collect(); + let kept = frames(&deep); + assert_eq!(kept.lines().count(), FRAMES + 1); + assert!(kept.ends_with("…\n")); + } + + /// A report written by a panic is what the next launch finds, until it is forgotten. + #[test] + fn a_report_outlives_the_run_until_answered() { + let folder = std::env::temp_dir().join(format!("snowbound-crash-{}", std::process::id())); + let _ = REPORT.set(folder.join("crash.txt")); + let report = REPORT.get().unwrap(); + let _ = std::fs::remove_file(report); + assert_eq!(kept(), None); + keep("Snowbound development\nPanic: x"); + assert_eq!(kept().as_deref(), Some("Snowbound development\nPanic: x")); + forget(); + assert_eq!(kept(), None); + std::fs::remove_dir_all(folder).unwrap(); + } +} diff --git a/crates/snowbound/src/desktop_linux.rs b/crates/snowbound/src/desktop_linux.rs index 5aa3c1a9463d7eb409e2977a5cd254c76a12a85f..e78065600be2b115a8c22b5d58de03559b053cd8 100644 --- a/crates/snowbound/src/desktop_linux.rs +++ b/crates/snowbound/src/desktop_linux.rs @@ -488,7 +488,7 @@ pub fn uninstall(proxy: &winit::event_loop::EventLoopProxy) { remove(); Ok(()) }); - crate::platform::confirm( + crate::confirm( "Uninstall Snowbound?", &detail, "Cancel", diff --git a/crates/snowbound/src/dialog_linux.rs b/crates/snowbound/src/dialog_linux.rs index 5434d0024643590f2f6d81ac01182a62daa0dc52..c88d7ff643641837569938ebe7f7b0e599ecb478 100644 --- a/crates/snowbound/src/dialog_linux.rs +++ b/crates/snowbound/src/dialog_linux.rs @@ -11,11 +11,10 @@ use winit::keyboard::NamedKey; pub enum Ask { /// A message with an OK button. Message, - /// Whether to go ahead: buttons for `cancel` and for `action`. - Question { - cancel: String, - action: String, - reply: Reply<()>, + /// A button for each of `buttons`, the last the safe answer; replies with the one pressed. + Choice { + buttons: Vec, + reply: Reply, }, /// A line of text, starting as `text`. Entry { text: String, reply: Reply }, @@ -109,6 +108,8 @@ fn field() -> Id { enum Answer { Cancel, Accept, + /// A choice's button. + Pressed(usize), /// A file chooser moves to this folder. Open(PathBuf), /// A file chooser's file to open, or the name to save as. @@ -182,10 +183,10 @@ impl State { pad: [6.0, 0.0], ..Spec::default() }; - // Return goes ahead, but for a question, whose safe answer is the default as - // AppKit's and Windows' are. - let mut answer = entered.then_some(match dialog.ask { - Ask::Question { .. } => Answer::Cancel, + // Return goes ahead, but for a choice, whose safe answer is the default as AppKit's + // and Windows' are. + let mut answer = entered.then_some(match &dialog.ask { + Ask::Choice { buttons, .. } => Answer::Pressed(buttons.len() - 1), _ => Answer::Accept, }); match &mut dialog.ask { @@ -260,7 +261,7 @@ impl State { crate::name(ui, field(), "Name"); } } - Ask::Message | Ask::Question { .. } => {} + Ask::Message | Ask::Choice { .. } => {} } ui.open( "buttons", @@ -284,18 +285,22 @@ impl State { ..Spec::default() }, ); - let (cancel, action) = match &dialog.ask { - Ask::Message => (None, "OK"), - Ask::Question { cancel, action, .. } => (Some(cancel.as_str()), action.as_str()), - Ask::Entry { .. } => (Some("Cancel"), "OK"), - Ask::File { save: false, .. } => (Some("Cancel"), "Open"), - Ask::File { save: true, .. } => (Some("Cancel"), "Save"), + // Left to right, the safe answer first, as GNOME and KDE place Cancel. + let buttons: Vec<(&str, Answer)> = match &dialog.ask { + Ask::Message => vec![("OK", Answer::Accept)], + Ask::Choice { buttons, .. } => (buttons.iter().enumerate().rev()) + .map(|(index, button)| (button.as_str(), Answer::Pressed(index))) + .collect(), + Ask::Entry { .. } => vec![("Cancel", Answer::Cancel), ("OK", Answer::Accept)], + Ask::File { save, .. } => vec![ + ("Cancel", Answer::Cancel), + (if *save { "Save" } else { "Open" }, Answer::Accept), + ], }; - if cancel.is_some_and(|cancel| ui::button(ui, "cancel", cancel).clicked) { - answer = Some(Answer::Cancel); - } - if ui::button(ui, "action", action).clicked { - answer = Some(Answer::Accept); + for (index, (button, pressed)) in buttons.into_iter().enumerate() { + if ui::button(ui, ("button", index), button).clicked { + answer = Some(pressed); + } } ui.close(); ui.close(); @@ -343,7 +348,11 @@ impl State { } match dialog.ask { Ask::Message => {} - Ask::Question { reply, .. } => reply.send(()), + Ask::Choice { reply, .. } => { + if let Answer::Pressed(index) = answer { + reply.send(index); + } + } Ask::Entry { text, reply } => reply.send(text), Ask::File { folder, diff --git a/crates/snowbound/src/history.rs b/crates/snowbound/src/history.rs index e77e686e5da93c0de7e593adc8db5aab12f1b1c7..2380a6fa361ea257c644316c9e50f4d0fac2798f 100644 --- a/crates/snowbound/src/history.rs +++ b/crates/snowbound/src/history.rs @@ -208,7 +208,7 @@ impl State { Scope::Notebook => ("notebook", session.library.name.clone()), }; let asked = Arc::clone(&session.library); - platform::confirm( + crate::confirm( &format!("Do you want to delete all page versions in the {container} \"{name}\"?"), "You can't restore these versions afterward.", "Cancel", diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index c982db6c66f56685ceeefc807953928d26d6f408..a5f545a56ac53820fa355f0dacf7c3ba28f89938 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -341,6 +341,7 @@ pub struct Keys(Mutex>); impl Library { /// `location` named `name`, with no notebook read, server, sync or kept sections. fn new(location: &str, name: String, cache: &Path) -> Self { + crate::crash::conceal(&name); Self { location: location.to_owned(), name, @@ -807,6 +808,7 @@ impl Library { notify: Box, deadline: Instant, ) -> Result> { + crate::crash::conceal(path); let notify = Arc::new(Mutex::new(notify)); let notifier = || { let notify = Arc::clone(¬ify); diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 1e330dcdc09801059929e7eb6472cae4964413f7..4c6fec45551ee540471a0c17352306b480acdce3 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -828,15 +828,18 @@ fn log_crashes() { let field = |field: &[libc::c_char]| unsafe { CStr::from_ptr(field.as_ptr()) }.to_string_lossy(); let session = |name| std::env::var(name).unwrap_or_default(); - let _ = writeln!( - log, - "Snowbound {} on Linux {} {}, {} {}", - option_env!("SNOWBOUND_BUILD").unwrap_or("development"), + let described = format!( + "Linux {} {}, {} {}", field(&system.release), field(&system.machine), session("XDG_SESSION_TYPE"), session("XDG_CURRENT_DESKTOP"), ); + let _ = writeln!( + log, + "Snowbound {} on {described}", + option_env!("SNOWBOUND_BUILD").unwrap_or("development"), + ); let mut stderr: libc::stat = unsafe { std::mem::zeroed() }; let seen = unsafe { libc::isatty(2) } == 1 || unsafe { libc::fstat(2, &mut stderr) } == 0 @@ -848,15 +851,9 @@ fn log_crashes() { None }; let _ = LOG.set((path, copy)); - std::panic::set_hook(Box::new(|info| { - let thread = std::thread::current(); - let backtrace = std::backtrace::Backtrace::force_capture(); - let report = format!( - "Thread {:?} {info}\n{backtrace}\n", - thread.name().unwrap_or("") - ); + crate::crash::hook(described, |report| { crashed(|fd| write_all(fd, report.as_bytes())); - })); + }); if let Ok(path) = crate::loader::executable() && let Ok(path) = CString::new(path.into_os_string().into_vec()) { @@ -1595,7 +1592,7 @@ pub fn pick_file(title: &str, types: &[&str], reply: Reply) { options.insert("filters", filters(&patterns, &globs)); } let types = types.iter().map(|kind| kind.to_string()).collect(); - choose( + pick( title, "OpenFile", options, @@ -1618,7 +1615,7 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> { /// Replies with the file the file chooser portal answers `method` with, titled `title`, or /// where no portal answers, Snowbound's `own`. -fn choose( +fn pick( title: &str, method: &'static str, mut options: HashMap<&'static str, Value<'static>>, @@ -1747,14 +1744,15 @@ pub fn reveal(target: impl AsRef) { } } -/// Asks whether to go ahead with `action`, offering `cancel` first. -pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { - let ask = Ask::Question { - cancel: cancel.into(), - action: action.into(), - reply, - }; - crate::dialog::show(message.into(), detail.into(), ask); +/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies +/// with the one pressed. +pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply) { + let buttons = buttons.iter().map(|&button| button.to_owned()).collect(); + crate::dialog::show( + message.into(), + detail.into(), + Ask::Choice { buttons, reply }, + ); } /// 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) { let name = "OneNote notebooks, sections and packages"; let types = ["onetoc2", "one", "onepkg"]; let globs = types.map(|kind| format!("*.{kind}")); - choose( + pick( title, "OpenFile", HashMap::from([("filters", filters(name, &globs))]), @@ -1800,7 +1798,7 @@ pub fn pick_new( name.to_owned(), folder.map_or_else(crate::dialog::start_folder, Into::into), ); - choose( + pick( title, "SaveFile", options, diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 40138b491265420dea41561963f1f15e2d20593c..0be2690ecf7452fcf6a97ded65370997bb9f4a0a 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -9,10 +9,9 @@ use objc2::{ sel, }; use objc2_app_kit::{ - NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor, - NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, - NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel, - NSModalResponseOK, NSSavePanel, NSWindow, + NSAlert, NSAlertFirstButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker, + NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventSubtype, NSEventType, NSMenu, + NSMenuItem, NSModalResponse, NSModalResponseCancel, NSModalResponseOK, NSSavePanel, NSWindow, }; use objc2_foundation::{ MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, @@ -33,7 +32,14 @@ use winit::{ /// Runs `run` inside an autorelease pool. 10.6 has none outside NSApplication's run loop, /// and quitting releases the windows after it returns. pub fn with_pool(run: impl FnOnce() -> R) -> R { - objc2::rc::autoreleasepool(|_| run()) + objc2::rc::autoreleasepool(|_| { + let version: Retained = unsafe { + let info: Retained = msg_send_id![class!(NSProcessInfo), processInfo]; + msg_send_id![&info, operatingSystemVersionString] + }; + crate::crash::hook(format!("macOS {version}"), |report| eprint!("{report}")); + run() + }) } pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface}; @@ -1648,18 +1654,23 @@ pub fn reveal(target: impl AsRef) { } } -/// Asks whether to go ahead with `action`, offering `cancel` first. -pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { +/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies +/// with the one pressed. AppKit places the last first, on Return. +pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply) { let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); + let count = buttons.len(); unsafe { let alert = NSAlert::new(mtm); alert.setMessageText(&NSString::from_str(message)); alert.setInformativeText(&NSString::from_str(detail)); - alert.addButtonWithTitle(&NSString::from_str(cancel)); - alert.addButtonWithTitle(&NSString::from_str(action)); - begin_alert(&alert, |response| { - if response == NSAlertSecondButtonReturn { - reply.send(()); + for button in buttons.iter().rev() { + alert.addButtonWithTitle(&NSString::from_str(button)); + } + begin_alert(&alert, move |response| { + if let Ok(added) = usize::try_from(response - NSAlertFirstButtonReturn) + && added < count + { + reply.send(count - 1 - added); } }); } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 7cd5758ed65b078d944808da777fe4fc373d3618..f9a2b42a2d8fd694b05cea9a225eb00673999526 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -9,6 +9,7 @@ mod background; mod commands; #[cfg(all(test, feature = "wgpu", not(windows)))] mod conflict_render; +mod crash; #[cfg(target_os = "linux")] #[path = "desktop_linux.rs"] mod desktop; @@ -265,6 +266,16 @@ impl Reply { } } +/// Asks whether to go ahead with `action`, offering `cancel`, the default, too. +fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { + let pressed = Reply(Box::new(move |pressed| { + if pressed == 0 { + reply.send(()); + } + })); + platform::choose(message, detail, &[action, cancel], pressed); +} + enum UserEvent { Quit, /// Quits without asking, as after the user agreed to discard a temporary page. @@ -1035,6 +1046,9 @@ async fn start_surface( match surface::Surface::new(window.clone(), backdrop, backend).await { Ok((surface, renderer, adapter)) => { note(&format!("Drawing with {} ({adapter})", backend.label())); + if let Ok(mut renderer) = crash::RENDERER.lock() { + *renderer = format!("{} ({adapter})", backend.label()); + } unavailable.retain(|(failed, _)| *failed != backend); return Ok((surface, renderer, (backend, adapter))); } @@ -6351,7 +6365,7 @@ impl App { }) else { return self.exit(event_loop); }; - platform::confirm( + crate::confirm( "Discard this page?", "This temporary page has no saved copy. Closing it will discard your edits.", "Keep Editing", @@ -6436,6 +6450,14 @@ impl ApplicationHandler for App { for path in self.opening.drain(..) { state.open_path(&path); } + state.offer_crash_report(); + if cfg!(debug_assertions) && std::env::var_os("SNOWBOUND_CRASH").is_some() { + let open = state.session.as_ref().map(|session| &session.library); + panic!( + "SNOWBOUND_CRASH asked to crash with {:?} open", + open.map(|library| &library.location) + ); + } self.state = Some(state); } }, @@ -6948,12 +6970,16 @@ fn launch() -> Result<(), Box> { event_loop.create_proxy(), )?; } + // A screenshot leaves the settings as it found them. + let settings_file = settings_file.filter(|_| screenshot.is_none()); + if let Some(file) = &settings_file { + let _ = crash::REPORT.set(file.with_file_name("crash.txt")); + } let mut app = App { proxy: event_loop.create_proxy(), input: Some(input), - // A screenshot leaves the settings as it found them. launch: Some(settings::Launch { - file: settings_file.filter(|_| screenshot.is_none()), + file: settings_file, saved, renderer, cache, diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index 0b1c9b864ef178041936ab75caf42e49da2565a0..d0a003b7241c284485c25da2d44afd6ee7f4228d 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -593,7 +593,7 @@ impl State { }; detail += &format!(" {changes} not yet saved to its files will be lost."); } - platform::confirm( + crate::confirm( &format!("Delete “{}”?", library.name), &detail, "Cancel", diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index d100d1b7e8a07b407fcaee17bf923d5c66fa5a8e..56bc0877cc5728ac471afcbaec5ee1c24e0f0282 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -867,7 +867,7 @@ impl State { } (Target::Section { library, path }, Action::Delete) => { let name = crate::library::entry_name(&path).to_owned(); - platform::confirm( + crate::confirm( "Are you sure you want to move this section to this notebook's Recycle Bin?", &name, "Cancel", @@ -882,7 +882,7 @@ impl State { } (Target::Group { library, path }, Action::Delete) => { let name = crate::library::entry_name(&path).to_owned(); - platform::confirm( + crate::confirm( "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?", &name, "Cancel", diff --git a/crates/snowbound/src/recycle.rs b/crates/snowbound/src/recycle.rs index d963becd9775417ba0af14f8632a75369966bf8e..bf0f942e8e7ad9f872772ff46c9eedd48cbc247f 100644 --- a/crates/snowbound/src/recycle.rs +++ b/crates/snowbound/src/recycle.rs @@ -76,7 +76,7 @@ impl State { /// Empty Recycle Bin on `library`, once confirmed as OneNote asks. pub(crate) fn empty_recycle_bin(&mut self, library: Arc) { - crate::platform::confirm( + crate::confirm( "Are you sure you want to empty the Recycle Bin for this notebook?", "Its pages and sections are deleted for good.", "Cancel", @@ -95,7 +95,7 @@ impl State { return self.recycle_now(request); }; let spaces = spaces.clone(); - crate::platform::confirm( + crate::confirm( "Are you sure you want to delete this page for good?", "It can't be restored.", "Cancel", diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index b8dd22af8c72a94af5bd989660da880c2f56d5a8..cc771e0dba2dcabecf094443aa70e6f5c40e94ee 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -47,7 +47,7 @@ fn running() -> Option { /// This build's platform, as `latest.json` and `build.json` key their entries; Apple /// silicon's for an Intel build Rosetta runs. -fn platform() -> String { +pub(crate) fn platform() -> String { if translated() { "macos-aarch64".to_owned() } else if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") { @@ -561,6 +561,24 @@ fn download(_: &str, _: u64) -> Result, String> { /// The `Fetch` the app uses. #[cfg(not(target_arch = "wasm32"))] fn download(path: &str, limit: u64) -> Result, String> { + agent(BASE, Duration::from_secs(10 * 60)) + .get(&format!("{BASE}{path}")) + .call() + .and_then(|mut response| { + // ureq refuses a body that reaches its limit, so an exact size needs one byte more. + response + .body_mut() + .with_config() + .limit(limit + 1) + .read_to_vec() + }) + .map_err(|error| error.to_string()) +} + +/// An HTTP client for `address`, through the proxy the system names for it, trusting the +/// system's certificate authorities and Mozilla's, giving up after `timeout`. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn agent(address: &str, timeout: Duration) -> ureq::Agent { let system = rustls_native_certs::load_native_certs().certs; let bundled = webpki_root_certs::TLS_SERVER_ROOT_CERTS; let roots = (system.iter().chain(bundled)) @@ -570,7 +588,10 @@ fn download(path: &str, limit: u64) -> Result, String> { // environment's. #[cfg(feature = "live")] let proxy = { - let host = BASE.trim_start_matches("https://").split('/').next(); + let host = address + .split("://") + .nth(1) + .and_then(|rest| rest.split(['/', ':']).next()); notebook::live::proxy::for_host(host.unwrap_or_default(), true).and_then(|proxy| { let credentials = (proxy.credentials.as_ref()) .map_or_else(String::new, |(name, password)| { @@ -580,30 +601,21 @@ fn download(path: &str, limit: u64) -> Result, String> { }) }; #[cfg(not(feature = "live"))] - let proxy = ureq::Proxy::try_from_env(); - let agent: ureq::Agent = ureq::Agent::config_builder() + let proxy = { + let _ = address; + ureq::Proxy::try_from_env() + }; + ureq::Agent::config_builder() .proxy(proxy) .tls_config( TlsConfig::builder() .root_certs(RootCerts::Specific(Arc::new(roots))) .build(), ) - .timeout_global(Some(Duration::from_secs(10 * 60))) + .timeout_global(Some(timeout)) .timeout_connect(Some(Duration::from_secs(30))) .build() - .into(); - agent - .get(&format!("{BASE}{path}")) - .call() - .and_then(|mut response| { - // ureq refuses a body that reaches its limit, so an exact size needs one byte more. - response - .body_mut() - .with_config() - .limit(limit + 1) - .read_to_vec() - }) - .map_err(|error| error.to_string()) + .into() } #[derive(Default)] @@ -744,7 +756,7 @@ impl State { return; }; match status { - Status::Ready(version, _, changes) => platform::confirm( + Status::Ready(version, _, changes) => crate::confirm( "Update ready", &described( format!("Snowbound {version} is ready to install."), @@ -757,7 +769,7 @@ impl State { Ok(()) }), ), - Status::Available(version, changes) => platform::confirm( + Status::Available(version, changes) => crate::confirm( "Update available", &described( format!("Download Snowbound {version} from its build folder."), diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index 8ecf705998a7e34653462960c8e26a2c04805f59..66f39afd4f000f8dd6b325462c8346a8a9a27e88 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -60,9 +60,9 @@ extern "C" { fn date_strings(ms: f64) -> Vec; #[wasm_bindgen(js_name = shortDate)] fn short_date_string(ms: f64) -> String; - /// Resolves to whether the user chose `action` over `cancel`. - #[wasm_bindgen(js_name = askConfirm)] - fn ask_confirm(message: &str, detail: &str, cancel: &str, action: &str) -> js_sys::Promise; + /// Resolves to the index of the button in `buttons` pressed, or -1. + #[wasm_bindgen(js_name = askChoice)] + fn ask_choice(message: &str, detail: &str, buttons: Vec) -> js_sys::Promise; /// Resolves to the text the user enters, starting as `value`; undefined when cancelled. #[wasm_bindgen(js_name = askText)] fn ask_text(message: &str, value: &str) -> js_sys::Promise; @@ -70,6 +70,9 @@ extern "C" { fn tell(message: &str, detail: &str); #[wasm_bindgen(js_name = openLink)] fn open_link(url: &str); + /// Posts a crash report to the site the page came from. + #[wasm_bindgen(js_name = sendCrash)] + pub fn send_crash(report: &str); /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length, /// [[offset, bytes], ...]]` a file's new length and the ranges that changed; with a /// 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 }); } -pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { - answered( - ask_confirm(message, detail, cancel, action), - move |chosen| { - if chosen.is_truthy() { - reply.send(()); - } - }, - ); +/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies +/// with the one pressed. +pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply) { + let buttons = buttons.iter().map(|&button| button.to_owned()).collect(); + answered(ask_choice(message, detail, buttons), move |pressed| { + if let Some(pressed) = pressed.as_f64().filter(|&pressed| pressed >= 0.0) { + reply.send(pressed as usize); + } + }); } pub fn alert(message: &str, detail: &str) { @@ -932,9 +935,11 @@ pub async fn start( fonts: Vec, dictionaries: Vec, ) -> Result<(), JsValue> { - std::panic::set_hook(Box::new(|info| report(info))); let window = web_sys::window().ok_or("No window")?; let navigator = window.navigator(); + crate::crash::hook(navigator.user_agent().unwrap_or_default(), |text| { + report(text) + }); MAC.set(navigator.platform().is_ok_and(|platform| { ["Mac", "iPhone", "iPad"] .iter() @@ -977,6 +982,7 @@ pub async fn start( JsValue::from_str(&error.to_string()) })?; state.surface.show(); + state.offer_crash_report(); // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases // the rest in. state.full_page = true; @@ -1631,7 +1637,7 @@ pub fn check_for_update(asked: bool) { update_now(state); Ok(()) }); - confirm( + crate::confirm( "Update ready", "Reload to use the newest Snowbound.", "Later", diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index a9e8f24c192d798a9cd66b948835e08d4c9df12a..50d0e4dea5970ace578940324cb58f50c0b557bf 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -942,13 +942,8 @@ pub fn with_pool( option_env!("SNOWBOUND_BUILD").unwrap_or("development") ); let shown = details.clone(); - std::panic::set_hook(Box::new(move |info| { - let thread = std::thread::current(); - let backtrace = std::backtrace::Backtrace::force_capture(); - eprintln!( - "Thread {:?} {info}\n{backtrace}", - thread.name().unwrap_or("") - ); + crate::crash::hook(format!("Windows {major}.{minor}.{build}"), move |report| { + eprint!("{report}"); // The panic aborts the process once this returns, taking an alert's thread with it. if cfg!(panic = "abort") && let Some(details) = &shown @@ -962,7 +957,7 @@ pub fn with_pool( wm::MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), style) }; } - })); + }); unsafe { windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler(0, Some(fault)) }; @@ -1759,15 +1754,17 @@ pub fn alert(message: &str, detail: &str) { }); } -/// Asks whether to go ahead with `action`, offering `cancel` too. -pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { - let [message, detail, cancel, action] = [message, detail, cancel, action].map(str::to_owned); - reply.after(move || ask(&message, &detail, &cancel, &action).then_some(())); +/// Asks `message` with a button for each of `buttons`, the last the safe answer, and replies +/// with the one pressed. +pub fn choose(message: &str, detail: &str, buttons: &[&str], reply: Reply) { + let [message, detail] = [message, detail].map(str::to_owned); + let buttons: Vec = buttons.iter().map(|&button| button.to_owned()).collect(); + reply.after(move || ask(&message, &detail, &buttons)); } -/// Whether the user goes ahead with `action`: a task dialog with buttons for it and `cancel` -/// where the common controls have one, as from Windows Vista, else OK and Cancel. -fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool { +/// The button pressed: a task dialog with `buttons` left to right where the common controls +/// have one, as from Windows Vista, else OK, the first, and Cancel. +fn ask(message: &str, detail: &str, buttons: &[String]) -> Option { use windows_sys::Win32::UI::Controls as controls; type Indirect = unsafe extern "system" fn( *const controls::TASKDIALOGCONFIG, @@ -1775,20 +1772,17 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool { *mut i32, *mut BOOL, ) -> i32; - let [title, instruction, content, cancel_text, action_text] = - ["Snowbound", message, detail, cancel, action].map(wide); + let [title, instruction, content] = ["Snowbound", message, detail].map(wide); if let Some(indirect) = function::("comctl32.dll", c"TaskDialogIndirect") { - const ACTION: i32 = 100; - let buttons = [ - controls::TASKDIALOG_BUTTON { - nButtonID: ACTION, - pszButtonText: action_text.as_ptr(), - }, - controls::TASKDIALOG_BUTTON { - nButtonID: wm::IDCANCEL, - pszButtonText: cancel_text.as_ptr(), - }, - ]; + const FIRST: i32 = 100; + let texts: Vec<_> = buttons.iter().map(|button| wide(button.as_str())).collect(); + let buttons: Vec<_> = (FIRST..) + .zip(&texts) + .map(|(id, text)| controls::TASKDIALOG_BUTTON { + nButtonID: id, + pszButtonText: text.as_ptr(), + }) + .collect(); let mut config: controls::TASKDIALOGCONFIG = unsafe { std::mem::zeroed() }; config.cbSize = size_of::() as u32; config.hwndParent = owner(); @@ -1798,7 +1792,7 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool { config.pszContent = content.as_ptr(); config.cButtons = buttons.len() as u32; config.pButtons = buttons.as_ptr(); - config.nDefaultButton = wm::IDCANCEL; + config.nDefaultButton = FIRST + buttons.len() as i32 - 1; let mut pressed = 0; let shown = unsafe { indirect( @@ -1809,7 +1803,9 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool { ) }; if shown >= 0 { - return pressed == ACTION; + return usize::try_from(pressed - FIRST) + .ok() + .filter(|&pressed| pressed < buttons.len()); } } let text = wide(format!("{message}\n\n{detail}")); @@ -1821,7 +1817,7 @@ fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool { wm::MB_OKCANCEL | wm::MB_ICONQUESTION | wm::MB_DEFBUTTON2, ) }; - answer == wm::IDOK + (answer == wm::IDOK).then_some(0) } /// The common controls' date and time picker in a dialog of its own. diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index 184a7f3e908fb4807666d597d532699d99cdbd7e..7575cbc566308540b6df2767cfd651ebbc8a798f 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -472,8 +472,8 @@ function ask(message, detail, buttons, value) { ); } -export function askConfirm(message, detail, cancel, action) { - return ask(message, detail, [action, cancel]).then(([pressed]) => pressed === 0); +export function askChoice(message, detail, buttons) { + return ask(message, detail, buttons).then(([pressed]) => pressed); } export function askText(message, value) { @@ -486,6 +486,11 @@ export function tell(message, detail) { ask(message, detail, ["OK"]); } +export function sendCrash(report) { + fetch("/crash", { method: "POST", headers: { "Content-Type": "text/plain; charset=utf-8" }, body: report }) + .catch((error) => console.error("Cannot send the crash report", error)); +} + export function openLink(url) { open(url, "_blank", "noopener"); }