| author | |
| committer | |
| log | 8e66d05cf42adad1486ec803fbc07da2bd880289 |
| tree | 8e569d7f4da4d3394dc66b6a1382166006c59d14 |
| parent | fcec92e2decf2a994e4c6754a59ff7cfb4e25004 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.517 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 the | 30 | (each over the last) picks one, and one that fails to start falls back to the |
| 31 | default and says so. Choosing another in Options starts it at once: the renderer and | 31 | default and says so. Choosing another in Options starts it at once: the renderer and |
| 32 | surface go and new ones begin, the window and everything in it staying as they are. | 32 | surface go and new ones begin, the window and everything in it staying as they are. |
| 33 | A panic writes a crash report beside the settings before the process ends (`crash.rs`): | ||
| 34 | the build, system, renderer, thread, uptime, panic and backtrace, with paths and the | ||
| 35 | open notebooks' and sections' names hidden; the browser keeps it in local storage. The next | ||
| 36 | launch asks whether to send it to the site's `POST /crash`, shows the exact text on Show | ||
| 37 | Report, and sends nothing unless Send Report is chosen; either answer deletes it. A run | ||
| 38 | with 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 on | 39 | `commands.rs` is the one table of commands: each one's title, its chords on |
| 34 | macOS and elsewhere, when it is enabled or checked, and what it does. The | 40 | macOS and elsewhere, when it is enabled or checked, and what it does. The |
| 35 | keyboard, the toolbar and the macOS menu bar all run commands from it. | 41 | keyboard, 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" |
| 57 | wasm-bindgen = "0.2" | 57 | wasm-bindgen = "0.2" |
| 58 | wasm-bindgen-futures = "0.4" | 58 | wasm-bindgen-futures = "0.4" |
| 59 | js-sys = "0.3" | 59 | js-sys = "0.3" |
| 60 | web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Node", "Window"] } | 60 | web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Node", "Storage", "Window"] } |
| 61 | 61 | ||
| 62 | [target.'cfg(target_os = "macos")'.dependencies] | 62 | [target.'cfg(target_os = "macos")'.dependencies] |
| 63 | libc = "0.2" | 63 | libc = "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 | |||
| 6 | use std::path::PathBuf; | ||
| 7 | use std::sync::atomic::{AtomicBool, Ordering}; | ||
| 8 | use std::sync::{Mutex, OnceLock}; | ||
| 9 | use 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. | ||
| 13 | pub static REPORT: OnceLock<PathBuf> = OnceLock::new(); | ||
| 14 | /// The backend drawing and its adapter, as "Metal (Apple M2)". | ||
| 15 | pub static RENDERER: Mutex<String> = Mutex::new(String::new()); | ||
| 16 | /// The notebooks, section groups and sections opened this run, which a report hides. | ||
| 17 | static NAMES: Mutex<Vec<String>> = Mutex::new(Vec::new()); | ||
| 18 | static STARTED: OnceLock<Instant> = OnceLock::new(); | ||
| 19 | /// The first panic's report is kept; the ones it sets off would only hide it. | ||
| 20 | static KEPT: AtomicBool = AtomicBool::new(false); | ||
| 21 | |||
| 22 | const ADDRESS: &str = "https://snowbound.paperclover.net/crash"; | ||
| 23 | /// Bounds on what a panic adds, so a report stays well under what the site takes. | ||
| 24 | const MESSAGE: usize = 2 << 10; | ||
| 25 | const FRAMES: usize = 48; | ||
| 26 | const BACKTRACE: usize = 32 << 10; | ||
| 27 | const NAMED: usize = 256; | ||
| 28 | |||
| 29 | /// Reports each panic on `system`, then hands the report to `then` to log. | ||
| 30 | pub 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. | ||
| 72 | pub 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>`. | ||
| 92 | fn 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 | |||
| 104 | fn 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`. | ||
| 149 | fn 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"))] | ||
| 183 | fn 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")] | ||
| 189 | fn 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"))] | ||
| 202 | fn 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")] | ||
| 209 | fn home() -> String { | ||
| 210 | String::new() | ||
| 211 | } | ||
| 212 | |||
| 213 | #[cfg(not(target_arch = "wasm32"))] | ||
| 214 | fn 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"))] | ||
| 224 | fn kept() -> Option<String> { | ||
| 225 | std::fs::read_to_string(REPORT.get()?).ok() | ||
| 226 | } | ||
| 227 | |||
| 228 | #[cfg(not(target_arch = "wasm32"))] | ||
| 229 | fn forget() { | ||
| 230 | if let Some(path) = REPORT.get() { | ||
| 231 | let _ = std::fs::remove_file(path); | ||
| 232 | } | ||
| 233 | } | ||
| 234 | |||
| 235 | #[cfg(not(target_arch = "wasm32"))] | ||
| 236 | fn 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")] | ||
| 255 | const STORED: &str = "snowbound-crash"; | ||
| 256 | |||
| 257 | #[cfg(target_arch = "wasm32")] | ||
| 258 | fn storage() -> Option<web_sys::Storage> { | ||
| 259 | web_sys::window()?.local_storage().ok()? | ||
| 260 | } | ||
| 261 | |||
| 262 | #[cfg(target_arch = "wasm32")] | ||
| 263 | fn keep(report: &str) { | ||
| 264 | if let Some(storage) = storage() { | ||
| 265 | let _ = storage.set_item(STORED, report); | ||
| 266 | } | ||
| 267 | } | ||
| 268 | |||
| 269 | #[cfg(target_arch = "wasm32")] | ||
| 270 | fn kept() -> Option<String> { | ||
| 271 | storage()?.get_item(STORED).ok()? | ||
| 272 | } | ||
| 273 | |||
| 274 | #[cfg(target_arch = "wasm32")] | ||
| 275 | fn forget() { | ||
| 276 | if let Some(storage) = storage() { | ||
| 277 | let _ = storage.remove_item(STORED); | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 281 | #[cfg(target_arch = "wasm32")] | ||
| 282 | fn send(report: String) { | ||
| 283 | crate::platform::send_crash(&report); | ||
| 284 | } | ||
| 285 | |||
| 286 | impl 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)] | ||
| 331 | mod 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; |
| 11 | pub enum Ask { | 11 | pub 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 { |
| 109 | enum Answer { | 108 | enum 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 as | 186 | // 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)>>); |
| 341 | impl Library { | 341 | impl 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) } == 1 | 844 | let seen = unsafe { libc::isatty(2) } == 1 |
| 842 | || unsafe { libc::fstat(2, &mut stderr) } == 0 | 845 | || unsafe { libc::fstat(2, &mut stderr) } == 0 |
| ... | @@ -848,15 +851,9 @@ fn log_crashes() { | ... | @@ -848,15 +851,9 @@ fn log_crashes() { |
| 848 | None | 851 | 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> { |
| 1618 | 1615 | ||
| 1619 | /// Replies with the file the file chooser portal answers `method` with, titled `title`, or | 1616 | /// 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`. |
| 1621 | fn choose( | 1618 | fn 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 | } |
| 1749 | 1746 | ||
| 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 |
| 1751 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { | 1748 | /// with the one pressed. |
| 1752 | let ask = Ask::Question { | 1749 | pub 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 | } |
| 1759 | 1757 | ||
| 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 | }; |
| 11 | use objc2_app_kit::{ | 11 | use 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 | }; |
| 17 | use objc2_foundation::{ | 16 | use 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. |
| 35 | pub fn with_pool<R>(run: impl FnOnce() -> R) -> R { | 34 | pub 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 | } |
| 38 | 44 | ||
| 39 | pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface}; | 45 | pub 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 | } |
| 1650 | 1656 | ||
| 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 |
| 1652 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { | 1658 | /// with the one pressed. AppKit places the last first, on Return. |
| 1659 | pub 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; |
| 9 | mod commands; | 9 | mod commands; |
| 10 | #[cfg(all(test, feature = "wgpu", not(windows)))] | 10 | #[cfg(all(test, feature = "wgpu", not(windows)))] |
| 11 | mod conflict_render; | 11 | mod conflict_render; |
| 12 | mod crash; | ||
| 12 | #[cfg(target_os = "linux")] | 13 | #[cfg(target_os = "linux")] |
| 13 | #[path = "desktop_linux.rs"] | 14 | #[path = "desktop_linux.rs"] |
| 14 | mod desktop; | 15 | mod 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 | } |
| 267 | 268 | ||
| 269 | /// Asks whether to go ahead with `action`, offering `cancel`, the default, too. | ||
| 270 | fn 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 | |||
| 268 | enum UserEvent { | 279 | enum 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 { |
| 76 | 76 | ||
| 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> { |
| 47 | 47 | ||
| 48 | /// This build's platform, as `latest.json` and `build.json` key their entries; Apple | 48 | /// 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. |
| 50 | fn platform() -> String { | 50 | pub(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"))] |
| 563 | fn download(path: &str, limit: u64) -> Result<Vec<u8>, String> { | 563 | fn 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"))] | ||
| 581 | pub(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 | } |
| 608 | 620 | ||
| 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 a | 77 | /// [[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 where | 78 | /// 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 | } |
| 815 | 818 | ||
| 816 | pub 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), | 821 | pub 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 | } |
| 826 | 829 | ||
| 827 | pub fn alert(message: &str, detail: &str) { | 830 | pub 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` eases | 986 | // 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) = &shown | 949 | && 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 | } |
| 1761 | 1756 | ||
| 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 |
| 1763 | pub 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); | 1759 | pub 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 | } |
| 1767 | 1764 | ||
| 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. |
| 1770 | fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool { | 1767 | fn 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::IDOK | 1820 | (answer == wm::IDOK).then_some(0) |
| 1825 | } | 1821 | } |
| 1826 | 1822 | ||
| 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 | } |
| 474 | 474 | ||
| 475 | export function askConfirm(message, detail, cancel, action) { | 475 | export 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 | } |
| 478 | 478 | ||
| 479 | export function askText(message, value) { | 479 | export 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 | } |
| 488 | 488 | ||
| 489 | export 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 | |||
| 489 | export function openLink(url) { | 494 | export function openLink(url) { |
| 490 | open(url, "_blank", "noopener"); | 495 | open(url, "_blank", "noopener"); |
| 491 | } | 496 | } |