From d8bbc7858622b6d9c278469aab701ca0b609cddf Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 15 May 2024 05:35:49 +0200 Subject: Merging upstream version 126.0. Signed-off-by: Daniel Baumann --- toolkit/crashreporter/client/app/Cargo.toml | 66 + toolkit/crashreporter/client/app/Makefile.in | 17 + toolkit/crashreporter/client/app/build.rs | 89 ++ .../client/app/macos_app_bundle/Info.plist | 34 + .../client/app/macos_app_bundle/PkgInfo | 2 + .../Resources/English.lproj/InfoPlist.strings.in | 8 + .../macos_app_bundle/Resources/crashreporter.icns | Bin 0 -> 61743 bytes toolkit/crashreporter/client/app/moz.build | 7 + toolkit/crashreporter/client/app/src/async_task.rs | 31 + toolkit/crashreporter/client/app/src/config.rs | 527 ++++++++ toolkit/crashreporter/client/app/src/data.rs | 400 ++++++ .../client/app/src/lang/language_info.rs | 74 ++ toolkit/crashreporter/client/app/src/lang/mod.rs | 89 ++ .../crashreporter/client/app/src/lang/omnijar.rs | 103 ++ toolkit/crashreporter/client/app/src/logging.rs | 86 ++ toolkit/crashreporter/client/app/src/logic.rs | 660 ++++++++++ toolkit/crashreporter/client/app/src/main.rs | 229 ++++ .../client/app/src/net/legacy_telemetry.rs | 177 +++ .../crashreporter/client/app/src/net/libcurl.rs | 406 ++++++ toolkit/crashreporter/client/app/src/net/mod.rs | 12 + toolkit/crashreporter/client/app/src/net/report.rs | 276 +++++ toolkit/crashreporter/client/app/src/process.rs | 23 + toolkit/crashreporter/client/app/src/settings.rs | 39 + toolkit/crashreporter/client/app/src/std/env.rs | 45 + toolkit/crashreporter/client/app/src/std/fs.rs | 559 +++++++++ toolkit/crashreporter/client/app/src/std/mock.rs | 254 ++++ .../crashreporter/client/app/src/std/mock_stub.rs | 20 + toolkit/crashreporter/client/app/src/std/mod.rs | 33 + toolkit/crashreporter/client/app/src/std/net.rs | 5 + toolkit/crashreporter/client/app/src/std/path.rs | 157 +++ .../crashreporter/client/app/src/std/process.rs | 201 +++ toolkit/crashreporter/client/app/src/std/thread.rs | 45 + toolkit/crashreporter/client/app/src/std/time.rs | 32 + toolkit/crashreporter/client/app/src/test.rs | 1289 ++++++++++++++++++++ .../crashreporter/client/app/src/thread_bound.rs | 41 + .../client/app/src/ui/crashreporter.png | Bin 0 -> 2001 bytes toolkit/crashreporter/client/app/src/ui/gtk.rs | 841 +++++++++++++ .../crashreporter/client/app/src/ui/macos/mod.rs | 1122 +++++++++++++++++ .../crashreporter/client/app/src/ui/macos/objc.rs | 242 ++++ .../crashreporter/client/app/src/ui/macos/plist.rs | 44 + toolkit/crashreporter/client/app/src/ui/mod.rs | 295 +++++ .../client/app/src/ui/model/button.rs | 26 + .../client/app/src/ui/model/checkbox.rs | 22 + .../crashreporter/client/app/src/ui/model/hbox.rs | 34 + .../crashreporter/client/app/src/ui/model/label.rs | 22 + .../crashreporter/client/app/src/ui/model/mod.rs | 344 ++++++ .../client/app/src/ui/model/progress.rs | 19 + .../client/app/src/ui/model/scroll.rs | 17 + .../client/app/src/ui/model/textbox.rs | 27 + .../crashreporter/client/app/src/ui/model/vbox.rs | 22 + .../client/app/src/ui/model/window.rs | 46 + toolkit/crashreporter/client/app/src/ui/test.rs | 270 ++++ .../client/app/src/ui/windows/font.rs | 56 + .../crashreporter/client/app/src/ui/windows/gdi.rs | 43 + .../client/app/src/ui/windows/layout.rs | 436 +++++++ .../crashreporter/client/app/src/ui/windows/mod.rs | 949 ++++++++++++++ .../client/app/src/ui/windows/quit_token.rs | 33 + .../client/app/src/ui/windows/twoway.rs | 36 + .../client/app/src/ui/windows/widestring.rs | 36 + .../client/app/src/ui/windows/window.rs | 302 +++++ 60 files changed, 11320 insertions(+) create mode 100644 toolkit/crashreporter/client/app/Cargo.toml create mode 100644 toolkit/crashreporter/client/app/Makefile.in create mode 100644 toolkit/crashreporter/client/app/build.rs create mode 100644 toolkit/crashreporter/client/app/macos_app_bundle/Info.plist create mode 100644 toolkit/crashreporter/client/app/macos_app_bundle/PkgInfo create mode 100644 toolkit/crashreporter/client/app/macos_app_bundle/Resources/English.lproj/InfoPlist.strings.in create mode 100644 toolkit/crashreporter/client/app/macos_app_bundle/Resources/crashreporter.icns create mode 100644 toolkit/crashreporter/client/app/moz.build create mode 100644 toolkit/crashreporter/client/app/src/async_task.rs create mode 100644 toolkit/crashreporter/client/app/src/config.rs create mode 100644 toolkit/crashreporter/client/app/src/data.rs create mode 100644 toolkit/crashreporter/client/app/src/lang/language_info.rs create mode 100644 toolkit/crashreporter/client/app/src/lang/mod.rs create mode 100644 toolkit/crashreporter/client/app/src/lang/omnijar.rs create mode 100644 toolkit/crashreporter/client/app/src/logging.rs create mode 100644 toolkit/crashreporter/client/app/src/logic.rs create mode 100644 toolkit/crashreporter/client/app/src/main.rs create mode 100644 toolkit/crashreporter/client/app/src/net/legacy_telemetry.rs create mode 100644 toolkit/crashreporter/client/app/src/net/libcurl.rs create mode 100644 toolkit/crashreporter/client/app/src/net/mod.rs create mode 100644 toolkit/crashreporter/client/app/src/net/report.rs create mode 100644 toolkit/crashreporter/client/app/src/process.rs create mode 100644 toolkit/crashreporter/client/app/src/settings.rs create mode 100644 toolkit/crashreporter/client/app/src/std/env.rs create mode 100644 toolkit/crashreporter/client/app/src/std/fs.rs create mode 100644 toolkit/crashreporter/client/app/src/std/mock.rs create mode 100644 toolkit/crashreporter/client/app/src/std/mock_stub.rs create mode 100644 toolkit/crashreporter/client/app/src/std/mod.rs create mode 100644 toolkit/crashreporter/client/app/src/std/net.rs create mode 100644 toolkit/crashreporter/client/app/src/std/path.rs create mode 100644 toolkit/crashreporter/client/app/src/std/process.rs create mode 100644 toolkit/crashreporter/client/app/src/std/thread.rs create mode 100644 toolkit/crashreporter/client/app/src/std/time.rs create mode 100644 toolkit/crashreporter/client/app/src/test.rs create mode 100644 toolkit/crashreporter/client/app/src/thread_bound.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/crashreporter.png create mode 100644 toolkit/crashreporter/client/app/src/ui/gtk.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/macos/mod.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/macos/objc.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/macos/plist.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/mod.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/model/button.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/model/checkbox.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/model/hbox.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/model/label.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/model/mod.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/model/progress.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/model/scroll.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/model/textbox.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/model/vbox.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/model/window.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/test.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/windows/font.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/windows/gdi.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/windows/layout.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/windows/mod.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/windows/quit_token.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/windows/twoway.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/windows/widestring.rs create mode 100644 toolkit/crashreporter/client/app/src/ui/windows/window.rs (limited to 'toolkit/crashreporter/client/app') diff --git a/toolkit/crashreporter/client/app/Cargo.toml b/toolkit/crashreporter/client/app/Cargo.toml new file mode 100644 index 0000000000..381ed32f97 --- /dev/null +++ b/toolkit/crashreporter/client/app/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "crashreporter" +version = "1.0.0" +edition = "2021" + +[[bin]] +name = "crashreporter" + +[dependencies] +anyhow = "1.0" +cfg-if = "1.0" +env_logger = { version = "0.10", default-features = false } +fluent = "0.16.0" +intl-memoizer = "0.5" +libloading = "0.7" +log = "0.4.17" +mozbuild = "0.1" +mozilla-central-workspace-hack = { version = "0.1", features = ["crashreporter"], optional = true } +once_cell = "1" +phf = "0.11" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +time = { version = "0.3", features = ["formatting", "serde"] } +unic-langid = { version = "0.9.1" } +uuid = { version = "1", features = ["v4", "serde"] } +zip = { version = "0.6", default-features = false } + +[target."cfg(target_os = \"macos\")".dependencies] +block = "0.1" +cocoa = { package = "cocoabind", path = "../cocoabind" } +objc = "0.2" + +[target."cfg(target_os = \"linux\")".dependencies] +gtk = { package = "gtkbind", path = "../gtkbind" } + +[target."cfg(target_os = \"windows\")".dependencies.windows-sys] +version = "0.52" +features = [ + "Win32_Foundation", + "Win32_Globalization", + "Win32_Graphics_Gdi", + "Win32_System_Com", + "Win32_System_LibraryLoader", + "Win32_System_SystemServices", + "Win32_System_Threading", + "Win32_UI_Controls", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging" +] + +[features] +# Required for tests +mock = [] + +[build-dependencies] +embed-manifest = "1.4" +mozbuild = "0.1" +phf_codegen = "0.11" +yaml-rust = "0.4" + +[dev-dependencies] +bytes = "1.4" +tokio = { version = "1.29", features = ["rt", "net", "time", "sync"] } +warp = { version = "0.3", default-features = false } diff --git a/toolkit/crashreporter/client/app/Makefile.in b/toolkit/crashreporter/client/app/Makefile.in new file mode 100644 index 0000000000..39fa3c8fbb --- /dev/null +++ b/toolkit/crashreporter/client/app/Makefile.in @@ -0,0 +1,17 @@ +# vim:set ts=8 sw=8 sts=8 noet: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +ifeq ($(OS_ARCH),Darwin) + +include $(topsrcdir)/config/rules.mk + +libs:: + $(NSINSTALL) -D $(DIST)/bin/crashreporter.app + rsync --archive --cvs-exclude --exclude '*.in' $(srcdir)/macos_app_bundle/ $(DIST)/bin/crashreporter.app/Contents/ + $(call py_action,preprocessor crashreporter.app/Contents/Resources/English.lproj/InfoPlist.strings,-Fsubstitution --output-encoding utf-16 -DAPP_NAME='$(MOZ_APP_DISPLAYNAME)' $(srcdir)/macos_app_bundle/Resources/English.lproj/InfoPlist.strings.in -o $(DIST)/bin/crashreporter.app/Contents/Resources/English.lproj/InfoPlist.strings) + $(NSINSTALL) -D $(DIST)/bin/crashreporter.app/Contents/MacOS + $(NSINSTALL) $(DIST)/bin/crashreporter $(DIST)/bin/crashreporter.app/Contents/MacOS + +endif diff --git a/toolkit/crashreporter/client/app/build.rs b/toolkit/crashreporter/client/app/build.rs new file mode 100644 index 0000000000..98f20dc375 --- /dev/null +++ b/toolkit/crashreporter/client/app/build.rs @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::{env, path::Path}; + +fn main() { + windows_manifest(); + crash_ping_annotations(); + set_mock_cfg(); +} + +fn windows_manifest() { + use embed_manifest::{embed_manifest, manifest, new_manifest}; + + if std::env::var_os("CARGO_CFG_WINDOWS").is_none() { + return; + } + + // See https://docs.rs/embed-manifest/1.4.0/embed_manifest/fn.new_manifest.html for what the + // default manifest includes. The defaults include almost all of the settings used in the old + // crash reporter. + let manifest = new_manifest("CrashReporter") + // Use legacy active code page because GDI doesn't support per-process UTF8 (and older + // win10 may not support this setting anyway). + .active_code_page(manifest::ActiveCodePage::Legacy) + // GDI scaling is not enabled by default but we need it to make the GDI-drawn text look + // nice on high-DPI displays. + .gdi_scaling(manifest::Setting::Enabled); + + embed_manifest(manifest).expect("unable to embed windows manifest file"); + + println!("cargo:rerun-if-changed=build.rs"); +} + +/// Generate crash ping annotation information from the yaml definition file. +fn crash_ping_annotations() { + use std::fs::File; + use std::io::{BufWriter, Write}; + use yaml_rust::{Yaml, YamlLoader}; + + let crash_annotations = Path::new("../../CrashAnnotations.yaml") + .canonicalize() + .unwrap(); + println!("cargo:rerun-if-changed={}", crash_annotations.display()); + + let crash_ping_file = Path::new(&env::var("OUT_DIR").unwrap()).join("ping_annotations.rs"); + + let yaml = std::fs::read_to_string(crash_annotations).unwrap(); + let Yaml::Hash(entries) = YamlLoader::load_from_str(&yaml) + .unwrap() + .into_iter() + .next() + .unwrap() + else { + panic!("unexpected crash annotations root type"); + }; + + let ping_annotations = entries.into_iter().filter_map(|(k, v)| { + v["ping"] + .as_bool() + .unwrap_or(false) + .then(|| k.into_string().unwrap()) + }); + + let mut phf_set = phf_codegen::Set::new(); + for annotation in ping_annotations { + phf_set.entry(annotation); + } + + let mut file = BufWriter::new(File::create(&crash_ping_file).unwrap()); + writeln!( + &mut file, + "static PING_ANNOTATIONS: phf::Set<&'static str> = {};", + phf_set.build() + ) + .unwrap(); +} + +/// Set the mock configuration option when tests are enabled or when the mock feature is enabled. +fn set_mock_cfg() { + // Very inconveniently, there's no way to detect `cfg(test)` from build scripts. See + // https://github.com/rust-lang/cargo/issues/4789. This seems like an arbitrary and pointless + // limitation, and only complicates the evaluation of mock behavior. Because of this, we have a + // `mock` feature which is activated by `toolkit/library/rust/moz.build`. + if env::var_os("CARGO_FEATURE_MOCK").is_some() || mozbuild::config::MOZ_CRASHREPORTER_MOCK { + println!("cargo:rustc-cfg=mock"); + } +} diff --git a/toolkit/crashreporter/client/app/macos_app_bundle/Info.plist b/toolkit/crashreporter/client/app/macos_app_bundle/Info.plist new file mode 100644 index 0000000000..f1679a922e --- /dev/null +++ b/toolkit/crashreporter/client/app/macos_app_bundle/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleDisplayName + crashreporter + CFBundleExecutable + crashreporter + CFBundleIconFile + crashreporter.icns + CFBundleIdentifier + org.mozilla.crashreporter + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + crashreporter + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleVersion + 1.0 + LSHasLocalizedDisplayName + + NSRequiresAquaSystemAppearance + + NSPrincipalClass + NSApplication + LSUIElement + + + diff --git a/toolkit/crashreporter/client/app/macos_app_bundle/PkgInfo b/toolkit/crashreporter/client/app/macos_app_bundle/PkgInfo new file mode 100644 index 0000000000..cae6d0a58f --- /dev/null +++ b/toolkit/crashreporter/client/app/macos_app_bundle/PkgInfo @@ -0,0 +1,2 @@ +APPL???? + diff --git a/toolkit/crashreporter/client/app/macos_app_bundle/Resources/English.lproj/InfoPlist.strings.in b/toolkit/crashreporter/client/app/macos_app_bundle/Resources/English.lproj/InfoPlist.strings.in new file mode 100644 index 0000000000..e08ce59eb6 --- /dev/null +++ b/toolkit/crashreporter/client/app/macos_app_bundle/Resources/English.lproj/InfoPlist.strings.in @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* Localized versions of Info.plist keys */ + +CFBundleName = "Crash Reporter"; +CFBundleDisplayName = "@APP_NAME@ Crash Reporter"; diff --git a/toolkit/crashreporter/client/app/macos_app_bundle/Resources/crashreporter.icns b/toolkit/crashreporter/client/app/macos_app_bundle/Resources/crashreporter.icns new file mode 100644 index 0000000000..341cd05a4d Binary files /dev/null and b/toolkit/crashreporter/client/app/macos_app_bundle/Resources/crashreporter.icns differ diff --git a/toolkit/crashreporter/client/app/moz.build b/toolkit/crashreporter/client/app/moz.build new file mode 100644 index 0000000000..6e5c19173f --- /dev/null +++ b/toolkit/crashreporter/client/app/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +RUST_PROGRAMS = ["crashreporter"] diff --git a/toolkit/crashreporter/client/app/src/async_task.rs b/toolkit/crashreporter/client/app/src/async_task.rs new file mode 100644 index 0000000000..839db5e562 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/async_task.rs @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Manage work across multiple threads. +//! +//! Each thread has thread-bound data which can be accessed in queued task functions. + +pub type TaskFn = Box; + +pub struct AsyncTask { + send: Box) + Send + Sync>, +} + +impl AsyncTask { + pub fn new) + Send + Sync + 'static>(send: F) -> Self { + AsyncTask { + send: Box::new(send), + } + } + + pub fn push(&self, f: F) { + (self.send)(Box::new(f)); + } + + pub fn wait R + Send + 'static>(&self, f: F) -> R { + let (tx, rx) = std::sync::mpsc::sync_channel(0); + self.push(move |v| tx.send(f(v)).unwrap()); + rx.recv().unwrap() + } +} diff --git a/toolkit/crashreporter/client/app/src/config.rs b/toolkit/crashreporter/client/app/src/config.rs new file mode 100644 index 0000000000..4e919395e2 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/config.rs @@ -0,0 +1,527 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +//! Application configuration. + +use crate::std::borrow::Cow; +use crate::std::ffi::{OsStr, OsString}; +use crate::std::path::{Path, PathBuf}; +use crate::{lang, logging::LogTarget, std}; +use anyhow::Context; + +/// The number of the most recent minidump files to retain when pruning. +const MINIDUMP_PRUNE_SAVE_COUNT: usize = 10; + +#[cfg(test)] +pub mod test { + pub const MINIDUMP_PRUNE_SAVE_COUNT: usize = super::MINIDUMP_PRUNE_SAVE_COUNT; +} + +const VENDOR_KEY: &str = "Vendor"; +const PRODUCT_KEY: &str = "ProductName"; +const DEFAULT_VENDOR: &str = "Mozilla"; +const DEFAULT_PRODUCT: &str = "Firefox"; + +#[derive(Default)] +pub struct Config { + /// Whether reports should be automatically submitted. + pub auto_submit: bool, + /// Whether all threads of the process should be dumped (versus just the crashing thread). + pub dump_all_threads: bool, + /// Whether to delete the dump files after submission. + pub delete_dump: bool, + /// The data directory. + pub data_dir: Option, + /// The events directory. + pub events_dir: Option, + /// The ping directory. + pub ping_dir: Option, + /// The dump file. + /// + /// If missing, an error dialog is displayed. + pub dump_file: Option, + /// The XUL_APP_FILE to define if restarting the application. + pub app_file: Option, + /// The path to the application to use when restarting the crashed process. + pub restart_command: Option, + /// The arguments to pass if restarting the application. + pub restart_args: Vec, + /// The URL to which to send reports. + pub report_url: Option, + /// The localized strings to use. + pub strings: Option, + /// The log target. + pub log_target: Option, +} + +pub struct ConfigStringBuilder<'a>(lang::LangStringBuilder<'a>); + +impl<'a> ConfigStringBuilder<'a> { + /// Set an argument for the string. + pub fn arg>>(self, key: &'a str, value: V) -> Self { + ConfigStringBuilder(self.0.arg(key, value)) + } + + /// Get the localized string. + pub fn get(self) -> String { + self.0 + .get() + .context("failed to get localized string") + .unwrap() + } +} + +impl Config { + /// Return a configuration with no values set, and all bool values false. + pub fn new() -> Self { + Self::default() + } + + /// Load a configuration from the application environment. + #[cfg_attr(mock, allow(unused))] + pub fn read_from_environment(&mut self) -> anyhow::Result<()> { + /// Most environment variables are prefixed with `MOZ_CRASHREPORTER_`. + macro_rules! ekey { + ( $name:literal ) => { + concat!("MOZ_CRASHREPORTER_", $name) + }; + } + + self.auto_submit = env_bool(ekey!("AUTO_SUBMIT")); + self.dump_all_threads = env_bool(ekey!("DUMP_ALL_THREADS")); + self.delete_dump = !env_bool(ekey!("NO_DELETE_DUMP")); + self.data_dir = env_path(ekey!("DATA_DIRECTORY")); + self.events_dir = env_path(ekey!("EVENTS_DIRECTORY")); + self.ping_dir = env_path(ekey!("PING_DIRECTORY")); + self.app_file = std::env::var_os(ekey!("RESTART_XUL_APP_FILE")); + + // Only support `MOZ_APP_LAUNCHER` on linux and macos. + if cfg!(not(target_os = "windows")) { + self.restart_command = std::env::var_os("MOZ_APP_LAUNCHER"); + } + + if self.restart_command.is_none() { + self.restart_command = Some( + self.sibling_program_path(mozbuild::config::MOZ_APP_NAME) + .into(), + ) + } + + // We no longer use don't use `MOZ_CRASHREPORTER_RESTART_ARG_0`, see bug 1872920. + self.restart_args = (1..) + .into_iter() + .map_while(|arg_num| std::env::var_os(format!("{}_{}", ekey!("RESTART_ARG"), arg_num))) + // Sometimes these are empty, in which case they should be ignored. + .filter(|s| !s.is_empty()) + .collect(); + + self.report_url = std::env::var_os(ekey!("URL")); + + let mut args = std::env::args_os() + // skip program name + .skip(1); + self.dump_file = args.next().map(|p| p.into()); + while let Some(arg) = args.next() { + log::warn!("ignoring extraneous argument: {}", arg.to_string_lossy()); + } + + self.strings = Some(lang::load().context("failed to load localized strings")?); + + Ok(()) + } + + /// Get the localized string for the given index. + pub fn string(&self, index: &str) -> String { + self.build_string(index).get() + } + + /// Build the localized string for the given index. + pub fn build_string<'a>(&'a self, index: &'a str) -> ConfigStringBuilder<'a> { + ConfigStringBuilder( + self.strings + .as_ref() + .expect("strings not set") + .builder(index), + ) + } + + /// Whether the configured language has right-to-left text flow. + pub fn is_rtl(&self) -> bool { + self.strings + .as_ref() + .map(|s| s.is_rtl()) + .unwrap_or_default() + } + + /// Load the extra file, updating configuration. + pub fn load_extra_file(&mut self) -> anyhow::Result { + let extra_file = self.extra_file().unwrap(); + + // Load the extra file (which minidump-analyzer just updated). + let extra: serde_json::Value = + serde_json::from_reader(std::fs::File::open(&extra_file).with_context(|| { + self.build_string("crashreporter-error-opening-file") + .arg("path", extra_file.display().to_string()) + .get() + })?) + .with_context(|| { + self.build_string("crashreporter-error-loading-file") + .arg("path", extra_file.display().to_string()) + .get() + })?; + + // Set report url if not already set. + if self.report_url.is_none() { + if let Some(url) = extra["ServerURL"].as_str() { + self.report_url = Some(url.into()); + } + } + + // Set the data dir if not already set. + if self.data_dir.is_none() { + let vendor = extra[VENDOR_KEY].as_str().unwrap_or(DEFAULT_VENDOR); + let product = extra[PRODUCT_KEY].as_str().unwrap_or(DEFAULT_PRODUCT); + self.data_dir = Some(self.get_data_dir(vendor, product)?); + } + + // Clear the restart command if WER handled the crash. This prevents restarting the + // program. See bug 1872920. + if extra.get("WindowsErrorReporting").is_some() { + self.restart_command = None; + } + + Ok(extra) + } + + /// Get the path to the extra file. + /// + /// Returns None if no dump_file is set. + pub fn extra_file(&self) -> Option { + self.dump_file.clone().map(extra_file_for_dump_file) + } + + /// Get the path to the memory file. + /// + /// Returns None if no dump_file is set or if the memory file does not exist. + pub fn memory_file(&self) -> Option { + self.dump_file.clone().and_then(|p| { + let p = memory_file_for_dump_file(p); + p.exists().then_some(p) + }) + } + + /// The path to the data directory. + /// + /// Panics if no data directory is set. + pub fn data_dir(&self) -> &Path { + self.data_dir.as_deref().unwrap() + } + + /// The path to the dump file. + /// + /// Panics if no dump file is set. + pub fn dump_file(&self) -> &Path { + self.dump_file.as_deref().unwrap() + } + + /// The id of the local dump file (the base filename without extension). + /// + /// Panics if no dump file is set. + pub fn local_dump_id(&self) -> Cow { + self.dump_file().file_stem().unwrap().to_string_lossy() + } + + /// Move crash data to the pending folder. + pub fn move_crash_data_to_pending(&mut self) -> anyhow::Result<()> { + let pending_crashes_dir = self.data_dir().join("pending"); + std::fs::create_dir_all(&pending_crashes_dir).with_context(|| { + self.build_string("crashreporter-error-creating-dir") + .arg("path", pending_crashes_dir.display().to_string()) + .get() + })?; + + let move_file = |from: &Path| -> anyhow::Result { + let to = pending_crashes_dir.join(from.file_name().unwrap()); + std::fs::rename(from, &to).with_context(|| { + self.build_string("crashreporter-error-moving-path") + .arg("from", from.display().to_string()) + .arg("to", to.display().to_string()) + .get() + })?; + Ok(to) + }; + + let new_dump_file = move_file(self.dump_file())?; + move_file(self.extra_file().unwrap().as_ref())?; + // Failing to move the memory file is recoverable. + if let Some(memory_file) = self.memory_file() { + if let Err(e) = move_file(memory_file.as_ref()) { + log::warn!("failed to move memory file: {e}"); + if let Err(e) = std::fs::remove_file(&memory_file) { + log::warn!("failed to remove {}: {e}", memory_file.display()); + } + } + } + + self.dump_file = Some(new_dump_file); + + Ok(()) + } + + /// Form the path which signals EOL for a particular version. + pub fn version_eol_file(&self, version: &str) -> PathBuf { + self.data_dir().join(format!("EndOfLife{version}")) + } + + /// Return the path used to store submitted crash ids. + pub fn submitted_crash_dir(&self) -> PathBuf { + self.data_dir().join("submitted") + } + + /// Delete files related to the crash report. + pub fn delete_files(&self) { + if !self.delete_dump { + return; + } + + for file in [&self.dump_file, &self.extra_file(), &self.memory_file()] + .into_iter() + .flatten() + { + if let Err(e) = std::fs::remove_file(file) { + log::warn!("failed to remove {}: {e}", file.display()); + } + } + } + + /// Prune old minidump files adjacent to the dump file. + pub fn prune_files(&self) -> anyhow::Result<()> { + log::info!("pruning minidump files to the {MINIDUMP_PRUNE_SAVE_COUNT} most recent"); + let Some(file) = &self.dump_file else { + anyhow::bail!("no dump file") + }; + let Some(dir) = file.parent() else { + anyhow::bail!("no parent directory for dump file") + }; + log::debug!("pruning {} directory", dir.display()); + let read_dir = dir.read_dir().with_context(|| { + format!( + "failed to read dump file parent directory {}", + dir.display() + ) + })?; + + let mut minidump_files = Vec::new(); + for entry in read_dir { + match entry { + Err(e) => log::error!( + "error while iterating over {} directory entry: {e}", + dir.display() + ), + Ok(e) if e.path().extension() == Some("dmp".as_ref()) => { + // Return if the metadata can't be read, since not being able to get metadata + // for any file could make the selection of minidumps to delete incorrect. + let meta = e.metadata().with_context(|| { + format!("failed to read metadata for {}", e.path().display()) + })?; + if meta.is_file() { + let modified_time = + meta.modified().expect( + "file modification time should be available on all crashreporter platforms", + ); + minidump_files.push((modified_time, e.path())); + } + } + _ => (), + } + } + + // Sort by modification time first, then path (just to have a defined behavior in the case + // of identical times). The reverse leaves the files in order from newest to oldest. + minidump_files.sort_unstable_by(|a, b| a.cmp(b).reverse()); + + // Delete files, skipping the most recent MINIDUMP_PRUNE_SAVE_COUNT. + for dump_file in minidump_files + .into_iter() + .skip(MINIDUMP_PRUNE_SAVE_COUNT) + .map(|v| v.1) + { + log::debug!("pruning {} and related files", dump_file.display()); + if let Err(e) = std::fs::remove_file(&dump_file) { + log::warn!("failed to delete {}: {e}", dump_file.display()); + } + + // Ignore errors for the extra file and the memory file: they may not exist. + let _ = std::fs::remove_file(extra_file_for_dump_file(dump_file.clone())); + let _ = std::fs::remove_file(memory_file_for_dump_file(dump_file)); + } + Ok(()) + } + + /// Get the path of a program that is a sibling of the crashreporter. + /// + /// On MacOS, this assumes that the crashreporter is its own application bundle within the main + /// program bundle. On other platforms this assumes siblings reside in the same directory as + /// the crashreporter. + /// + /// The returned path isn't guaranteed to exist. + // This method could be standalone rather than living in `Config`; it's here because it makes + // sense that if it were to rely on anything, it would be the `Config` (and that may change in + // the future). + pub fn sibling_program_path>(&self, program: N) -> PathBuf { + // Expect shouldn't ever panic here because we need more than one argument to run + // the program in the first place (we've already previously iterated args). + // + // We use argv[0] rather than `std::env::current_exe` because `current_exe` doesn't define + // how symlinks are treated, and we want to support running directly from the local build + // directory (which uses symlinks on linux and macos). + let self_path = PathBuf::from(std::env::args_os().next().expect("failed to get argv[0]")); + let exe_extension = self_path.extension().unwrap_or_default(); + + let mut program_path = self_path.clone(); + // Pop the executable off to get the parent directory. + program_path.pop(); + program_path.push(program.as_ref()); + program_path.set_extension(exe_extension); + + if !program_path.exists() && cfg!(all(not(mock), target_os = "macos")) { + // On macOS the crash reporter client is shipped as an application bundle contained + // within Firefox's main application bundle. So when it's invoked its current working + // directory looks like: + // Firefox.app/Contents/MacOS/crashreporter.app/Contents/MacOS/ + // The other applications we ship with Firefox are stored in the main bundle + // (Firefox.app/Contents/MacOS/) so we we need to go back three directories + // to reach them. + + // 4 pops: 1 for the path that was just pushed, and 3 more for + // `crashreporter.app/Contents/MacOS`. + for _ in 0..4 { + program_path.pop(); + } + program_path.push(program.as_ref()); + program_path.set_extension(exe_extension); + } + + program_path + } + + cfg_if::cfg_if! { + if #[cfg(mock)] { + fn get_data_dir(&self, vendor: &str, product: &str) -> anyhow::Result { + let mut path = PathBuf::from("data_dir"); + path.push(vendor); + path.push(product); + path.push("Crash Reports"); + Ok(path) + } + } else if #[cfg(target_os = "linux")] { + fn get_data_dir(&self, vendor: &str, product: &str) -> anyhow::Result { + // home_dir is deprecated due to incorrect behavior on windows, but we only use it on linux + #[allow(deprecated)] + let mut data_path = + std::env::home_dir().with_context(|| self.string("crashreporter-error-no-home-dir"))?; + data_path.push(format!(".{}", vendor.to_lowercase())); + data_path.push(product.to_lowercase()); + data_path.push("Crash Reports"); + Ok(data_path) + } + } else if #[cfg(target_os = "macos")] { + fn get_data_dir(&self, _vendor: &str, product: &str) -> anyhow::Result { + use objc::{ + rc::autoreleasepool, + runtime::{Object, BOOL, YES}, + *, + }; + #[link(name = "Foundation", kind = "framework")] + extern "system" { + fn NSSearchPathForDirectoriesInDomains( + directory: usize, + domain_mask: usize, + expand_tilde: BOOL, + ) -> *mut Object /* NSArray* */; + } + #[allow(non_upper_case_globals)] + const NSApplicationSupportDirectory: usize = 14; + #[allow(non_upper_case_globals)] + const NSUserDomainMask: usize = 1; + + let mut data_path = autoreleasepool(|| { + let paths /* NSArray* */ = unsafe { + NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES) + }; + if paths.is_null() { + anyhow::bail!("NSSearchPathForDirectoriesInDomains returned nil"); + } + let path: *mut Object /* NSString* */ = unsafe { msg_send![paths, firstObject] }; + if path.is_null() { + anyhow::bail!("NSSearchPathForDirectoriesInDomains returned no paths"); + } + + let str_pointer: *const i8 = unsafe { msg_send![path, UTF8String] }; + // # Safety + // The pointer is a readable C string with a null terminator. + let Ok(s) = unsafe { std::ffi::CStr::from_ptr(str_pointer) }.to_str() else { + anyhow::bail!("NSString wasn't valid UTF8"); + }; + Ok(PathBuf::from(s)) + })?; + data_path.push(product); + std::fs::create_dir_all(&data_path).with_context(|| { + self.build_string("crashreporter-error-creating-dir") + .arg("path", data_path.display().to_string()) + .get() + })?; + data_path.push("Crash Reports"); + Ok(data_path) + } + } else if #[cfg(target_os = "windows")] { + fn get_data_dir(&self, vendor: &str, product: &str) -> anyhow::Result { + use crate::std::os::windows::ffi::OsStringExt; + use windows_sys::{ + core::PWSTR, + Win32::{ + Globalization::lstrlenW, + System::Com::CoTaskMemFree, + UI::Shell::{FOLDERID_RoamingAppData, SHGetKnownFolderPath}, + }, + }; + + let mut path: PWSTR = std::ptr::null_mut(); + let result = unsafe { SHGetKnownFolderPath(&FOLDERID_RoamingAppData, 0, 0, &mut path) }; + if result != 0 { + unsafe { CoTaskMemFree(path as _) }; + anyhow::bail!("failed to get known path for roaming appdata"); + } + + let length = unsafe { lstrlenW(path) }; + let slice = unsafe { std::slice::from_raw_parts(path, length as usize) }; + let osstr = OsString::from_wide(slice); + unsafe { CoTaskMemFree(path as _) }; + let mut path = PathBuf::from(osstr); + path.push(vendor); + path.push(product); + path.push("Crash Reports"); + Ok(path) + } + } + } +} + +fn env_bool>(name: K) -> bool { + std::env::var(name).map(|s| !s.is_empty()).unwrap_or(false) +} + +fn env_path>(name: K) -> Option { + std::env::var_os(name).map(PathBuf::from) +} + +fn extra_file_for_dump_file(mut dump_file: PathBuf) -> PathBuf { + dump_file.set_extension("extra"); + dump_file +} + +fn memory_file_for_dump_file(mut dump_file: PathBuf) -> PathBuf { + dump_file.set_extension("memory.json.gz"); + dump_file +} diff --git a/toolkit/crashreporter/client/app/src/data.rs b/toolkit/crashreporter/client/app/src/data.rs new file mode 100644 index 0000000000..474da8966a --- /dev/null +++ b/toolkit/crashreporter/client/app/src/data.rs @@ -0,0 +1,400 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Data binding types are used to implement dynamic behaviors in UIs. [`Event`] is the primitive +//! type underlying most others. [Properties](Property) are what should usually be used in UI +//! models, since they have `From` impls allowing different binding behaviors to be set. + +use std::cell::RefCell; +use std::rc::Rc; + +/// An event which can have multiple subscribers. +/// +/// The type parameter is the payload of the event. +pub struct Event { + subscribers: Rc>>>, +} + +impl Clone for Event { + fn clone(&self) -> Self { + Event { + subscribers: self.subscribers.clone(), + } + } +} + +impl Default for Event { + fn default() -> Self { + Event { + subscribers: Default::default(), + } + } +} + +impl std::fmt::Debug for Event { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{} {{ {} subscribers }}", + std::any::type_name::(), + self.subscribers.borrow().len() + ) + } +} + +impl Event { + /// Add a callback for when the event is fired. + pub fn subscribe(&self, f: F) + where + F: Fn(&T) + 'static, + { + self.subscribers.borrow_mut().push(Box::new(f)); + } + + /// Fire the event with the given payload. + pub fn fire(&self, payload: &T) { + for f in self.subscribers.borrow().iter() { + f(payload); + } + } +} + +/// A synchronized runtime value. +/// +/// Consumers can subscribe to change events on the value. Change events are fired when +/// `borrow_mut()` references are dropped. +#[derive(Default)] +pub struct Synchronized { + inner: Rc>, +} + +impl Clone for Synchronized { + fn clone(&self) -> Self { + Synchronized { + inner: self.inner.clone(), + } + } +} + +impl std::fmt::Debug for Synchronized { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct(std::any::type_name::()) + .field("current", &*self.inner.current.borrow()) + .field("change", &self.inner.change) + .finish() + } +} + +#[derive(Default)] +struct SynchronizedInner { + current: RefCell, + change: Event, +} + +impl Synchronized { + /// Create a new value with the given inner data. + pub fn new(initial: T) -> Self { + Synchronized { + inner: Rc::new(SynchronizedInner { + current: RefCell::new(initial), + change: Default::default(), + }), + } + } + + /// Borrow a value's data. + pub fn borrow(&self) -> std::cell::Ref { + self.inner.current.borrow() + } + + /// Mutably borrow a value's data. + /// + /// When the mutable reference is dropped, a change event is fired. + pub fn borrow_mut(&self) -> ValueRefMut<'_, T> { + ValueRefMut { + value: std::mem::ManuallyDrop::new(self.inner.current.borrow_mut()), + inner: &self.inner, + } + } + + /// Subscribe to change events in the value. + pub fn on_change(&self, f: F) { + self.inner.change.subscribe(f); + } + + /// Update another synchronized value when this one changes. + pub fn update_on_change U + 'static>( + &self, + other: &Synchronized, + f: F, + ) { + let other = other.clone(); + self.on_change(move |val| { + *other.borrow_mut() = f(val); + }); + } + + /// Create a new synchronized value which will update when this one changes. + pub fn mapped U + 'static>(&self, f: F) -> Synchronized { + let s = Synchronized::new(f(&*self.borrow())); + self.update_on_change(&s, f); + s + } + + pub fn join T + Clone + 'static>( + a: &Synchronized, + b: &Synchronized, + f: F, + ) -> Self + where + T: 'static, + { + let s = Synchronized::new(f(&*a.borrow(), &*b.borrow())); + let update = cc! { (a,b,s) move || { + *s.borrow_mut() = f(&*a.borrow(), &*b.borrow()); + }}; + a.on_change(cc! { (update) move |_| update()}); + b.on_change(move |_| update()); + s + } +} + +/// A runtime value that can be fetched on-demand (read-only). +/// +/// Consumers call [`read`] or [`get`] to retrieve the value, while producers call [`register`] to +/// set the function which is called to retrieve the value. This is of most use for things like +/// editable text strings, where it would be unnecessarily expensive to e.g. update a +/// `Synchronized` property as the text string is changed (debouncing could be used, but if change +/// notification isn't needed then it's still unnecessary). +pub struct OnDemand { + get: Rc>>>, +} + +impl Default for OnDemand { + fn default() -> Self { + OnDemand { + get: Default::default(), + } + } +} + +impl Clone for OnDemand { + fn clone(&self) -> Self { + OnDemand { + get: self.get.clone(), + } + } +} + +impl std::fmt::Debug for OnDemand { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{} {{ {} }}", + std::any::type_name::(), + if self.get.borrow().is_none() { + "not registered" + } else { + "registered" + } + ) + } +} + +impl OnDemand { + /// Reads the current value. + pub fn read(&self, value: &mut T) { + match &*self.get.borrow() { + None => { + // The test UI doesn't always register OnDemand getters (only on a per-test basis), + // so don't panic otherwise the tests will fail unnecessarily. + #[cfg(not(test))] + panic!("OnDemand not registered by renderer") + } + Some(f) => f(value), + } + } + + /// Get a copy of the current value. + pub fn get(&self) -> T + where + T: Default, + { + let mut r = T::default(); + self.read(&mut r); + r + } + + /// Register the function to use when getting the value. + pub fn register(&self, f: impl Fn(&mut T) + 'static) { + *self.get.borrow_mut() = Some(Box::new(f)); + } +} + +/// A UI element property. +/// +/// Properties support static and dynamic value bindings. +/// * `T` can be converted to static bindings. +/// * `Synchronized` can be converted to dynamic bindings which will be updated +/// bidirectionally. +/// * `OnDemand` can be converted to dynamic bindings which can be queried on an as-needed +/// basis. +#[derive(Clone, Debug)] +pub enum Property { + Static(T), + Binding(Synchronized), + ReadOnly(OnDemand), +} + +#[cfg(test)] +impl Property { + pub fn set(&self, value: T) { + match self { + Property::Static(_) => panic!("cannot set static property"), + Property::Binding(s) => *s.borrow_mut() = value, + Property::ReadOnly(o) => o.register(move |v| *v = value.clone()), + } + } + + pub fn get(&self) -> T { + match self { + Property::Static(v) => v.clone(), + Property::Binding(s) => s.borrow().clone(), + Property::ReadOnly(o) => o.get(), + } + } +} + +impl Default for Property { + fn default() -> Self { + Property::Static(Default::default()) + } +} + +impl From for Property { + fn from(value: T) -> Self { + Property::Static(value) + } +} + +impl From<&Synchronized> for Property { + fn from(value: &Synchronized) -> Self { + Property::Binding(value.clone()) + } +} + +impl From<&OnDemand> for Property { + fn from(value: &OnDemand) -> Self { + Property::ReadOnly(value.clone()) + } +} + +/// A mutable Value reference. +/// +/// When dropped, the Value's change event will fire (_after_ demoting the RefMut to a Ref). +pub struct ValueRefMut<'a, T> { + value: std::mem::ManuallyDrop>, + inner: &'a SynchronizedInner, +} + +impl std::ops::Deref for ValueRefMut<'_, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + &*self.value + } +} + +impl std::ops::DerefMut for ValueRefMut<'_, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut *self.value + } +} + +impl Drop for ValueRefMut<'_, T> { + fn drop(&mut self) { + unsafe { std::mem::ManuallyDrop::drop(&mut self.value) }; + self.inner.change.fire(&*self.inner.current.borrow()); + } +} + +#[cfg(test)] +mod test { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + + #[derive(Default, Clone)] + struct Trace { + count: Rc, + } + + impl Trace { + fn inc(&self) { + self.count.fetch_add(1, Relaxed); + } + + fn set(&self, v: usize) { + self.count.store(v, Relaxed); + } + + fn count(&self) -> usize { + self.count.load(Relaxed) + } + } + + #[test] + fn event() { + let t1 = Trace::default(); + let t2 = Trace::default(); + let evt = Event::default(); + evt.subscribe(cc! { (t1) move |x| { + assert!(x == &42); + t1.inc() + }}); + evt.fire(&42); + assert_eq!(t1.count(), 1); + evt.subscribe(cc! { (t2) move |_| t2.inc() }); + evt.fire(&42); + assert_eq!(t1.count(), 2); + assert_eq!(t2.count(), 1); + } + + #[test] + fn synchronized() { + let t1 = Trace::default(); + let s = Synchronized::::default(); + assert_eq!(*s.borrow(), 0); + + s.on_change(cc! { (t1) move |v| t1.set(*v) }); + { + let mut s_ref = s.borrow_mut(); + *s_ref = 41; + // Changes should only occur when the ref is dropped + assert_eq!(t1.count(), 0); + *s_ref = 42; + } + assert_eq!(t1.count(), 42); + assert_eq!(*s.borrow(), 42); + } + + #[test] + fn ondemand() { + let t1 = Trace::default(); + let d = OnDemand::::default(); + d.register(|v| *v = 42); + { + let mut v = 0; + d.read(&mut v); + assert_eq!(v, 42); + } + d.register(|v| *v = 10); + assert_eq!(d.get(), 10); + + t1.inc(); + d.register(cc! { (t1) move |v| *v = t1.count() }); + assert_eq!(d.get(), 1); + t1.set(42); + assert_eq!(d.get(), 42); + } +} diff --git a/toolkit/crashreporter/client/app/src/lang/language_info.rs b/toolkit/crashreporter/client/app/src/lang/language_info.rs new file mode 100644 index 0000000000..b05953e2b3 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/lang/language_info.rs @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +use super::LangStrings; +use anyhow::Context; +use fluent::{bundle::FluentBundle, FluentResource}; +use unic_langid::LanguageIdentifier; + +const FALLBACK_FTL_FILE: &str = include_str!(mozbuild::srcdir_path!( + "/toolkit/locales/en-US/crashreporter/crashreporter.ftl" +)); +const FALLBACK_BRANDING_FILE: &str = include_str!(mozbuild::srcdir_path!( + "/browser/branding/official/locales/en-US/brand.ftl" +)); + +/// Localization language information. +#[derive(Debug, Clone)] +pub struct LanguageInfo { + pub identifier: String, + pub ftl_definitions: String, + pub ftl_branding: String, +} + +impl Default for LanguageInfo { + fn default() -> Self { + Self::fallback() + } +} + +impl LanguageInfo { + /// Get the fallback bundled language information (en-US). + pub fn fallback() -> Self { + LanguageInfo { + identifier: "en-US".to_owned(), + ftl_definitions: FALLBACK_FTL_FILE.to_owned(), + ftl_branding: FALLBACK_BRANDING_FILE.to_owned(), + } + } + + /// Load strings from the language info. + pub fn load_strings(self) -> anyhow::Result { + let Self { + identifier: lang, + ftl_definitions: definitions, + ftl_branding: branding, + } = self; + + let langid = lang + .parse::() + .with_context(|| format!("failed to parse language identifier ({lang})"))?; + let rtl = langid.character_direction() == unic_langid::CharacterDirection::RTL; + let mut bundle = FluentBundle::new_concurrent(vec![langid]); + + fn add_ftl( + bundle: &mut FluentBundle, + ftl: String, + ) -> anyhow::Result<()> { + let resource = FluentResource::try_new(ftl) + .ok() + .context("failed to create fluent resource")?; + bundle + .add_resource(resource) + .ok() + .context("failed to add fluent resource to bundle")?; + Ok(()) + } + + add_ftl(&mut bundle, branding).context("failed to add branding")?; + add_ftl(&mut bundle, definitions).context("failed to add localization")?; + + Ok(LangStrings::new(bundle, rtl)) + } +} diff --git a/toolkit/crashreporter/client/app/src/lang/mod.rs b/toolkit/crashreporter/client/app/src/lang/mod.rs new file mode 100644 index 0000000000..9b7495f92a --- /dev/null +++ b/toolkit/crashreporter/client/app/src/lang/mod.rs @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +mod language_info; +mod omnijar; + +use fluent::{bundle::FluentBundle, FluentArgs, FluentResource}; +use intl_memoizer::concurrent::IntlLangMemoizer; +#[cfg(test)] +pub use language_info::LanguageInfo; +use std::borrow::Cow; +use std::collections::BTreeMap; + +/// Get the localized string bundle. +pub fn load() -> anyhow::Result { + // TODO support langpacks, bug 1873210 + omnijar::read().unwrap_or_else(|e| { + log::warn!("failed to read localization data from the omnijar ({e}), falling back to bundled content"); + Default::default() + }).load_strings() +} + +/// A bundle of localized strings. +pub struct LangStrings { + bundle: FluentBundle, + rtl: bool, +} + +/// Arguments to build a localized string. +pub type LangStringsArgs<'a> = BTreeMap<&'a str, Cow<'a, str>>; + +impl LangStrings { + pub fn new(bundle: FluentBundle, rtl: bool) -> Self { + LangStrings { bundle, rtl } + } + + /// Return whether the localized language has right-to-left text flow. + pub fn is_rtl(&self) -> bool { + self.rtl + } + + pub fn get(&self, index: &str, args: LangStringsArgs) -> anyhow::Result { + let mut fluent_args = FluentArgs::with_capacity(args.len()); + for (k, v) in args { + fluent_args.set(k, v); + } + + let Some(pattern) = self.bundle.get_message(index).and_then(|m| m.value()) else { + anyhow::bail!("failed to get fluent message for {index}"); + }; + let mut errs = Vec::new(); + let ret = self + .bundle + .format_pattern(pattern, Some(&fluent_args), &mut errs); + if !errs.is_empty() { + anyhow::bail!("errors while formatting pattern: {errs:?}"); + } + Ok(ret.into_owned()) + } + + pub fn builder<'a>(&'a self, index: &'a str) -> LangStringBuilder<'a> { + LangStringBuilder { + strings: self, + index, + args: Default::default(), + } + } +} + +/// A localized string builder. +pub struct LangStringBuilder<'a> { + strings: &'a LangStrings, + index: &'a str, + args: LangStringsArgs<'a>, +} + +impl<'a> LangStringBuilder<'a> { + /// Set an argument for the string. + pub fn arg>>(mut self, key: &'a str, value: V) -> Self { + self.args.insert(key, value.into()); + self + } + + /// Get the localized string. + pub fn get(self) -> anyhow::Result { + self.strings.get(self.index, self.args) + } +} diff --git a/toolkit/crashreporter/client/app/src/lang/omnijar.rs b/toolkit/crashreporter/client/app/src/lang/omnijar.rs new file mode 100644 index 0000000000..2d2c34dd8d --- /dev/null +++ b/toolkit/crashreporter/client/app/src/lang/omnijar.rs @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use super::language_info::LanguageInfo; +use crate::std::{ + env::current_exe, + fs::File, + io::{BufRead, BufReader, Read}, + path::Path, +}; +use anyhow::Context; +use zip::read::ZipArchive; + +/// Read the appropriate localization fluent definitions from the omnijar files. +/// +/// Returns (locale name, fluent definitions). +pub fn read() -> anyhow::Result { + let mut path = current_exe().context("failed to get current executable")?; + path.pop(); + path.push("omni.ja"); + + let mut zip = read_omnijar_file(&path)?; + let locales = { + let buf = BufReader::new( + zip.by_name("res/multilocale.txt") + .context("failed to read multilocale file in zip archive")?, + ); + let line = buf + .lines() + .next() + .ok_or(anyhow::anyhow!("multilocale file was empty"))? + .context("failed to read first line of multilocale file")?; + line.split(",") + .map(|s| s.trim().to_owned()) + .collect::>() + }; + + let (locale, ftl_definitions) = 'defs: { + for locale in &locales { + match read_strings(locale, &mut zip) { + Ok(v) => break 'defs (locale.to_string(), v), + Err(e) => log::warn!("{e:#}"), + } + } + anyhow::bail!("failed to find any usable localized strings in the omnijar") + }; + + // The brand ftl is in the browser omnijar. + path.pop(); + path.push("browser"); + path.push("omni.ja"); + + let ftl_branding = 'branding: { + for locale in &locales { + match read_branding(&locale, &mut zip) { + Ok(v) => break 'branding v, + Err(e) => log::warn!("failed to read branding from omnijar: {e:#}"), + } + } + log::info!("using fallback branding info"); + LanguageInfo::default().ftl_branding + }; + + Ok(LanguageInfo { + identifier: locale, + ftl_definitions, + ftl_branding, + }) +} + +/// Read the localized strings from the given zip archive (omnijar). +fn read_strings(locale: &str, archive: &mut ZipArchive) -> anyhow::Result { + let mut file = archive + .by_name(&format!( + "localization/{locale}/crashreporter/crashreporter.ftl" + )) + .with_context(|| format!("failed to locate localization file for {locale}"))?; + + let mut ftl_definitions = String::new(); + file.read_to_string(&mut ftl_definitions) + .with_context(|| format!("failed to read localization file for {locale}"))?; + + Ok(ftl_definitions) +} + +/// Read the branding information from the given zip archive (omnijar). +fn read_branding(locale: &str, archive: &mut ZipArchive) -> anyhow::Result { + let mut file = archive + .by_name(&format!("localization/{locale}/branding/brand.ftl")) + .with_context(|| format!("failed to locate branding localization file for {locale}"))?; + let mut s = String::new(); + file.read_to_string(&mut s) + .with_context(|| format!("failed to read branding localization file for {locale}"))?; + Ok(s) +} + +fn read_omnijar_file(path: &Path) -> anyhow::Result> { + ZipArchive::new( + File::open(&path).with_context(|| format!("failed to open {}", path.display()))?, + ) + .with_context(|| format!("failed to read zip archive in {}", path.display())) +} diff --git a/toolkit/crashreporter/client/app/src/logging.rs b/toolkit/crashreporter/client/app/src/logging.rs new file mode 100644 index 0000000000..c3f85312f8 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/logging.rs @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +//! Application logging facilities. + +use crate::std::{ + self, + path::Path, + sync::{Arc, Mutex}, +}; + +/// Initialize logging and return a log target which can be used to change the destination of log +/// statements. +#[cfg_attr(mock, allow(unused))] +pub fn init() -> LogTarget { + let log_target_inner = LogTargetInner::default(); + + env_logger::builder() + .parse_env( + env_logger::Env::new() + .filter("MOZ_CRASHEREPORTER") + .write_style("MOZ_CRASHREPORTER_STYLE"), + ) + .target(env_logger::fmt::Target::Pipe(Box::new( + log_target_inner.clone(), + ))) + .init(); + + LogTarget { + inner: log_target_inner, + } +} + +/// Controls the target of logging. +#[derive(Clone)] +pub struct LogTarget { + inner: LogTargetInner, +} + +impl LogTarget { + /// Set the file to which log statements will be written. + pub fn set_file(&self, path: &Path) { + match std::fs::File::create(path) { + Ok(file) => { + if let Ok(mut guard) = self.inner.target.lock() { + *guard = Box::new(file); + } + } + Err(e) => log::error!("failed to retarget log to {}: {e}", path.display()), + } + } +} + +/// A private inner class implements Write, allows creation, etc. Externally the `LogTarget` only +/// supports changing the target and nothing else. +#[derive(Clone)] +struct LogTargetInner { + target: Arc>>, +} + +impl Default for LogTargetInner { + fn default() -> Self { + LogTargetInner { + target: Arc::new(Mutex::new(Box::new(std::io::stderr()))), + } + } +} + +impl std::io::Write for LogTargetInner { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let Ok(mut guard) = self.target.lock() else { + // Pretend we wrote successfully. + return Ok(buf.len()); + }; + guard.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + let Ok(mut guard) = self.target.lock() else { + // Pretend we flushed successfully. + return Ok(()); + }; + guard.flush() + } +} diff --git a/toolkit/crashreporter/client/app/src/logic.rs b/toolkit/crashreporter/client/app/src/logic.rs new file mode 100644 index 0000000000..4ad1baa9c6 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/logic.rs @@ -0,0 +1,660 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Business logic for the crash reporter. + +use crate::std::{ + cell::RefCell, + path::PathBuf, + process::Command, + sync::{ + atomic::{AtomicBool, Ordering::Relaxed}, + Arc, Mutex, + }, +}; +use crate::{ + async_task::AsyncTask, + config::Config, + net, + settings::Settings, + std, + ui::{ReportCrashUI, ReportCrashUIState, SubmitState}, +}; +use anyhow::Context; +use uuid::Uuid; + +/// The main crash reporting logic. +pub struct ReportCrash { + pub settings: RefCell, + config: Arc, + extra: serde_json::Value, + settings_file: PathBuf, + attempted_to_send: AtomicBool, + ui: Option>, +} + +impl ReportCrash { + pub fn new(config: Arc, extra: serde_json::Value) -> anyhow::Result { + let settings_file = config.data_dir().join("crashreporter_settings.json"); + let settings: Settings = match std::fs::File::open(&settings_file) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + anyhow::bail!( + "failed to open settings file ({}): {e}", + settings_file.display() + ); + } + Err(_) => Default::default(), + Ok(f) => Settings::from_reader(f)?, + }; + Ok(ReportCrash { + config, + extra, + settings_file, + settings: settings.into(), + attempted_to_send: Default::default(), + ui: None, + }) + } + + /// Returns whether an attempt was made to send the report. + pub fn run(mut self) -> anyhow::Result { + self.set_log_file(); + let hash = self.compute_minidump_hash().map(Some).unwrap_or_else(|e| { + log::warn!("failed to compute minidump hash: {e}"); + None + }); + let ping_uuid = self.send_crash_ping(hash.as_deref()).unwrap_or_else(|e| { + log::warn!("failed to send crash ping: {e}"); + None + }); + if let Err(e) = self.update_events_file(hash.as_deref(), ping_uuid) { + log::warn!("failed to update events file: {e}"); + } + self.sanitize_extra(); + self.check_eol_version()?; + if !self.config.auto_submit { + self.run_ui(); + } else { + anyhow::ensure!(self.try_send().unwrap_or(false), "failed to send report"); + } + + Ok(self.attempted_to_send.load(Relaxed)) + } + + /// Set the log file based on the current configured paths. + /// + /// This is the earliest that this can occur as the configuration data dir may be set based on + /// fields in the extra file. + fn set_log_file(&self) { + if let Some(log_target) = &self.config.log_target { + log_target.set_file(&self.config.data_dir().join("submit.log")); + } + } + + /// Compute the SHA256 hash of the minidump file contents, and return it as a hex string. + fn compute_minidump_hash(&self) -> anyhow::Result { + let hash = { + use sha2::{Digest, Sha256}; + let mut dump_file = std::fs::File::open(self.config.dump_file())?; + let mut hasher = Sha256::new(); + std::io::copy(&mut dump_file, &mut hasher)?; + hasher.finalize() + }; + + let mut s = String::with_capacity(hash.len() * 2); + for byte in hash { + use crate::std::fmt::Write; + write!(s, "{:02x}", byte).unwrap(); + } + + Ok(s) + } + + /// Send a crash ping to telemetry. + /// + /// Returns the crash ping uuid. + fn send_crash_ping(&self, minidump_hash: Option<&str>) -> anyhow::Result> { + if self.config.ping_dir.is_none() { + log::warn!("not sending crash ping because no ping directory configured"); + return Ok(None); + } + + //TODO support glean crash pings (or change pingsender to do so) + + let dump_id = self.config.local_dump_id(); + let ping = net::legacy_telemetry::Ping::crash(&self.extra, dump_id.as_ref(), minidump_hash) + .context("failed to create telemetry crash ping")?; + + let submission_url = ping + .submission_url(&self.extra) + .context("failed to generate ping submission URL")?; + + let target_file = self + .config + .ping_dir + .as_ref() + .unwrap() + .join(format!("{}.json", ping.id())); + + let file = std::fs::File::create(&target_file).with_context(|| { + format!( + "failed to open ping file {} for writing", + target_file.display() + ) + })?; + + serde_json::to_writer(file, &ping).context("failed to serialize telemetry crash ping")?; + + let pingsender_path = self.config.sibling_program_path("pingsender"); + + crate::process::background_command(&pingsender_path) + .arg(submission_url) + .arg(target_file) + .spawn() + .with_context(|| { + format!( + "failed to launch pingsender process at {}", + pingsender_path.display() + ) + })?; + + // TODO asynchronously get pingsender result and log it? + + Ok(Some(ping.id().clone())) + } + + /// Remove unneeded entries from the extra file, and add some that indicate from where the data + /// is being sent. + fn sanitize_extra(&mut self) { + if let Some(map) = self.extra.as_object_mut() { + // Remove these entries, they don't need to be sent. + map.remove("ServerURL"); + map.remove("StackTraces"); + } + + self.extra["SubmittedFrom"] = "Client".into(); + self.extra["Throttleable"] = "1".into(); + } + + /// Update the events file with information about the crash ping, minidump hash, and + /// stacktraces. + fn update_events_file( + &self, + minidump_hash: Option<&str>, + ping_uuid: Option, + ) -> anyhow::Result<()> { + use crate::std::io::{BufRead, Error, ErrorKind, Write}; + struct EventsFile { + event_version: String, + time: String, + uuid: String, + pub data: serde_json::Value, + } + + impl EventsFile { + pub fn parse(mut reader: impl BufRead) -> std::io::Result { + let mut lines = (&mut reader).lines(); + + let mut read_field = move |name: &str| -> std::io::Result { + lines.next().transpose()?.ok_or_else(|| { + Error::new(ErrorKind::InvalidData, format!("missing {name} field")) + }) + }; + + let event_version = read_field("event version")?; + let time = read_field("time")?; + let uuid = read_field("uuid")?; + let data = serde_json::from_reader(reader)?; + Ok(EventsFile { + event_version, + time, + uuid, + data, + }) + } + + pub fn write(&self, mut writer: impl Write) -> std::io::Result<()> { + writeln!(writer, "{}", self.event_version)?; + writeln!(writer, "{}", self.time)?; + writeln!(writer, "{}", self.uuid)?; + serde_json::to_writer(writer, &self.data)?; + Ok(()) + } + } + + let Some(events_dir) = &self.config.events_dir else { + log::warn!("not updating the events file; no events directory configured"); + return Ok(()); + }; + + let event_path = events_dir.join(self.config.local_dump_id().as_ref()); + + // Read events file. + let file = std::fs::File::open(&event_path) + .with_context(|| format!("failed to open event file at {}", event_path.display()))?; + + let mut events_file = + EventsFile::parse(std::io::BufReader::new(file)).with_context(|| { + format!( + "failed to parse events file contents in {}", + event_path.display() + ) + })?; + + // Update events file fields. + if let Some(hash) = minidump_hash { + events_file.data["MinidumpSha256Hash"] = hash.into(); + } + if let Some(uuid) = ping_uuid { + events_file.data["CrashPingUUID"] = uuid.to_string().into(); + } + events_file.data["StackTraces"] = self.extra["StackTraces"].clone(); + + // Write altered events file. + let file = std::fs::File::create(&event_path).with_context(|| { + format!("failed to truncate event file at {}", event_path.display()) + })?; + + events_file + .write(file) + .with_context(|| format!("failed to write event file at {}", event_path.display())) + } + + /// Check whether the version of the software that generated the crash is EOL. + fn check_eol_version(&self) -> anyhow::Result<()> { + if let Some(version) = self.extra["Version"].as_str() { + if self.config.version_eol_file(version).exists() { + self.config.delete_files(); + anyhow::bail!(self.config.string("crashreporter-error-version-eol")); + } + } + Ok(()) + } + + /// Save the current settings. + fn save_settings(&self) { + let result: anyhow::Result<()> = (|| { + Ok(self + .settings + .borrow() + .to_writer(std::fs::File::create(&self.settings_file)?)?) + })(); + if let Err(e) = result { + log::error!("error while saving settings: {e}"); + } + } + + /// Handle a response from submitting a crash report. + /// + /// Returns the crash ID to use for the recorded submission event. Errors in this function may + /// result in None being returned to consider the crash report submission as a failure even + /// though the server did provide a response. + fn handle_crash_report_response( + &self, + response: net::report::Response, + ) -> anyhow::Result> { + if let Some(version) = response.stop_sending_reports_for { + // Create the EOL version file. The content seemingly doesn't matter, but we mimic what + // was written by the old crash reporter. + if let Err(e) = std::fs::write(self.config.version_eol_file(&version), "1\n") { + log::warn!("failed to write EOL file: {e}"); + } + } + + if response.discarded { + log::debug!("response indicated that the report was discarded"); + return Ok(None); + } + + let Some(crash_id) = response.crash_id else { + log::debug!("response did not provide a crash id"); + return Ok(None); + }; + + // Write the id to the `submitted` directory + let submitted_dir = self.config.submitted_crash_dir(); + std::fs::create_dir_all(&submitted_dir).with_context(|| { + format!( + "failed to create submitted crash directory {}", + submitted_dir.display() + ) + })?; + + let crash_id_file = submitted_dir.join(format!("{crash_id}.txt")); + + let mut file = std::fs::File::create(&crash_id_file).with_context(|| { + format!( + "failed to create submitted crash file for {crash_id} ({})", + crash_id_file.display() + ) + })?; + + // Shadow `std::fmt::Write` to use the correct trait below. + use crate::std::io::Write; + + if let Err(e) = writeln!( + &mut file, + "{}", + self.config + .build_string("crashreporter-crash-identifier") + .arg("id", &crash_id) + .get() + ) { + log::warn!( + "failed to write to submitted crash file ({}) for {crash_id}: {e}", + crash_id_file.display() + ); + } + + if let Some(url) = response.view_url { + if let Err(e) = writeln!( + &mut file, + "{}", + self.config + .build_string("crashreporter-crash-details") + .arg("url", url) + .get() + ) { + log::warn!( + "failed to write view url to submitted crash file ({}) for {crash_id}: {e}", + crash_id_file.display() + ); + } + } + + Ok(Some(crash_id)) + } + + /// Write the submission event. + /// + /// A `None` crash_id indicates that the submission failed. + fn write_submission_event(&self, crash_id: Option) -> anyhow::Result<()> { + let Some(events_dir) = &self.config.events_dir else { + // If there's no events dir, don't do anything. + return Ok(()); + }; + + let local_id = self.config.local_dump_id(); + let event_path = events_dir.join(format!("{local_id}-submission")); + + let unix_epoch_seconds = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("system time is before the unix epoch") + .as_secs(); + std::fs::write( + &event_path, + format!( + "crash.submission.1\n{unix_epoch_seconds}\n{local_id}\n{}\n{}", + crash_id.is_some(), + crash_id.as_deref().unwrap_or("") + ), + ) + .with_context(|| format!("failed to write event to {}", event_path.display())) + } + + /// Restart the program. + fn restart_process(&self) { + if self.config.restart_command.is_none() { + // The restart button should be hidden in this case, so this error should not occur. + log::error!("no process configured for restart"); + return; + } + + let mut cmd = Command::new(self.config.restart_command.as_ref().unwrap()); + cmd.args(&self.config.restart_args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + if let Some(xul_app_file) = &self.config.app_file { + cmd.env("XUL_APP_FILE", xul_app_file); + } + log::debug!("restarting process: {:?}", cmd); + if let Err(e) = cmd.spawn() { + log::error!("failed to restart process: {e}"); + } + } + + /// Run the crash reporting UI. + fn run_ui(&mut self) { + use crate::std::{sync::mpsc, thread}; + + let (logic_send, logic_recv) = mpsc::channel(); + // Wrap work_send in an Arc so that it can be captured weakly by the work queue and + // drop when the UI finishes, including panics (allowing the logic thread to exit). + // + // We need to wrap in a Mutex because std::mpsc::Sender isn't Sync (until rust 1.72). + let logic_send = Arc::new(Mutex::new(logic_send)); + + let weak_logic_send = Arc::downgrade(&logic_send); + let logic_remote_queue = AsyncTask::new(move |f| { + if let Some(logic_send) = weak_logic_send.upgrade() { + // This is best-effort: ignore errors. + let _ = logic_send.lock().unwrap().send(f); + } + }); + + let crash_ui = ReportCrashUI::new( + &*self.settings.borrow(), + self.config.clone(), + logic_remote_queue, + ); + + // Set the UI remote queue. + self.ui = Some(crash_ui.async_task()); + + // Spawn a separate thread to handle all interactions with `self`. This prevents blocking + // the UI for any reason. + + // Use a barrier to ensure both threads are live before either starts (ensuring they + // can immediately queue work for each other). + let barrier = std::sync::Barrier::new(2); + let barrier = &barrier; + thread::scope(move |s| { + // Move `logic_send` into this scope so that it will drop when the scope completes + // (which will drop the `mpsc::Sender` and cause the logic thread to complete and join + // when the UI finishes so the scope can exit). + let _logic_send = logic_send; + s.spawn(move || { + barrier.wait(); + while let Ok(f) = logic_recv.recv() { + f(self); + } + // Clear the UI remote queue, using it after this point is an error. + // + // NOTE we do this here because the compiler can't reason about `self` being safely + // accessible after `thread::scope` returns. This is effectively the same result + // since the above loop will only exit when `logic_send` is dropped at the end of + // the scope. + self.ui = None; + }); + + barrier.wait(); + crash_ui.run() + }); + } +} + +// These methods may interact with `self.ui`. +impl ReportCrash { + /// Update the submission details shown in the UI. + pub fn update_details(&self) { + use crate::std::fmt::Write; + + let extra = self.current_extra_data(); + + let mut details = String::new(); + let mut entries: Vec<_> = extra.as_object().unwrap().into_iter().collect(); + entries.sort_unstable_by_key(|(k, _)| *k); + for (key, value) in entries { + let _ = write!(details, "{key}: "); + if let Some(v) = value.as_str() { + details.push_str(v); + } else { + match serde_json::to_string(value) { + Ok(s) => details.push_str(&s), + Err(e) => { + let _ = write!(details, ""); + } + } + } + let _ = writeln!(details); + } + let _ = writeln!( + details, + "{}", + self.config.string("crashreporter-report-info") + ); + + self.ui().push(move |ui| *ui.details.borrow_mut() = details); + } + + /// Restart the application and send the crash report. + pub fn restart(&self) { + self.save_settings(); + // Get the program restarted before sending the report. + self.restart_process(); + let result = self.try_send(); + self.close_window(result.is_some()); + } + + /// Quit and send the crash report. + pub fn quit(&self) { + self.save_settings(); + let result = self.try_send(); + self.close_window(result.is_some()); + } + + fn close_window(&self, report_sent: bool) { + if report_sent && !self.config.auto_submit && !cfg!(test) { + // Add a delay to allow the user to see the result. + std::thread::sleep(std::time::Duration::from_secs(5)); + } + + self.ui().push(|r| r.close_window.fire(&())); + } + + /// Try to send the report. + /// + /// This function may be called without a UI active (if auto_submit is true), so it will not + /// panic if `self.ui` is unset. + /// + /// Returns whether the report was received (regardless of whether the response was processed + /// successfully), if a report could be sent at all (based on the configuration). + fn try_send(&self) -> Option { + self.attempted_to_send.store(true, Relaxed); + let send_report = self.settings.borrow().submit_report; + + if !send_report { + log::trace!("not sending report due to user setting"); + return None; + } + + // TODO? load proxy info from libgconf on linux + + let Some(url) = &self.config.report_url else { + log::warn!("not sending report due to missing report url"); + return None; + }; + + if let Some(ui) = &self.ui { + ui.push(|r| *r.submit_state.borrow_mut() = SubmitState::InProgress); + } + + // Send the report to the server. + let extra = self.current_extra_data(); + let memory_file = self.config.memory_file(); + let report = net::report::CrashReport { + extra: &extra, + dump_file: self.config.dump_file(), + memory_file: memory_file.as_deref(), + url, + }; + + let report_response = report + .send() + .map(Some) + .unwrap_or_else(|e| { + log::error!("failed to initialize report transmission: {e}"); + None + }) + .and_then(|sender| { + // Normally we might want to do the following asynchronously since it will block, + // however we don't really need the Logic thread to do anything else (the UI + // becomes disabled from this point onward), so we just do it here. Same goes for + // the `std::thread::sleep` in close_window() later on. + sender.finish().map(Some).unwrap_or_else(|e| { + log::error!("failed to send report: {e}"); + None + }) + }); + + let report_received = report_response.is_some(); + let crash_id = report_response.and_then(|response| { + self.handle_crash_report_response(response) + .unwrap_or_else(|e| { + log::error!("failed to handle crash report response: {e}"); + None + }) + }); + + if report_received { + // If the response could be handled (indicated by the returned crash id), clean up by + // deleting the minidump files. Otherwise, prune old minidump files. + if crash_id.is_some() { + self.config.delete_files(); + } else { + if let Err(e) = self.config.prune_files() { + log::warn!("failed to prune files: {e}"); + } + } + } + + if let Err(e) = self.write_submission_event(crash_id) { + log::warn!("failed to write submission event: {e}"); + } + + // Indicate whether the report was sent successfully, regardless of whether the response + // was processed successfully. + // + // FIXME: this is how the old crash reporter worked, but we might want to change this + // behavior. + if let Some(ui) = &self.ui { + ui.push(move |r| { + *r.submit_state.borrow_mut() = if report_received { + SubmitState::Success + } else { + SubmitState::Failure + } + }); + } + + Some(report_received) + } + + /// Form the extra data, taking into account user input. + fn current_extra_data(&self) -> serde_json::Value { + let include_address = self.settings.borrow().include_url; + let comment = if !self.config.auto_submit { + self.ui().wait(|r| r.comment.get()) + } else { + Default::default() + }; + + let mut extra = self.extra.clone(); + + if !comment.is_empty() { + extra["Comments"] = comment.into(); + } + + if !include_address { + extra.as_object_mut().unwrap().remove("URL"); + } + + extra + } + + fn ui(&self) -> &AsyncTask { + self.ui.as_ref().expect("UI remote queue missing") + } +} diff --git a/toolkit/crashreporter/client/app/src/main.rs b/toolkit/crashreporter/client/app/src/main.rs new file mode 100644 index 0000000000..07e1b04cb8 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/main.rs @@ -0,0 +1,229 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +//! The crash reporter application. +//! +//! # Architecture +//! The application uses a simple declarative [UI model](ui::model) to define the UI. This model +//! contains [data bindings](data) which provide the dynamic behaviors of the UI. Separate UI +//! implementations for linux (gtk), macos (cocoa), and windows (win32) exist, as well as a test UI +//! which is virtual (no actual interface is presented) but allows runtime introspection. +//! +//! # Mocking +//! This application contains mock interfaces for all the `std` functions it uses which interact +//! with the host system. You can see their implementation in [`crate::std`]. To enable mocking, +//! use the `mock` feature or build with `MOZ_CRASHREPORTER_MOCK` set (which, in `build.rs`, is +//! translated to a `cfg` option). *Note* that this cfg _must_ be enabled when running tests. +//! Unfortunately it is not possible to detect whether tests are being built in `build.rs, which +//! is why a feature needed to be made in the first place (it is enabled automatically when running +//! `mach rusttests`). +//! +//! Currently the input program configuration which is mocked when running the application is fixed +//! (see the [`main`] implementation in this file). If needed in the future, it would be nice to +//! extend this to allow runtime tweaking. +//! +//! # Development +//! Because of the mocking support previously mentioned, in generally any `std` imports should +//! actually use `crate::std`. If mocked functions/types are missing, they should be added with +//! appropriate mocking hooks. + +// Use the WINDOWS windows subsystem. This prevents a console window from opening with the +// application. +#![cfg_attr(windows, windows_subsystem = "windows")] + +use crate::std::sync::Arc; +use anyhow::Context; +use config::Config; + +/// cc is short for Clone Capture, a shorthand way to clone a bunch of values before an expression +/// (particularly useful for closures). +/// +/// It is defined here to allow it to be used in all submodules (textual scope lookup). +macro_rules! cc { + ( ($($c:ident),*) $e:expr ) => { + { + $(let $c = $c.clone();)* + $e + } + } +} + +mod async_task; +mod config; +mod data; +mod lang; +mod logging; +mod logic; +mod net; +mod process; +mod settings; +mod std; +mod thread_bound; +mod ui; + +#[cfg(test)] +mod test; + +#[cfg(not(mock))] +fn main() { + let log_target = logging::init(); + + let mut config = Config::new(); + let config_result = config.read_from_environment(); + config.log_target = Some(log_target); + + let mut config = Arc::new(config); + + let result = config_result.and_then(|()| { + let attempted_send = try_run(&mut config)?; + if !attempted_send { + // Exited without attempting to send the crash report; delete files. + config.delete_files(); + } + Ok(()) + }); + + if let Err(message) = result { + // TODO maybe errors should also delete files? + log::error!("exiting with error: {message}"); + if !config.auto_submit { + // Only show a dialog if auto_submit is disabled. + ui::error_dialog(&config, message); + } + std::process::exit(1); + } +} + +#[cfg(mock)] +fn main() { + // TODO it'd be nice to be able to set these values at runtime in some way when running the + // mock application. + + use crate::std::{ + fs::{MockFS, MockFiles}, + mock, + process::Command, + }; + const MOCK_MINIDUMP_EXTRA: &str = r#"{ + "Vendor": "FooCorp", + "ProductName": "Bar", + "ReleaseChannel": "release", + "BuildID": "1234", + "StackTraces": { + "status": "OK" + }, + "Version": "100.0", + "ServerURL": "https://reports.example", + "TelemetryServerURL": "https://telemetry.example", + "TelemetryClientId": "telemetry_client", + "TelemetrySessionId": "telemetry_session", + "URL": "https://url.example" + }"#; + + // Actual content doesn't matter, aside from the hash that is generated. + const MOCK_MINIDUMP_FILE: &[u8] = &[1, 2, 3, 4]; + const MOCK_CURRENT_TIME: &str = "2004-11-09T12:34:56Z"; + const MOCK_PING_UUID: uuid::Uuid = uuid::Uuid::nil(); + const MOCK_REMOTE_CRASH_ID: &str = "8cbb847c-def2-4f68-be9e-000000000000"; + + // Create a default set of files which allow successful operation. + let mock_files = MockFiles::new(); + mock_files + .add_file("minidump.dmp", MOCK_MINIDUMP_FILE) + .add_file("minidump.extra", MOCK_MINIDUMP_EXTRA); + + // Create a default mock environment which allows successful operation. + let mut mock = mock::builder(); + mock.set( + Command::mock("work_dir/minidump-analyzer"), + Box::new(|_| Ok(crate::std::process::success_output())), + ) + .set( + Command::mock("work_dir/pingsender"), + Box::new(|_| Ok(crate::std::process::success_output())), + ) + .set( + Command::mock("curl"), + Box::new(|_| { + let mut output = crate::std::process::success_output(); + output.stdout = format!("CrashID={MOCK_REMOTE_CRASH_ID}").into(); + // Network latency. + std::thread::sleep(std::time::Duration::from_secs(2)); + Ok(output) + }), + ) + .set(MockFS, mock_files.clone()) + .set( + crate::std::env::MockCurrentExe, + "work_dir/crashreporter".into(), + ) + .set( + crate::std::time::MockCurrentTime, + time::OffsetDateTime::parse( + MOCK_CURRENT_TIME, + &time::format_description::well_known::Rfc3339, + ) + .unwrap() + .into(), + ) + .set(mock::MockHook::new("ping_uuid"), MOCK_PING_UUID); + + let result = mock.run(|| { + let mut cfg = Config::new(); + cfg.data_dir = Some("data_dir".into()); + cfg.events_dir = Some("events_dir".into()); + cfg.ping_dir = Some("ping_dir".into()); + cfg.dump_file = Some("minidump.dmp".into()); + cfg.restart_command = Some("mockfox".into()); + cfg.strings = Some(lang::load().unwrap()); + let mut cfg = Arc::new(cfg); + try_run(&mut cfg) + }); + + if let Err(e) = result { + log::error!("exiting with error: {e}"); + std::process::exit(1); + } +} + +fn try_run(config: &mut Arc) -> anyhow::Result { + if config.dump_file.is_none() { + if !config.auto_submit { + Err(anyhow::anyhow!(config.string("crashreporter-information"))) + } else { + Ok(false) + } + } else { + // Run minidump-analyzer to gather stack traces. + { + let analyzer_path = config.sibling_program_path("minidump-analyzer"); + let mut cmd = crate::process::background_command(&analyzer_path); + if config.dump_all_threads { + cmd.arg("--full"); + } + cmd.arg(config.dump_file()); + let output = cmd + .output() + .with_context(|| config.string("crashreporter-error-minidump-analyzer"))?; + if !output.status.success() { + log::warn!( + "minidump-analyzer failed to run ({});\n\nstderr: {}\n\nstdout: {}", + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + } + } + + let extra = { + // Perform a few things which may change the config, then treat is as immutable. + let config = Arc::get_mut(config).expect("unexpected config references"); + let extra = config.load_extra_file()?; + config.move_crash_data_to_pending()?; + extra + }; + + logic::ReportCrash::new(config.clone(), extra)?.run() + } +} diff --git a/toolkit/crashreporter/client/app/src/net/legacy_telemetry.rs b/toolkit/crashreporter/client/app/src/net/legacy_telemetry.rs new file mode 100644 index 0000000000..680f1614b0 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/net/legacy_telemetry.rs @@ -0,0 +1,177 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Support for legacy telemetry ping creation. The ping support serialization which should be used +//! when submitting. + +use anyhow::Context; +use serde::Serialize; +use std::collections::BTreeMap; +use uuid::Uuid; + +const TELEMETRY_VERSION: u64 = 4; +const PAYLOAD_VERSION: u64 = 1; + +// Generated by `build.rs`. +// static PING_ANNOTATIONS: phf::Set<&'static str>; +include!(concat!(env!("OUT_DIR"), "/ping_annotations.rs")); + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Ping<'a> { + Crash { + id: Uuid, + version: u64, + #[serde(with = "time::serde::rfc3339")] + creation_date: time::OffsetDateTime, + client_id: &'a str, + #[serde(skip_serializing_if = "serde_json::Value::is_null")] + environment: serde_json::Value, + payload: Payload<'a>, + application: Application<'a>, + }, +} + +time::serde::format_description!(date_format, Date, "[year]-[month]-[day]"); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Payload<'a> { + session_id: &'a str, + version: u64, + #[serde(with = "date_format")] + crash_date: time::Date, + #[serde(with = "time::serde::rfc3339")] + crash_time: time::OffsetDateTime, + has_crash_environment: bool, + crash_id: &'a str, + minidump_sha256_hash: Option<&'a str>, + process_type: &'a str, + #[serde(skip_serializing_if = "serde_json::Value::is_null")] + stack_traces: serde_json::Value, + metadata: BTreeMap<&'a str, &'a str>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Application<'a> { + vendor: &'a str, + name: &'a str, + build_id: &'a str, + display_version: String, + platform_version: String, + version: &'a str, + channel: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + architecture: Option, + #[serde(skip_serializing_if = "Option::is_none")] + xpcom_abi: Option, +} + +impl<'a> Ping<'a> { + pub fn crash( + extra: &'a serde_json::Value, + crash_id: &'a str, + minidump_sha256_hash: Option<&'a str>, + ) -> anyhow::Result { + let now: time::OffsetDateTime = crate::std::time::SystemTime::now().into(); + let environment: serde_json::Value = extra["TelemetryEnvironment"] + .as_str() + .and_then(|estr| serde_json::from_str(estr).ok()) + .unwrap_or_default(); + + // The subset of extra file entries (crash annotations) which are allowed in pings. + let metadata = extra + .as_object() + .map(|map| { + map.iter() + .filter_map(|(k, v)| { + PING_ANNOTATIONS + .contains(k) + .then(|| k.as_str()) + .zip(v.as_str()) + }) + .collect() + }) + .unwrap_or_default(); + + let display_version = environment + .pointer("/build/displayVersion") + .and_then(|s| s.as_str()) + .unwrap_or_default() + .to_owned(); + let platform_version = environment + .pointer("/build/platformVersion") + .and_then(|s| s.as_str()) + .unwrap_or_default() + .to_owned(); + let architecture = environment + .pointer("/build/architecture") + .and_then(|s| s.as_str()) + .map(ToOwned::to_owned); + let xpcom_abi = environment + .pointer("/build/xpcomAbi") + .and_then(|s| s.as_str()) + .map(ToOwned::to_owned); + + Ok(Ping::Crash { + id: crate::std::mock::hook(Uuid::new_v4(), "ping_uuid"), + version: TELEMETRY_VERSION, + creation_date: now, + client_id: extra["TelemetryClientId"] + .as_str() + .context("missing TelemetryClientId")?, + environment, + payload: Payload { + session_id: extra["TelemetrySessionId"] + .as_str() + .context("missing TelemetrySessionId")?, + version: PAYLOAD_VERSION, + crash_date: now.date(), + crash_time: now, + has_crash_environment: true, + crash_id, + minidump_sha256_hash, + process_type: "main", + stack_traces: extra["StackTraces"].clone(), + metadata, + }, + application: Application { + vendor: extra["Vendor"].as_str().unwrap_or_default(), + name: extra["ProductName"].as_str().unwrap_or_default(), + build_id: extra["BuildID"].as_str().unwrap_or_default(), + display_version, + platform_version, + version: extra["Version"].as_str().unwrap_or_default(), + channel: extra["ReleaseChannel"].as_str().unwrap_or_default(), + architecture, + xpcom_abi, + }, + }) + } + + /// Generate the telemetry URL for submitting this ping. + pub fn submission_url(&self, extra: &serde_json::Value) -> anyhow::Result { + let url = extra["TelemetryServerURL"] + .as_str() + .context("missing TelemetryServerURL")?; + let id = self.id(); + let name = extra["ProductName"] + .as_str() + .context("missing ProductName")?; + let version = extra["Version"].as_str().context("missing Version")?; + let channel = extra["ReleaseChannel"] + .as_str() + .context("missing ReleaseChannel")?; + let buildid = extra["BuildID"].as_str().context("missing BuildID")?; + Ok(format!("{url}/submit/telemetry/{id}/crash/{name}/{version}/{channel}/{buildid}?v={TELEMETRY_VERSION}")) + } + + /// Get the ping identifier. + pub fn id(&self) -> &Uuid { + match self { + Ping::Crash { id, .. } => id, + } + } +} diff --git a/toolkit/crashreporter/client/app/src/net/libcurl.rs b/toolkit/crashreporter/client/app/src/net/libcurl.rs new file mode 100644 index 0000000000..0adfd7d4b4 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/net/libcurl.rs @@ -0,0 +1,406 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +//! Partial libcurl bindings with some wrappers for safe cleanup. + +use crate::std::path::Path; +use libloading::{Library, Symbol}; +use once_cell::sync::Lazy; +use std::ffi::{c_char, c_long, c_uint, CStr, CString}; + +// Constants lifted from `curl.h` +const CURLE_OK: CurlCode = 0; +const CURL_ERROR_SIZE: usize = 256; + +const CURLOPTTYPE_LONG: CurlOption = 0; +const CURLOPTTYPE_OBJECTPOINT: CurlOption = 10000; +const CURLOPTTYPE_FUNCTIONPOINT: CurlOption = 20000; +const CURLOPTTYPE_STRINGPOINT: CurlOption = CURLOPTTYPE_OBJECTPOINT; +const CURLOPTTYPE_CBPOINT: CurlOption = CURLOPTTYPE_OBJECTPOINT; + +const CURLOPT_WRITEDATA: CurlOption = CURLOPTTYPE_CBPOINT + 1; +const CURLOPT_URL: CurlOption = CURLOPTTYPE_STRINGPOINT + 2; +const CURLOPT_ERRORBUFFER: CurlOption = CURLOPTTYPE_OBJECTPOINT + 10; +const CURLOPT_WRITEFUNCTION: CurlOption = CURLOPTTYPE_FUNCTIONPOINT + 11; +const CURLOPT_USERAGENT: CurlOption = CURLOPTTYPE_STRINGPOINT + 18; +const CURLOPT_MIMEPOST: CurlOption = CURLOPTTYPE_OBJECTPOINT + 269; +const CURLOPT_MAXREDIRS: CurlOption = CURLOPTTYPE_LONG + 68; + +const CURLINFO_LONG: CurlInfo = 0x200000; +const CURLINFO_RESPONSE_CODE: CurlInfo = CURLINFO_LONG + 2; + +const CURL_LIB_NAMES: &[&str] = if cfg!(target_os = "linux") { + &[ + "libcurl.so", + "libcurl.so.4", + // Debian gives libcurl a different name when it is built against GnuTLS + "libcurl-gnutls.so", + "libcurl-gnutls.so.4", + // Older versions in case we find nothing better + "libcurl.so.3", + "libcurl-gnutls.so.3", // See above for Debian + ] +} else if cfg!(target_os = "macos") { + &[ + "/usr/lib/libcurl.dylib", + "/usr/lib/libcurl.4.dylib", + "/usr/lib/libcurl.3.dylib", + ] +} else if cfg!(target_os = "windows") { + &["libcurl.dll", "curl.dll"] +} else { + &[] +}; + +// Shim until min rust version 1.74 which allows std::io::Error::other +fn error_other(error: E) -> std::io::Error +where + E: Into>, +{ + std::io::Error::new(std::io::ErrorKind::Other, error) +} + +#[repr(transparent)] +#[derive(Clone, Copy)] +struct CurlHandle(*mut ()); +type CurlCode = c_uint; +type CurlOption = c_uint; +type CurlInfo = c_uint; +#[repr(transparent)] +#[derive(Clone, Copy)] +struct CurlMime(*mut ()); +#[repr(transparent)] +#[derive(Clone, Copy)] +struct CurlMimePart(*mut ()); + +macro_rules! library_binding { + ( $localname:ident members[$($members:tt)*] load[$($load:tt)*] fn $name:ident $args:tt $( -> $ret:ty )? ; $($rest:tt)* ) => { + library_binding! { + $localname + members[ + $($members)* + $name: Symbol<'static, unsafe extern fn $args $(->$ret)?>, + ] + load[ + $($load)* + $name: unsafe { + let symbol = $localname.get::$ret)?>(stringify!($name).as_bytes()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?; + // All symbols refer to library, so `'static` lifetimes are safe (`library` + // will outlive them). + std::mem::transmute(symbol) + }, + ] + $($rest)* + } + }; + ( $localname:ident members[$($members:tt)*] load[$($load:tt)*] ) => { + pub struct Curl { + $($members)* + _library: Library + } + + impl Curl { + fn load() -> std::io::Result { + // Try each of the libraries, debug-logging load failures. + let library = CURL_LIB_NAMES.iter().find_map(|name| { + log::debug!("attempting to load {name}"); + match unsafe { Library::new(name) } { + Ok(lib) => { + log::info!("loaded {name}"); + Some(lib) + } + Err(e) => { + log::debug!("error when loading {name}: {e}"); + None + } + } + }); + + let $localname = library.ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "failed to find curl library") + })?; + + Ok(Curl { $($load)* _library: $localname }) + } + } + }; + ( $($rest:tt)* ) => { + library_binding! { + library members[] load[] $($rest)* + } + } +} + +library_binding! { + fn curl_easy_init() -> CurlHandle; + fn curl_easy_setopt(CurlHandle, CurlOption, ...) -> CurlCode; + fn curl_easy_perform(CurlHandle) -> CurlCode; + fn curl_easy_getinfo(CurlHandle, CurlInfo, ...) -> CurlCode; + fn curl_easy_cleanup(CurlHandle); + fn curl_mime_init(CurlHandle) -> CurlMime; + fn curl_mime_addpart(CurlMime) -> CurlMimePart; + fn curl_mime_name(CurlMimePart, *const c_char) -> CurlCode; + fn curl_mime_filename(CurlMimePart, *const c_char) -> CurlCode; + fn curl_mime_type(CurlMimePart, *const c_char) -> CurlCode; + fn curl_mime_data(CurlMimePart, *const c_char, usize) -> CurlCode; + fn curl_mime_filedata(CurlMimePart, *const c_char) -> CurlCode; + fn curl_mime_free(CurlMime); +} + +/// Load libcurl if possible. +pub fn load() -> std::io::Result<&'static Curl> { + static CURL: Lazy> = Lazy::new(Curl::load); + CURL.as_ref().map_err(error_other) +} + +#[derive(Debug)] +pub struct Error { + code: CurlCode, + error: Option, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "curl error code {}", self.code)?; + if let Some(e) = &self.error { + write!(f, ": {e}")?; + } + Ok(()) + } +} + +impl std::error::Error for Error {} + +impl From for std::io::Error { + fn from(e: Error) -> Self { + error_other(e) + } +} + +pub type Result = std::result::Result; + +fn to_result(code: CurlCode) -> Result<()> { + if code == CURLE_OK { + Ok(()) + } else { + Err(Error { code, error: None }) + } +} + +impl Curl { + pub fn easy(&self) -> std::io::Result { + let handle = unsafe { (self.curl_easy_init)() }; + if handle.0.is_null() { + Err(error_other("curl_easy_init failed")) + } else { + Ok(Easy { + lib: self, + handle, + mime: Default::default(), + }) + } + } +} + +struct ErrorBuffer([u8; CURL_ERROR_SIZE]); + +impl Default for ErrorBuffer { + fn default() -> Self { + ErrorBuffer([0; CURL_ERROR_SIZE]) + } +} + +pub struct Easy<'a> { + lib: &'a Curl, + handle: CurlHandle, + mime: Option>, +} + +impl<'a> Easy<'a> { + pub fn set_url(&mut self, url: &str) -> Result<()> { + let url = CString::new(url.to_string()).unwrap(); + to_result(unsafe { (self.lib.curl_easy_setopt)(self.handle, CURLOPT_URL, url.as_ptr()) }) + } + + pub fn set_user_agent(&mut self, user_agent: &str) -> Result<()> { + let ua = CString::new(user_agent.to_string()).unwrap(); + to_result(unsafe { + (self.lib.curl_easy_setopt)(self.handle, CURLOPT_USERAGENT, ua.as_ptr()) + }) + } + + pub fn mime(&self) -> std::io::Result> { + let handle = unsafe { (self.lib.curl_mime_init)(self.handle) }; + if handle.0.is_null() { + Err(error_other("curl_mime_init failed")) + } else { + Ok(Mime { + lib: self.lib, + handle, + }) + } + } + + pub fn set_mime_post(&mut self, mime: Mime<'a>) -> Result<()> { + let result = to_result(unsafe { + (self.lib.curl_easy_setopt)(self.handle, CURLOPT_MIMEPOST, mime.handle) + }); + if result.is_ok() { + self.mime = Some(mime); + } + result + } + + pub fn set_max_redirs(&mut self, redirs: c_long) -> Result<()> { + to_result(unsafe { (self.lib.curl_easy_setopt)(self.handle, CURLOPT_MAXREDIRS, redirs) }) + } + + /// Returns the response data on success. + pub fn perform(&self) -> Result> { + // Set error buffer, but degrade service if it doesn't work. + let mut error_buffer = ErrorBuffer::default(); + let error_buffer_set = unsafe { + (self.lib.curl_easy_setopt)( + self.handle, + CURLOPT_ERRORBUFFER, + error_buffer.0.as_mut_ptr() as *mut c_char, + ) + } == CURLE_OK; + + // Set the write function to fill a Vec. If there is a panic, this might leave stale + // pointers in the curl options, but they won't be used without another perform, at which + // point they'll be overwritten. + let mut data: Vec = Vec::new(); + extern "C" fn write_callback( + data: *const u8, + size: usize, + nmemb: usize, + dest: &mut Vec, + ) -> usize { + let total = size * nmemb; + dest.extend(unsafe { std::slice::from_raw_parts(data, total) }); + total + } + unsafe { + to_result((self.lib.curl_easy_setopt)( + self.handle, + CURLOPT_WRITEFUNCTION, + write_callback as extern "C" fn(*const u8, usize, usize, &mut Vec) -> usize, + ))?; + to_result((self.lib.curl_easy_setopt)( + self.handle, + CURLOPT_WRITEDATA, + &mut data as *mut _, + ))?; + }; + + let mut result = to_result(unsafe { (self.lib.curl_easy_perform)(self.handle) }); + + // Clean up a bit by unsetting the write function and write data, though they won't be used + // anywhere else. Ignore return values. + unsafe { + (self.lib.curl_easy_setopt)( + self.handle, + CURLOPT_WRITEFUNCTION, + std::ptr::null_mut::<()>(), + ); + (self.lib.curl_easy_setopt)(self.handle, CURLOPT_WRITEDATA, std::ptr::null_mut::<()>()); + } + + if error_buffer_set { + unsafe { + (self.lib.curl_easy_setopt)( + self.handle, + CURLOPT_ERRORBUFFER, + std::ptr::null_mut::<()>(), + ) + }; + if let Err(e) = &mut result { + if let Ok(cstr) = CStr::from_bytes_until_nul(error_buffer.0.as_slice()) { + e.error = Some(cstr.to_string_lossy().into_owned()); + } + } + } + + result.map(move |()| data) + } + + pub fn get_response_code(&self) -> Result { + let mut code = c_long::default(); + to_result(unsafe { + (self.lib.curl_easy_getinfo)( + self.handle, + CURLINFO_RESPONSE_CODE, + &mut code as *mut c_long, + ) + })?; + Ok(code.try_into().expect("negative http response code")) + } +} + +impl Drop for Easy<'_> { + fn drop(&mut self) { + self.mime.take(); + unsafe { (self.lib.curl_easy_cleanup)(self.handle) }; + } +} + +pub struct Mime<'a> { + lib: &'a Curl, + handle: CurlMime, +} + +impl<'a> Mime<'a> { + pub fn add_part(&mut self) -> std::io::Result> { + let handle = unsafe { (self.lib.curl_mime_addpart)(self.handle) }; + if handle.0.is_null() { + Err(error_other("curl_mime_addpart failed")) + } else { + Ok(MimePart { + lib: self.lib, + handle, + }) + } + } +} + +impl Drop for Mime<'_> { + fn drop(&mut self) { + unsafe { (self.lib.curl_mime_free)(self.handle) }; + } +} + +pub struct MimePart<'a> { + lib: &'a Curl, + handle: CurlMimePart, +} + +impl MimePart<'_> { + pub fn set_name(&mut self, name: &str) -> Result<()> { + let name = CString::new(name.to_string()).unwrap(); + to_result(unsafe { (self.lib.curl_mime_name)(self.handle, name.as_ptr()) }) + } + + pub fn set_filename(&mut self, filename: &str) -> Result<()> { + let filename = CString::new(filename.to_string()).unwrap(); + to_result(unsafe { (self.lib.curl_mime_filename)(self.handle, filename.as_ptr()) }) + } + + pub fn set_type(&mut self, mime_type: &str) -> Result<()> { + let mime_type = CString::new(mime_type.to_string()).unwrap(); + to_result(unsafe { (self.lib.curl_mime_type)(self.handle, mime_type.as_ptr()) }) + } + + pub fn set_filedata(&mut self, file: &Path) -> Result<()> { + let file = CString::new(file.display().to_string()).unwrap(); + to_result(unsafe { (self.lib.curl_mime_filedata)(self.handle, file.as_ptr()) }) + } + + pub fn set_data(&mut self, data: &[u8]) -> Result<()> { + to_result(unsafe { + (self.lib.curl_mime_data)(self.handle, data.as_ptr() as *const c_char, data.len()) + }) + } +} diff --git a/toolkit/crashreporter/client/app/src/net/mod.rs b/toolkit/crashreporter/client/app/src/net/mod.rs new file mode 100644 index 0000000000..d9951d7fc3 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/net/mod.rs @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +pub mod legacy_telemetry; +mod libcurl; +pub mod report; + +#[cfg(test)] +pub fn can_load_libcurl() -> bool { + libcurl::load().is_ok() +} diff --git a/toolkit/crashreporter/client/app/src/net/report.rs b/toolkit/crashreporter/client/app/src/net/report.rs new file mode 100644 index 0000000000..46be952547 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/net/report.rs @@ -0,0 +1,276 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +//! Support for crash report creation and upload. +//! +//! Upload currently uses the system libcurl or curl binary rather than a rust network stack (as +//! curl is more mature, albeit the code to interact with it must be a bit more careful). + +use crate::std::{ffi::OsStr, path::Path, process::Child}; +use anyhow::Context; + +#[cfg(mock)] +use crate::std::mock::{mock_key, MockKey}; + +#[cfg(mock)] +mock_key! { + pub struct MockLibCurl => Box std::io::Result> + Send + Sync> +} + +pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + +/// A crash report to upload. +/// +/// Post a multipart form payload to the report URL. +/// +/// The form data contains: +/// | name | filename | content | mime | +/// ==================================== +/// | `extra` | `extra.json` | extra json object | `application/json`| +/// | `upload_file_minidump` | dump file name | dump file contents | derived (probably application/binary) | +/// if present: +/// | `memory_report` | memory file name | memory file contents | derived (probably gzipped json) | +pub struct CrashReport<'a> { + pub extra: &'a serde_json::Value, + pub dump_file: &'a Path, + pub memory_file: Option<&'a Path>, + pub url: &'a OsStr, +} + +impl CrashReport<'_> { + /// Send the crash report. + pub fn send(&self) -> std::io::Result { + // Windows 10+ and macOS 10.15+ contain `curl` 7.64.1+ as a system-provided binary, so + // `send_with_curl_binary` should not fail. + // + // Linux distros generally do not contain `curl`, but `libcurl` is very likely to be + // incidentally installed (if not outright part of the distro base packages). Based on a + // cursory look at the debian repositories as an examplar, the curl binary is much less + // likely to be incidentally installed. + // + // For uniformity, we always will try the curl binary first, then try libcurl if that + // fails. + + let extra_json_data = serde_json::to_string(self.extra)?; + + self.send_with_curl_binary(extra_json_data.clone()) + .or_else(|e| { + log::info!("failed to invoke curl ({e}), trying libcurl"); + self.send_with_libcurl(extra_json_data.clone()) + }) + } + + /// Send the crash report using the `curl` binary. + fn send_with_curl_binary(&self, extra_json_data: String) -> std::io::Result { + let mut cmd = crate::process::background_command("curl"); + + cmd.args(["--user-agent", USER_AGENT]); + + cmd.arg("--form"); + // `@-` causes the data to be read from stdin, which is desirable to not have to worry + // about process argument string length limitations (though they are generally pretty high + // limits). + cmd.arg("extra=@-;filename=extra.json;type=application/json"); + + cmd.arg("--form"); + cmd.arg(format!( + "upload_file_minidump=@{}", + CurlQuote(&self.dump_file.display().to_string()) + )); + + if let Some(path) = self.memory_file { + cmd.arg("--form"); + cmd.arg(format!( + "memory_report=@{}", + CurlQuote(&path.display().to_string()) + )); + } + + cmd.arg(self.url); + + cmd.stdin(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + cmd.spawn().map(move |child| CrashReportSender::CurlChild { + child, + extra_json_data, + }) + } + + /// Send the crash report using the `curl` library. + fn send_with_libcurl(&self, extra_json_data: String) -> std::io::Result { + #[cfg(mock)] + if !crate::std::mock::try_hook(false, "use_system_libcurl") { + return self.send_with_mock_libcurl(extra_json_data); + } + + let curl = super::libcurl::load()?; + let mut easy = curl.easy()?; + + easy.set_url(&self.url.to_string_lossy())?; + easy.set_user_agent(USER_AGENT)?; + easy.set_max_redirs(30)?; + + let mut mime = easy.mime()?; + { + let mut part = mime.add_part()?; + part.set_name("extra")?; + part.set_filename("extra.json")?; + part.set_type("application/json")?; + part.set_data(extra_json_data.as_bytes())?; + } + { + let mut part = mime.add_part()?; + part.set_name("upload_file_minidump")?; + part.set_filename(&self.dump_file.display().to_string())?; + part.set_filedata(self.dump_file)?; + } + if let Some(path) = self.memory_file { + let mut part = mime.add_part()?; + part.set_name("memory_report")?; + part.set_filename(&path.display().to_string())?; + part.set_filedata(path)?; + } + easy.set_mime_post(mime)?; + + Ok(CrashReportSender::LibCurl { easy }) + } + + #[cfg(mock)] + fn send_with_mock_libcurl( + &self, + _extra_json_data: String, + ) -> std::io::Result { + MockLibCurl + .get(|f| f(&self)) + .map(|response| CrashReportSender::MockLibCurl { response }) + } +} + +pub enum CrashReportSender { + CurlChild { + child: Child, + extra_json_data: String, + }, + LibCurl { + easy: super::libcurl::Easy<'static>, + }, + #[cfg(mock)] + MockLibCurl { + response: std::io::Result, + }, +} + +impl CrashReportSender { + pub fn finish(self) -> anyhow::Result { + let response = match self { + Self::CurlChild { + mut child, + extra_json_data, + } => { + { + let mut stdin = child + .stdin + .take() + .context("failed to get curl process stdin")?; + std::io::copy(&mut std::io::Cursor::new(extra_json_data), &mut stdin) + .context("failed to write extra file data to stdin of curl process")?; + // stdin is dropped at the end of this scope so that the stream gets an EOF, + // otherwise curl will wait for more input. + } + let output = child + .wait_with_output() + .context("failed to wait on curl process")?; + anyhow::ensure!( + output.status.success(), + "process failed (exit status {}) with stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() + } + Self::LibCurl { easy } => { + let response = easy.perform()?; + let response_code = easy.get_response_code()?; + + let response = String::from_utf8_lossy(&response).into_owned(); + dbg!(&response, &response_code); + + anyhow::ensure!( + response_code == 200, + "unexpected response code ({response_code}): {response}" + ); + + response + } + #[cfg(mock)] + Self::MockLibCurl { response } => response?.into(), + }; + + log::debug!("received response from sending report: {:?}", &*response); + Ok(Response::parse(response)) + } +} + +/// A parsed response from submitting a crash report. +#[derive(Default, Debug)] +pub struct Response { + pub crash_id: Option, + pub stop_sending_reports_for: Option, + pub view_url: Option, + pub discarded: bool, +} + +impl Response { + /// Parse a server response. + /// + /// The response should be newline-separated `=` pairs. + fn parse>(response: S) -> Self { + let mut ret = Self::default(); + // Fields may be omitted, and parsing is best-effort but will not produce any errors (just + // a default Response struct). + for line in response.as_ref().lines() { + if let Some((key, value)) = line.split_once('=') { + match key { + "StopSendingReportsFor" => { + ret.stop_sending_reports_for = Some(value.to_owned()) + } + "Discarded" => ret.discarded = true, + "CrashID" => ret.crash_id = Some(value.to_owned()), + "ViewURL" => ret.view_url = Some(value.to_owned()), + _ => (), + } + } + } + ret + } +} + +/// Quote a string per https://curl.se/docs/manpage.html#-F. +/// That is, add quote characters and escape " and \ with backslashes. +struct CurlQuote<'a>(&'a str); +impl std::fmt::Display for CurlQuote<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::fmt::Write; + + f.write_char('"')?; + const ESCAPE_CHARS: [char; 2] = ['"', '\\']; + for substr in self.0.split_inclusive(ESCAPE_CHARS) { + // The last string returned by `split_inclusive` may or may not contain the + // search character, unfortunately. + if substr.ends_with(ESCAPE_CHARS) { + // Safe to use a byte offset rather than a character offset because the + // ESCAPE_CHARS are each 1 byte in utf8. + let (s, escape) = substr.split_at(substr.len() - 1); + f.write_str(s)?; + f.write_char('\\')?; + f.write_str(escape)?; + } else { + f.write_str(substr)?; + } + } + f.write_char('"') + } +} diff --git a/toolkit/crashreporter/client/app/src/process.rs b/toolkit/crashreporter/client/app/src/process.rs new file mode 100644 index 0000000000..126e5b533b --- /dev/null +++ b/toolkit/crashreporter/client/app/src/process.rs @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Process utility functions. + +use crate::std::{ffi::OsStr, process::Command}; + +/// Return a command configured to run in the background. +/// +/// This means that no associated console will be opened, when applicable. +pub fn background_command>(program: S) -> Command { + #[allow(unused_mut)] + let mut cmd = Command::new(program); + #[cfg(windows)] + { + #[cfg_attr(mock, allow(unused))] + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} diff --git a/toolkit/crashreporter/client/app/src/settings.rs b/toolkit/crashreporter/client/app/src/settings.rs new file mode 100644 index 0000000000..7ea0e4fe26 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/settings.rs @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +//! Persistent settings of the application. + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct Settings { + /// Whether a crash report should be sent. + pub submit_report: bool, + /// Whether the URL that was open should be included in a sent report. + pub include_url: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + submit_report: true, + include_url: false, + } + } +} + +impl Settings { + /// Write the settings to the given writer. + pub fn to_writer(&self, writer: W) -> anyhow::Result<()> { + Ok(serde_json::to_writer_pretty(writer, self)?) + } + + /// Read the settings from the given reader. + pub fn from_reader(reader: R) -> anyhow::Result { + Ok(serde_json::from_reader(reader)?) + } + + #[cfg(test)] + pub fn to_string(&self) -> String { + serde_json::to_string_pretty(self).unwrap() + } +} diff --git a/toolkit/crashreporter/client/app/src/std/env.rs b/toolkit/crashreporter/client/app/src/std/env.rs new file mode 100644 index 0000000000..edc22ded8d --- /dev/null +++ b/toolkit/crashreporter/client/app/src/std/env.rs @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use super::mock::{mock_key, MockKey}; +pub use std::env::VarError; +use std::ffi::{OsStr, OsString}; + +mock_key! { + pub struct MockCurrentExe => std::path::PathBuf +} + +pub struct ArgsOs { + argv0: Option, +} + +impl Iterator for ArgsOs { + type Item = OsString; + + fn next(&mut self) -> Option { + Some( + self.argv0 + .take() + .expect("only argv[0] is available when mocked"), + ) + } +} + +pub fn var>(_key: K) -> Result { + unimplemented!("no var access in tests") +} + +pub fn var_os>(_key: K) -> Option { + unimplemented!("no var access in tests") +} + +pub fn args_os() -> ArgsOs { + MockCurrentExe.get(|r| ArgsOs { + argv0: Some(r.clone().into()), + }) +} + +pub fn current_exe() -> std::io::Result { + Ok(MockCurrentExe.get(|r| r.clone().into())) +} diff --git a/toolkit/crashreporter/client/app/src/std/fs.rs b/toolkit/crashreporter/client/app/src/std/fs.rs new file mode 100644 index 0000000000..8ba2c572d5 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/std/fs.rs @@ -0,0 +1,559 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::std::mock::{mock_key, MockKey}; +use std::collections::HashMap; +use std::ffi::OsString; +use std::io::{ErrorKind, Read, Result, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +/// Mock filesystem file content. +#[derive(Debug, Default, Clone)] +pub struct MockFileContent(Arc>>); + +impl MockFileContent { + pub fn empty() -> Self { + Self::default() + } + + pub fn new(data: String) -> Self { + Self::new_bytes(data.into()) + } + + pub fn new_bytes(data: Vec) -> Self { + MockFileContent(Arc::new(Mutex::new(data))) + } +} + +impl From<()> for MockFileContent { + fn from(_: ()) -> Self { + Self::empty() + } +} + +impl From for MockFileContent { + fn from(s: String) -> Self { + Self::new(s) + } +} + +impl From<&str> for MockFileContent { + fn from(s: &str) -> Self { + Self::new(s.to_owned()) + } +} + +impl From> for MockFileContent { + fn from(bytes: Vec) -> Self { + Self::new_bytes(bytes) + } +} + +impl From<&[u8]> for MockFileContent { + fn from(bytes: &[u8]) -> Self { + Self::new_bytes(bytes.to_owned()) + } +} + +/// Mocked filesystem directory entries. +pub type MockDirEntries = HashMap; + +/// The content of a mock filesystem item. +pub enum MockFSContent { + /// File content. + File(Result), + /// A directory with the given entries. + Dir(MockDirEntries), +} + +impl std::fmt::Debug for MockFSContent { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::File(_) => f.debug_tuple("File").finish(), + Self::Dir(e) => f.debug_tuple("Dir").field(e).finish(), + } + } +} + +/// A mock filesystem item. +#[derive(Debug)] +pub struct MockFSItem { + /// The content of the item (file/dir). + pub content: MockFSContent, + /// The modification time of the item. + pub modified: SystemTime, +} + +impl From for MockFSItem { + fn from(content: MockFSContent) -> Self { + MockFSItem { + content, + modified: SystemTime::UNIX_EPOCH, + } + } +} + +/// A mock filesystem. +#[derive(Debug, Clone)] +pub struct MockFiles { + root: Arc>, +} + +impl Default for MockFiles { + fn default() -> Self { + MockFiles { + root: Arc::new(Mutex::new(MockFSContent::Dir(Default::default()).into())), + } + } +} + +impl MockFiles { + /// Create a new, empty filesystem. + pub fn new() -> Self { + Self::default() + } + + /// Add a mocked file with the given content. The modification time will be the unix epoch. + /// + /// Pancis if the parent directory is not already mocked. + pub fn add_file, C: Into>(&self, path: P, content: C) -> &Self { + self.add_file_result(path, Ok(content.into()), SystemTime::UNIX_EPOCH) + } + + /// Add a mocked directory. + pub fn add_dir>(&self, path: P) -> &Self { + self.path(path, true, |_| ()).unwrap(); + self + } + + /// Add a mocked file that returns the given result and has the given modification time. + /// + /// Pancis if the parent directory is not already mocked. + pub fn add_file_result>( + &self, + path: P, + result: Result, + modified: SystemTime, + ) -> &Self { + let name = path.as_ref().file_name().expect("invalid path"); + self.parent_dir(path.as_ref(), move |dir| { + if dir.contains_key(name) { + Err(ErrorKind::AlreadyExists.into()) + } else { + dir.insert( + name.to_owned(), + MockFSItem { + content: MockFSContent::File(result), + modified, + }, + ); + Ok(()) + } + }) + .and_then(|r| r) + .unwrap(); + self + } + + /// If create_dirs is true, all missing path components (_including the final component_) are + /// created as directories. In this case `Err` is only returned if a file conflicts with + /// a directory component. + pub fn path, F, R>(&self, path: P, create_dirs: bool, f: F) -> Result + where + F: FnOnce(&mut MockFSItem) -> R, + { + let mut guard = self.root.lock().unwrap(); + let mut cur_entry = &mut *guard; + for component in path.as_ref().components() { + use std::path::Component::*; + match component { + CurDir | RootDir | Prefix(_) => continue, + ParentDir => panic!("unsupported path: {}", path.as_ref().display()), + Normal(name) => { + let cur_dir = match &mut cur_entry.content { + MockFSContent::File(_) => return Err(ErrorKind::NotFound.into()), + MockFSContent::Dir(d) => d, + }; + cur_entry = if create_dirs { + cur_dir + .entry(name.to_owned()) + .or_insert_with(|| MockFSContent::Dir(Default::default()).into()) + } else { + cur_dir.get_mut(name).ok_or(ErrorKind::NotFound)? + }; + } + } + } + Ok(f(cur_entry)) + } + + /// Get the mocked parent directory of the given path and call a callback on the mocked + /// directory's entries. + pub fn parent_dir, F, R>(&self, path: P, f: F) -> Result + where + F: FnOnce(&mut MockDirEntries) -> R, + { + self.path( + path.as_ref().parent().unwrap_or(&Path::new("")), + false, + move |item| match &mut item.content { + MockFSContent::File(_) => Err(ErrorKind::NotFound.into()), + MockFSContent::Dir(d) => Ok(f(d)), + }, + ) + .and_then(|r| r) + } + + /// Return a file assertion helper for the mocked filesystem. + pub fn assert_files(&self) -> AssertFiles { + let mut files = HashMap::new(); + let root = self.root.lock().unwrap(); + + fn dir(files: &mut HashMap, path: &Path, item: &MockFSItem) { + match &item.content { + MockFSContent::File(Ok(c)) => { + files.insert(path.to_owned(), c.clone()); + } + MockFSContent::Dir(d) => { + for (component, item) in d { + dir(files, &path.join(component), item); + } + } + _ => (), + } + } + dir(&mut files, Path::new(""), &*root); + AssertFiles { files } + } +} + +/// A utility for asserting the state of the mocked filesystem. +/// +/// All files must be accounted for; when dropped, a panic will occur if some files remain which +/// weren't checked. +#[derive(Debug)] +pub struct AssertFiles { + files: HashMap, +} + +// On windows we ignore drive prefixes. This is only relevant for real paths, which are only +// present for edge case situations in tests (where AssertFiles is used). +fn remove_prefix(p: &Path) -> &Path { + let mut iter = p.components(); + if let Some(std::path::Component::Prefix(_)) = iter.next() { + iter.next(); // Prefix is followed by RootDir + iter.as_path() + } else { + p + } +} + +impl AssertFiles { + /// Assert that the given path contains the given content (as a utf8 string). + pub fn check, S: AsRef>(&mut self, path: P, content: S) -> &mut Self { + let p = remove_prefix(path.as_ref()); + let Some(mfc) = self.files.remove(p) else { + panic!("missing file: {}", p.display()); + }; + let guard = mfc.0.lock().unwrap(); + assert_eq!( + std::str::from_utf8(&*guard).unwrap(), + content.as_ref(), + "file content mismatch: {}", + p.display() + ); + self + } + + /// Assert that the given path contains the given byte content. + pub fn check_bytes, B: AsRef<[u8]>>( + &mut self, + path: P, + content: B, + ) -> &mut Self { + let p = remove_prefix(path.as_ref()); + let Some(mfc) = self.files.remove(p) else { + panic!("missing file: {}", p.display()); + }; + let guard = mfc.0.lock().unwrap(); + assert_eq!( + &*guard, + content.as_ref(), + "file content mismatch: {}", + p.display() + ); + self + } + + /// Ignore the given file (whether it exists or not). + pub fn ignore>(&mut self, path: P) -> &mut Self { + self.files.remove(remove_prefix(path.as_ref())); + self + } + + /// Assert that the given path exists without checking its content. + pub fn check_exists>(&mut self, path: P) -> &mut Self { + let p = remove_prefix(path.as_ref()); + if self.files.remove(p).is_none() { + panic!("missing file: {}", p.display()); + } + self + } + + /// Finish checking files. + /// + /// This panics if all files were not checked. + /// + /// This is also called when the value is dropped. + pub fn finish(&mut self) { + let files = std::mem::take(&mut self.files); + if !files.is_empty() { + panic!("additional files not expected: {:?}", files.keys()); + } + } +} + +impl Drop for AssertFiles { + fn drop(&mut self) { + if !std::thread::panicking() { + self.finish(); + } + } +} + +mock_key! { + pub struct MockFS => MockFiles +} + +pub struct File { + content: MockFileContent, + pos: usize, +} + +impl File { + pub fn open>(path: P) -> Result { + MockFS.get(move |files| { + files + .path(path, false, |item| match &item.content { + MockFSContent::File(result) => result + .as_ref() + .map(|b| File { + content: b.clone(), + pos: 0, + }) + .map_err(|e| e.kind().into()), + MockFSContent::Dir(_) => Err(ErrorKind::NotFound.into()), + }) + .and_then(|r| r) + }) + } + + pub fn create>(path: P) -> Result { + let path = path.as_ref(); + MockFS.get(|files| { + let name = path.file_name().expect("invalid path"); + files.parent_dir(path, move |d| { + if !d.contains_key(name) { + d.insert( + name.to_owned(), + MockFSItem { + content: MockFSContent::File(Ok(Default::default())), + modified: super::time::SystemTime::now().0, + }, + ); + } + }) + })?; + Self::open(path) + } +} + +impl Read for File { + fn read(&mut self, buf: &mut [u8]) -> Result { + let guard = self.content.0.lock().unwrap(); + if self.pos >= guard.len() { + return Ok(0); + } + let to_read = std::cmp::min(buf.len(), guard.len() - self.pos); + buf[..to_read].copy_from_slice(&guard[self.pos..self.pos + to_read]); + self.pos += to_read; + Ok(to_read) + } +} + +impl Seek for File { + fn seek(&mut self, pos: SeekFrom) -> Result { + let len = self.content.0.lock().unwrap().len(); + match pos { + SeekFrom::Start(n) => self.pos = n as usize, + SeekFrom::End(n) => { + if n < 0 { + let offset = -n as usize; + if offset > len { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "out of bounds", + )); + } + self.pos = len - offset; + } else { + self.pos = len + n as usize + } + } + SeekFrom::Current(n) => { + if n < 0 { + let offset = -n as usize; + if offset > self.pos { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "out of bounds", + )); + } + self.pos -= offset; + } else { + self.pos += n as usize; + } + } + } + Ok(self.pos as u64) + } +} + +impl Write for File { + fn write(&mut self, buf: &[u8]) -> Result { + let mut guard = self.content.0.lock().unwrap(); + let end = self.pos + buf.len(); + if end > guard.len() { + guard.resize(end, 0); + } + (&mut guard[self.pos..end]).copy_from_slice(buf); + self.pos = end; + Ok(buf.len()) + } + + fn flush(&mut self) -> Result<()> { + Ok(()) + } +} + +pub fn create_dir_all>(path: P) -> Result<()> { + MockFS.get(move |files| files.path(path, true, |_| ())) +} + +pub fn rename, Q: AsRef>(from: P, to: Q) -> Result<()> { + MockFS.get(move |files| { + let from_name = from.as_ref().file_name().expect("invalid path"); + let item = files + .parent_dir(from.as_ref(), move |d| { + d.remove(from_name).ok_or(ErrorKind::NotFound.into()) + }) + .and_then(|r| r)?; + + let to_name = to.as_ref().file_name().expect("invalid path"); + files + .parent_dir(to.as_ref(), move |d| { + // Just error if `to` exists, which doesn't quite follow `std::fs::rename` behavior. + if d.contains_key(to_name) { + Err(ErrorKind::AlreadyExists.into()) + } else { + d.insert(to_name.to_owned(), item); + Ok(()) + } + }) + .and_then(|r| r) + }) +} + +pub fn remove_file>(path: P) -> Result<()> { + MockFS.get(move |files| { + let name = path.as_ref().file_name().expect("invalid path"); + files + .parent_dir(path.as_ref(), |d| { + if let Some(MockFSItem { + content: MockFSContent::Dir(_), + .. + }) = d.get(name) + { + Err(ErrorKind::NotFound.into()) + } else { + d.remove(name).ok_or(ErrorKind::NotFound.into()).map(|_| ()) + } + }) + .and_then(|r| r) + }) +} + +pub fn write, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> { + File::create(path.as_ref())?.write_all(contents.as_ref()) +} + +pub struct ReadDir { + base: PathBuf, + children: Vec, +} + +impl ReadDir { + pub fn new(path: &Path) -> Result { + MockFS.get(move |files| { + files + .path(path, false, |item| match &item.content { + MockFSContent::Dir(d) => Ok(ReadDir { + base: path.to_owned(), + children: d.keys().cloned().collect(), + }), + MockFSContent::File(_) => Err(ErrorKind::NotFound.into()), + }) + .and_then(|r| r) + }) + } +} + +impl Iterator for ReadDir { + type Item = Result; + fn next(&mut self) -> Option { + let child = self.children.pop()?; + Some(Ok(DirEntry(self.base.join(child)))) + } +} + +pub struct DirEntry(PathBuf); + +impl DirEntry { + pub fn path(&self) -> super::path::PathBuf { + super::path::PathBuf(self.0.clone()) + } + + pub fn metadata(&self) -> Result { + MockFS.get(|files| { + files.path(&self.0, false, |item| { + let is_dir = matches!(&item.content, MockFSContent::Dir(_)); + Metadata { + is_dir, + modified: item.modified, + } + }) + }) + } +} + +pub struct Metadata { + is_dir: bool, + modified: SystemTime, +} + +impl Metadata { + pub fn is_file(&self) -> bool { + !self.is_dir + } + + pub fn is_dir(&self) -> bool { + self.is_dir + } + + pub fn modified(&self) -> Result { + Ok(super::time::SystemTime(self.modified)) + } +} diff --git a/toolkit/crashreporter/client/app/src/std/mock.rs b/toolkit/crashreporter/client/app/src/std/mock.rs new file mode 100644 index 0000000000..ed942a09bd --- /dev/null +++ b/toolkit/crashreporter/client/app/src/std/mock.rs @@ -0,0 +1,254 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Mocking utilities. +//! +//! Mock data is set on a per-thread basis. [`crate::std::thread`] handles this automatically for +//! scoped threads, and warns about creating threads otherwise (which won't be able to +//! automatically share mocked data, but it can be easily done with [`SharedMockData`] when +//! appropriate). +//! +//! Mock data is stored using type erasure, with a [`MockKey`] indexing arbitrary values. Use +//! [`mock_key!`] to define keys and the values to which they map. This approach was taken as a +//! matter of covenience for programmers, and the resulting creation and consumption APIs are +//! succinct yet extensible. +//! +//! Consumers should define keys (and expose them for mockers), and at runtime create a mock key +//! instance and call [`MockKey::get`] or [`MockKey::try_get`] to retrieve mocked values to use. +//! +//! Mockers should call [`builder`] to create a builder, [`set`](Builder::set) key/value mappings, +//! and call [`run`](Builder::run) to execute code with the mock data set. + +use std::any::{Any, TypeId}; +use std::collections::{hash_map::DefaultHasher, HashMap}; +use std::hash::{Hash, Hasher}; +use std::sync::atomic::{AtomicPtr, Ordering::Relaxed}; + +type MockDataMap = HashMap, Box>; + +thread_local! { + static MOCK_DATA: AtomicPtr = Default::default(); +} + +/// A trait intended to be used as a trait object interface for mock keys. +pub trait MockKeyStored: Any + std::fmt::Debug + Sync { + fn eq(&self, other: &dyn MockKeyStored) -> bool; + fn hash(&self, state: &mut DefaultHasher); +} + +impl PartialEq for dyn MockKeyStored { + fn eq(&self, other: &Self) -> bool { + MockKeyStored::eq(self, other) + } +} + +impl Eq for dyn MockKeyStored {} + +impl Hash for dyn MockKeyStored { + fn hash(&self, state: &mut H) { + self.type_id().hash(state); + let mut hasher = DefaultHasher::new(); + MockKeyStored::hash(self, &mut hasher); + state.write_u64(hasher.finish()); + } +} + +impl dyn MockKeyStored { + pub fn downcast_ref(&self) -> Option<&T> { + if self.type_id() == TypeId::of::() { + Some(unsafe { &*(self as *const _ as *const T) }) + } else { + None + } + } +} + +/// A type which can be used as a mock key. +pub trait MockKey: MockKeyStored + Sized { + /// The value to which the key maps. + type Value: Any + Send + Sync; + + /// Get the value set for this key, returning `None` if no data is set. + fn try_get(&self, f: F) -> Option + where + F: FnOnce(&Self::Value) -> R, + { + MOCK_DATA.with(move |ptr| { + let ptr = ptr.load(Relaxed); + if ptr.is_null() { + panic!("no mock data set"); + } + unsafe { &*ptr } + .get(self as &dyn MockKeyStored) + .and_then(move |b| b.downcast_ref()) + .map(f) + }) + } + + /// Get the value set for this key. + /// + /// Panics if no mock data is set for the key. + fn get(&self, f: F) -> R + where + F: FnOnce(&Self::Value) -> R, + { + match self.try_get(f) { + Some(v) => v, + None => panic!("mock data for {self:?} not set"), + } + } +} + +/// Mock data which can be shared amongst threads. +pub struct SharedMockData(AtomicPtr); + +impl Clone for SharedMockData { + fn clone(&self) -> Self { + SharedMockData(AtomicPtr::new(self.0.load(Relaxed))) + } +} + +impl SharedMockData { + /// Create a `SharedMockData` which stores the mock data from the current thread. + pub fn new() -> Self { + MOCK_DATA.with(|ptr| SharedMockData(AtomicPtr::new(ptr.load(Relaxed)))) + } + + /// Set the mock data on the current thread. + /// + /// # Safety + /// Callers must ensure that the mock data outlives the lifetime of the thread. + pub unsafe fn set(self) { + MOCK_DATA.with(|ptr| ptr.store(self.0.into_inner(), Relaxed)); + } +} + +/// Create a mock builder, which allows adding mock data and running functions under that mock +/// environment. +pub fn builder() -> Builder { + Builder::new() +} + +/// A mock data builder. +#[derive(Default)] +pub struct Builder { + data: MockDataMap, +} + +impl Builder { + /// Create a new, empty builder. + pub fn new() -> Self { + Default::default() + } + + /// Set a mock data key/value mapping. + pub fn set(&mut self, key: K, value: K::Value) -> &mut Self { + self.data.insert(Box::new(key), Box::new(value)); + self + } + + /// Run the given function with mock data set. + pub fn run(&mut self, f: F) -> R + where + F: FnOnce() -> R, + { + MOCK_DATA.with(|ptr| ptr.store(&mut self.data, Relaxed)); + let ret = f(); + MOCK_DATA.with(|ptr| ptr.store(std::ptr::null_mut(), Relaxed)); + ret + } +} + +/// A general-purpose [`MockKey`] keyed by an identifier string and the stored type. +/// +/// Use [`hook`] or [`try_hook`] in code accessing the values. +pub struct MockHook { + name: &'static str, + _p: std::marker::PhantomData T>, +} + +impl std::fmt::Debug for MockHook { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct(&format!("MockHook<{}>", std::any::type_name::())) + .field("name", &self.name) + .finish() + } +} + +impl MockKeyStored for MockHook { + fn eq(&self, other: &dyn MockKeyStored) -> bool { + std::any::TypeId::of::() == other.type_id() + && self.name == other.downcast_ref::().unwrap().name + } + fn hash(&self, state: &mut DefaultHasher) { + self.name.hash(state) + } +} + +impl MockKey for MockHook { + type Value = T; +} + +impl MockHook { + /// Create a new mock hook key with the given name. + pub fn new(name: &'static str) -> Self { + MockHook { + name, + _p: Default::default(), + } + } +} + +/// Create a mock hook with the given name. When mocking isn't enabled, the given value will be +/// used instead. Panics if the hook isn't set. +pub fn hook(_normally: T, name: &'static str) -> T { + MockHook::new(name).get(|v: &T| v.clone()) +} + +/// Create a mock hook with the given name. When mocking isn't enabled or the hook hasn't been set, +/// the given value will be used instead. +pub fn try_hook(fallback: T, name: &'static str) -> T { + MockHook::new(name) + .try_get(|v: &T| v.clone()) + .unwrap_or(fallback) +} + +/// Create a mock key with an associated value type. +/// +/// Supports the following syntaxes: +/// * Unit struct: ` struct NAME => VALUE_TYPE` +/// * Tuple struct: ` struct NAME(ITEMS) => VALUE_TYPE` +/// * Normal struct: ` struct NAME { FIELDS } => VALUE_TYPE` +macro_rules! mock_key { + ( $vis:vis struct $name:ident => $value:ty ) => { + $crate::std::mock::mock_key! { @structdef[$vis struct $name;] $name $value } + }; + ( $vis:vis struct $name:ident ($($tuple:tt)*) => $value:ty ) => { + $crate::std::mock::mock_key! { @structdef[$vis struct $name($($tuple)*);] $name $value } + }; + ( $vis:vis struct $name:ident {$($full:tt)*} => $value:ty ) => { + $crate::std::mock::mock_key! { @structdef[$vis struct $name{$($full)*}] $name $value } + }; + ( @structdef [$($def:tt)+] $name:ident $value:ty ) => { + #[derive(Debug, PartialEq, Eq, Hash)] + $($def)+ + + impl crate::std::mock::MockKeyStored for $name { + fn eq(&self, other: &dyn crate::std::mock::MockKeyStored) -> bool { + std::any::TypeId::of::() == other.type_id() + && PartialEq::eq(self, other.downcast_ref::().unwrap()) + } + + fn hash(&self, state: &mut std::collections::hash_map::DefaultHasher) { + std::hash::Hash::hash(self, state) + } + } + + impl crate::std::mock::MockKey for $name { + type Value = $value; + } + } +} + +pub(crate) use mock_key; diff --git a/toolkit/crashreporter/client/app/src/std/mock_stub.rs b/toolkit/crashreporter/client/app/src/std/mock_stub.rs new file mode 100644 index 0000000000..a02ac3dea1 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/std/mock_stub.rs @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +#![allow(dead_code)] + +//! Stubs used when mocking isn't enabled. + +/// Create a mock hook with the given name. When mocking isn't enabled, the given value will be +/// used instead. Panics if the hook isn't set. +#[inline(always)] +pub fn hook(normally: T, _name: &'static str) -> T { + normally +} + +/// Create a mock hook with the given name. When mocking isn't enabled or the hook hasn't been set, +/// the given value will be used instead. +#[inline(always)] +pub fn try_hook(fallback: T, _name: &'static str) -> T { + fallback +} diff --git a/toolkit/crashreporter/client/app/src/std/mod.rs b/toolkit/crashreporter/client/app/src/std/mod.rs new file mode 100644 index 0000000000..467d6b0c14 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/std/mod.rs @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Standard library wrapper (for mocking in tests). +//! +//! In general this should always be used rather than `std` directly, and _especially_ when using +//! `std` functions and types which interact with the runtime host environment. +//! +//! Note that, in some cases, this wrapper extends the `std` library. Notably, the [`mock`] module +//! adds mocking functions. + +#![cfg_attr(mock, allow(unused))] + +pub use std::*; + +#[cfg_attr(not(mock), path = "mock_stub.rs")] +pub mod mock; + +#[cfg(mock)] +pub mod env; +#[cfg(mock)] +pub mod fs; +#[cfg(mock)] +pub mod net; +#[cfg(mock)] +pub mod path; +#[cfg(mock)] +pub mod process; +#[cfg(mock)] +pub mod thread; +#[cfg(mock)] +pub mod time; diff --git a/toolkit/crashreporter/client/app/src/std/net.rs b/toolkit/crashreporter/client/app/src/std/net.rs new file mode 100644 index 0000000000..dd51a44756 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/std/net.rs @@ -0,0 +1,5 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Stub to avoid net use, if any. diff --git a/toolkit/crashreporter/client/app/src/std/path.rs b/toolkit/crashreporter/client/app/src/std/path.rs new file mode 100644 index 0000000000..c565615514 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/std/path.rs @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! We unfortunately have to mock `Path` because of `exists`, `try_exists`, and `metadata`. + +pub use std::path::*; + +use super::mock::MockKey; +use std::ffi::OsStr; + +macro_rules! delegate { + ( fn $name:ident (&self $(, $arg:ident : $argty:ty )* ) -> $ret:ty ) => { + pub fn $name (&self, $($arg : $ty)*) -> $ret { + self.0.$name($($arg),*) + } + } +} + +#[repr(transparent)] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Path(std::path::Path); + +impl AsRef for Path { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for Path { + fn as_ref(&self) -> &OsStr { + self.0.as_ref() + } +} + +impl AsRef for &str { + fn as_ref(&self) -> &Path { + Path::from_path(self.as_ref()) + } +} + +impl AsRef for String { + fn as_ref(&self) -> &Path { + Path::from_path(self.as_ref()) + } +} + +impl AsRef for &OsStr { + fn as_ref(&self) -> &Path { + Path::from_path(self.as_ref()) + } +} + +impl Path { + fn from_path(path: &std::path::Path) -> &Self { + // # Safety + // Transparent wrapper is safe to transmute. + unsafe { std::mem::transmute(path) } + } + + pub fn exists(&self) -> bool { + super::fs::MockFS + .try_get(|files| { + files + .path(self, false, |item| match &item.content { + super::fs::MockFSContent::File(r) => r.is_ok(), + _ => true, + }) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + pub fn read_dir(&self) -> super::io::Result { + super::fs::ReadDir::new(&self.0) + } + + delegate!(fn display(&self) -> Display); + delegate!(fn file_stem(&self) -> Option<&OsStr>); + delegate!(fn file_name(&self) -> Option<&OsStr>); + delegate!(fn extension(&self) -> Option<&OsStr>); + + pub fn join>(&self, path: P) -> PathBuf { + PathBuf(self.0.join(&path.as_ref().0)) + } + + pub fn parent(&self) -> Option<&Path> { + self.0.parent().map(Path::from_path) + } +} + +#[repr(transparent)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PathBuf(pub(super) std::path::PathBuf); + +impl PathBuf { + pub fn set_extension>(&mut self, extension: S) -> bool { + self.0.set_extension(extension) + } + + pub fn push>(&mut self, path: P) { + self.0.push(path.as_ref()) + } + + pub fn pop(&mut self) -> bool { + self.0.pop() + } +} + +impl std::ops::Deref for PathBuf { + type Target = Path; + fn deref(&self) -> &Self::Target { + Path::from_path(self.0.as_ref()) + } +} + +impl AsRef for PathBuf { + fn as_ref(&self) -> &Path { + Path::from_path(self.0.as_ref()) + } +} + +impl AsRef for PathBuf { + fn as_ref(&self) -> &std::path::Path { + self.0.as_ref() + } +} + +impl AsRef for PathBuf { + fn as_ref(&self) -> &OsStr { + self.0.as_ref() + } +} + +impl From for PathBuf { + fn from(os_str: std::ffi::OsString) -> Self { + PathBuf(os_str.into()) + } +} + +impl From for PathBuf { + fn from(pathbuf: std::path::PathBuf) -> Self { + PathBuf(pathbuf) + } +} + +impl From for std::ffi::OsString { + fn from(pathbuf: PathBuf) -> Self { + pathbuf.0.into() + } +} + +impl From<&str> for PathBuf { + fn from(s: &str) -> Self { + PathBuf(s.into()) + } +} diff --git a/toolkit/crashreporter/client/app/src/std/process.rs b/toolkit/crashreporter/client/app/src/std/process.rs new file mode 100644 index 0000000000..49dd028447 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/std/process.rs @@ -0,0 +1,201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +pub use std::process::*; + +use crate::std::mock::{mock_key, MockKey}; + +use std::ffi::{OsStr, OsString}; +use std::io::Result; +use std::sync::{Arc, Mutex}; + +mock_key! { + // Uses PathBuf rather than OsString to avoid path separator differences. + pub struct MockCommand(::std::path::PathBuf) => Box Result + Send + Sync> +} + +#[derive(Debug)] +pub struct Command { + pub program: OsString, + pub args: Vec, + pub env: std::collections::HashMap, + pub stdin: Vec, + // XXX The spawn stuff is hacky, but for now there's only one case where we really need to + // interact with `spawn` so we live with it for testing. + pub spawning: bool, + pub spawned_child: Mutex>, +} + +impl Command { + pub fn mock>(program: S) -> MockCommand { + MockCommand(program.as_ref().into()) + } + + pub fn new>(program: S) -> Self { + Command { + program: program.as_ref().into(), + args: vec![], + env: Default::default(), + stdin: Default::default(), + spawning: false, + spawned_child: Mutex::new(None), + } + } + + pub fn arg>(&mut self, arg: S) -> &mut Self { + self.args.push(arg.as_ref().into()); + self + } + + pub fn args(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + S: AsRef, + { + for arg in args.into_iter() { + self.arg(arg); + } + self + } + + pub fn env(&mut self, key: K, val: V) -> &mut Self + where + K: AsRef, + V: AsRef, + { + self.env.insert(key.as_ref().into(), val.as_ref().into()); + self + } + + pub fn stdin>(&mut self, _cfg: T) -> &mut Self { + self + } + + pub fn stdout>(&mut self, _cfg: T) -> &mut Self { + self + } + + pub fn stderr>(&mut self, _cfg: T) -> &mut Self { + self + } + + pub fn output(&mut self) -> std::io::Result { + MockCommand(self.program.as_os_str().into()).get(|f| f(self)) + } + + pub fn spawn(&mut self) -> std::io::Result { + self.spawning = true; + self.output()?; + self.spawning = false; + let stdin = Arc::new(Mutex::new(vec![])); + Ok(Child { + stdin: Some(ChildStdin { + data: stdin.clone(), + }), + cmd: self.clone_for_child(), + stdin_data: Some(stdin), + }) + } + + #[cfg(windows)] + pub fn creation_flags(&mut self, _flags: u32) -> &mut Self { + self + } + + pub fn output_from_real_command(&self) -> std::io::Result { + let mut spawned_child = self.spawned_child.lock().unwrap(); + if spawned_child.is_none() { + *spawned_child = Some( + ::std::process::Command::new(self.program.clone()) + .args(self.args.clone()) + .envs(self.env.clone()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?, + ); + } + + if self.spawning { + return Ok(success_output()); + } + + let mut child = spawned_child.take().unwrap(); + { + let mut input = child.stdin.take().unwrap(); + std::io::copy(&mut std::io::Cursor::new(&self.stdin), &mut input)?; + } + child.wait_with_output() + } + + fn clone_for_child(&self) -> Self { + Command { + program: self.program.clone(), + args: self.args.clone(), + env: self.env.clone(), + stdin: self.stdin.clone(), + spawning: false, + spawned_child: Mutex::new(self.spawned_child.lock().unwrap().take()), + } + } +} + +pub struct Child { + pub stdin: Option, + cmd: Command, + stdin_data: Option>>>, +} + +impl Child { + pub fn wait_with_output(mut self) -> std::io::Result { + self.ref_wait_with_output().unwrap() + } + + fn ref_wait_with_output(&mut self) -> Option> { + drop(self.stdin.take()); + if let Some(stdin) = self.stdin_data.take() { + self.cmd.stdin = Arc::try_unwrap(stdin) + .expect("stdin not dropped, wait_with_output may block") + .into_inner() + .unwrap(); + Some(MockCommand(self.cmd.program.as_os_str().into()).get(|f| f(&self.cmd))) + } else { + None + } + } +} + +pub struct ChildStdin { + data: Arc>>, +} + +impl std::io::Write for ChildStdin { + fn write(&mut self, buf: &[u8]) -> Result { + self.data.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> Result<()> { + Ok(()) + } +} + +#[cfg(unix)] +pub fn success_exit_status() -> ExitStatus { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(0) +} + +#[cfg(windows)] +pub fn success_exit_status() -> ExitStatus { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(0) +} + +pub fn success_output() -> Output { + Output { + status: success_exit_status(), + stdout: vec![], + stderr: vec![], + } +} diff --git a/toolkit/crashreporter/client/app/src/std/thread.rs b/toolkit/crashreporter/client/app/src/std/thread.rs new file mode 100644 index 0000000000..d2dc74702a --- /dev/null +++ b/toolkit/crashreporter/client/app/src/std/thread.rs @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +pub use std::thread::*; + +// Mock `spawn` just to issue a warning that mocking within the thread won't work without manual +// intervention. +pub fn spawn(f: F) -> JoinHandle +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, +{ + eprintln!("warning: mocking won't work in `std::thread::spawn`ed threads by default. Use `std::mock::SharedMockData` if mocking is needed and it's safe to do so."); + std::thread::spawn(f) +} + +pub struct Scope<'scope, 'env: 'scope> { + mock_data: super::mock::SharedMockData, + scope: &'scope std::thread::Scope<'scope, 'env>, +} + +impl<'scope, 'env> Scope<'scope, 'env> { + pub fn spawn(&self, f: F) -> ScopedJoinHandle<'scope, T> + where + F: FnOnce() -> T + Send + 'scope, + T: Send + 'scope, + { + let mock_data = self.mock_data.clone(); + self.scope.spawn(move || { + // # Safety + // `thread::scope` guarantees that the mock data will outlive the thread. + unsafe { mock_data.set() }; + f() + }) + } +} + +pub fn scope<'env, F, T>(f: F) -> T +where + F: for<'scope> FnOnce(Scope<'scope, 'env>) -> T, +{ + let mock_data = super::mock::SharedMockData::new(); + std::thread::scope(|scope| f(Scope { mock_data, scope })) +} diff --git a/toolkit/crashreporter/client/app/src/std/time.rs b/toolkit/crashreporter/client/app/src/std/time.rs new file mode 100644 index 0000000000..5c351a7bcf --- /dev/null +++ b/toolkit/crashreporter/client/app/src/std/time.rs @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use super::mock::{mock_key, MockKey}; +pub use std::time::{Duration, SystemTimeError}; + +mock_key! { + pub struct MockCurrentTime => std::time::SystemTime +} + +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq)] +pub struct SystemTime(pub(super) std::time::SystemTime); + +impl From for ::time::OffsetDateTime { + fn from(t: SystemTime) -> Self { + t.0.into() + } +} + +impl SystemTime { + pub const UNIX_EPOCH: SystemTime = SystemTime(std::time::SystemTime::UNIX_EPOCH); + + pub fn now() -> Self { + MockCurrentTime.get(|t| SystemTime(*t)) + } + + pub fn duration_since(&self, earlier: Self) -> Result { + self.0.duration_since(earlier.0) + } +} diff --git a/toolkit/crashreporter/client/app/src/test.rs b/toolkit/crashreporter/client/app/src/test.rs new file mode 100644 index 0000000000..42d2334bca --- /dev/null +++ b/toolkit/crashreporter/client/app/src/test.rs @@ -0,0 +1,1289 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Tests here mostly interact with the [test UI](crate::ui::test). As such, most tests read a bit +//! more like integration tests than unit tests, testing the behavior of the application as a +//! whole. + +use super::*; +use crate::config::{test::MINIDUMP_PRUNE_SAVE_COUNT, Config}; +use crate::settings::Settings; +use crate::std::{ + ffi::OsString, + fs::{MockFS, MockFiles}, + io::ErrorKind, + mock, + process::Command, + sync::{ + atomic::{AtomicUsize, Ordering::Relaxed}, + Arc, + }, +}; +use crate::ui::{self, test::model, ui_impl::Interact}; + +/// A simple thread-safe counter which can be used in tests to mark that certain code paths were +/// hit. +#[derive(Clone, Default)] +struct Counter(Arc); + +impl Counter { + /// Create a new zero counter. + pub fn new() -> Self { + Self::default() + } + + /// Increment the counter. + pub fn inc(&self) { + self.0.fetch_add(1, Relaxed); + } + + /// Get the current count. + pub fn count(&self) -> usize { + self.0.load(Relaxed) + } + + /// Assert that the current count is 1. + pub fn assert_one(&self) { + assert_eq!(self.count(), 1); + } +} + +/// Fluent wraps arguments with the unicode BiDi characters. +struct FluentArg(T); + +impl std::fmt::Display for FluentArg { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + use crate::std::fmt::Write; + f.write_char('\u{2068}')?; + self.0.fmt(f)?; + f.write_char('\u{2069}') + } +} + +/// Run a gui and interaction on separate threads. +fn gui_interact(gui: G, interact: I) -> R +where + G: FnOnce() -> R, + I: FnOnce(Interact) + Send + 'static, +{ + let i = Interact::hook(); + let handle = { + let i = i.clone(); + ::std::thread::spawn(move || { + i.wait_for_ready(); + interact(i); + }) + }; + let ret = gui(); + // In case the gui failed before launching. + i.cancel(); + handle.join().unwrap(); + ret +} + +const MOCK_MINIDUMP_EXTRA: &str = r#"{ + "Vendor": "FooCorp", + "ProductName": "Bar", + "ReleaseChannel": "release", + "BuildID": "1234", + "StackTraces": { + "status": "OK" + }, + "Version": "100.0", + "ServerURL": "https://reports.example.com", + "TelemetryServerURL": "https://telemetry.example.com", + "TelemetryClientId": "telemetry_client", + "TelemetrySessionId": "telemetry_session", + "SomeNestedJson": { "foo": "bar" }, + "URL": "https://url.example.com" + }"#; + +// Actual content doesn't matter, aside from the hash that is generated. +const MOCK_MINIDUMP_FILE: &[u8] = &[1, 2, 3, 4]; +const MOCK_MINIDUMP_SHA256: &str = + "9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a"; +macro_rules! current_date { + () => { + "2004-11-09" + }; +} +const MOCK_CURRENT_DATE: &str = current_date!(); +const MOCK_CURRENT_TIME: &str = concat!(current_date!(), "T12:34:56Z"); +const MOCK_PING_UUID: uuid::Uuid = uuid::Uuid::nil(); +const MOCK_REMOTE_CRASH_ID: &str = "8cbb847c-def2-4f68-be9e-000000000000"; + +fn current_datetime() -> time::OffsetDateTime { + time::OffsetDateTime::parse( + MOCK_CURRENT_TIME, + &time::format_description::well_known::Rfc3339, + ) + .unwrap() +} + +fn current_unix_time() -> i64 { + current_datetime().unix_timestamp() +} + +fn current_system_time() -> ::std::time::SystemTime { + current_datetime().into() +} + +/// A basic configuration which populates some necessary/useful fields. +fn test_config() -> Config { + let mut cfg = Config::default(); + cfg.data_dir = Some("data_dir".into()); + cfg.events_dir = Some("events_dir".into()); + cfg.ping_dir = Some("ping_dir".into()); + cfg.dump_file = Some("minidump.dmp".into()); + cfg.strings = lang::LanguageInfo::default().load_strings().ok(); + cfg +} + +/// A test fixture to make configuration, mocking, and assertions easier. +struct GuiTest { + /// The configuration used in the test. Initialized to [`test_config`]. + pub config: Config, + /// The mock builder used in the test, initialized with a basic set of mocked values to ensure + /// most things will work out of the box. + pub mock: mock::Builder, + /// The mocked filesystem, which can be used for mock setup and assertions after completion. + pub files: MockFiles, +} + +impl GuiTest { + /// Create a new GuiTest with enough configured for the application to run + pub fn new() -> Self { + // Create a default set of files which allow successful operation. + let mock_files = MockFiles::new(); + mock_files + .add_file_result( + "minidump.dmp", + Ok(MOCK_MINIDUMP_FILE.into()), + current_system_time(), + ) + .add_file_result( + "minidump.extra", + Ok(MOCK_MINIDUMP_EXTRA.into()), + current_system_time(), + ); + + // Create a default mock environment which allows successful operation. + let mut mock = mock::builder(); + mock.set( + Command::mock("work_dir/minidump-analyzer"), + Box::new(|_| Ok(crate::std::process::success_output())), + ) + .set( + Command::mock("work_dir/pingsender"), + Box::new(|_| Ok(crate::std::process::success_output())), + ) + .set( + Command::mock("curl"), + Box::new(|_| { + let mut output = crate::std::process::success_output(); + output.stdout = format!("CrashID={MOCK_REMOTE_CRASH_ID}").into(); + Ok(output) + }), + ) + .set(MockFS, mock_files.clone()) + .set( + crate::std::env::MockCurrentExe, + "work_dir/crashreporter".into(), + ) + .set(crate::std::time::MockCurrentTime, current_system_time()) + .set(mock::MockHook::new("ping_uuid"), MOCK_PING_UUID); + + GuiTest { + config: test_config(), + mock, + files: mock_files, + } + } + + /// Run the test as configured, using the given function to interact with the GUI. + /// + /// Returns the final result of the application logic. + pub fn try_run( + &mut self, + interact: F, + ) -> anyhow::Result { + let GuiTest { + ref mut config, + ref mut mock, + .. + } = self; + let mut config = Arc::new(std::mem::take(config)); + + // Run the mock environment. + mock.run(move || gui_interact(move || try_run(&mut config), interact)) + } + + /// Run the test as configured, using the given function to interact with the GUI. + /// + /// Panics if the application logic returns an error (which would normally be displayed to the + /// user). + pub fn run(&mut self, interact: F) { + if let Err(e) = self.try_run(interact) { + panic!( + "gui failure:{}", + e.chain().map(|e| format!("\n {e}")).collect::() + ); + } + } + + /// Get the file assertion helper. + pub fn assert_files(&self) -> AssertFiles { + AssertFiles { + data_dir: "data_dir".into(), + events_dir: "events_dir".into(), + inner: self.files.assert_files(), + } + } +} + +/// A wrapper around the mock [`AssertFiles`](crate::std::fs::AssertFiles). +/// +/// This implements higher-level assertions common across tests, but also supports the lower-level +/// assertions (though those return the [`AssertFiles`](crate::std::fs::AssertFiles) reference so +/// higher-level assertions must be chained first). +struct AssertFiles { + data_dir: String, + events_dir: String, + inner: std::fs::AssertFiles, +} + +impl AssertFiles { + fn data(&self, rest: &str) -> String { + format!("{}/{rest}", &self.data_dir) + } + + fn events(&self, rest: &str) -> String { + format!("{}/{rest}", &self.events_dir) + } + + /// Set the data dir if not the default. + pub fn set_data_dir(&mut self, data_dir: S) -> &mut Self { + let data_dir = data_dir.to_string(); + // Data dir should be relative to root. + self.data_dir = data_dir.trim_start_matches('/').to_string(); + self + } + + /// Ignore the generated log file. + pub fn ignore_log(&mut self) -> &mut Self { + self.inner.ignore(self.data("submit.log")); + self + } + + /// Assert that the crash report was submitted according to the filesystem. + pub fn submitted(&mut self) -> &mut Self { + self.inner.check( + self.data(&format!("submitted/{MOCK_REMOTE_CRASH_ID}.txt")), + format!("Crash ID: {}\n", FluentArg(MOCK_REMOTE_CRASH_ID)), + ); + self + } + + /// Assert that the given settings where saved. + pub fn saved_settings(&mut self, settings: Settings) -> &mut Self { + self.inner.check( + self.data("crashreporter_settings.json"), + settings.to_string(), + ); + self + } + + /// Assert that a crash is pending according to the filesystem. + pub fn pending(&mut self) -> &mut Self { + let dmp = self.data("pending/minidump.dmp"); + self.inner + .check(self.data("pending/minidump.extra"), MOCK_MINIDUMP_EXTRA) + .check_bytes(dmp, MOCK_MINIDUMP_FILE); + self + } + + /// Assert that a crash ping was sent according to the filesystem. + pub fn ping(&mut self) -> &mut Self { + self.inner.check( + format!("ping_dir/{MOCK_PING_UUID}.json"), + serde_json::json! {{ + "type": "crash", + "id": MOCK_PING_UUID, + "version": 4, + "creation_date": MOCK_CURRENT_TIME, + "client_id": "telemetry_client", + "payload": { + "sessionId": "telemetry_session", + "version": 1, + "crashDate": MOCK_CURRENT_DATE, + "crashTime": MOCK_CURRENT_TIME, + "hasCrashEnvironment": true, + "crashId": "minidump", + "minidumpSha256Hash": MOCK_MINIDUMP_SHA256, + "processType": "main", + "stackTraces": { + "status": "OK" + }, + "metadata": { + "BuildID": "1234", + "ProductName": "Bar", + "ReleaseChannel": "release", + } + }, + "application": { + "vendor": "FooCorp", + "name": "Bar", + "buildId": "1234", + "displayVersion": "", + "platformVersion": "", + "version": "100.0", + "channel": "release" + } + }} + .to_string(), + ); + self + } + + /// Assert that a crash submission event was written with the given submission status. + pub fn submission_event(&mut self, success: bool) -> &mut Self { + self.inner.check( + self.events("minidump-submission"), + format!( + "crash.submission.1\n\ + {}\n\ + minidump\n\ + {success}\n\ + {}", + current_unix_time(), + if success { MOCK_REMOTE_CRASH_ID } else { "" } + ), + ); + self + } +} + +impl std::ops::Deref for AssertFiles { + type Target = std::fs::AssertFiles; + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl std::ops::DerefMut for AssertFiles { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +#[test] +fn error_dialog() { + gui_interact( + || { + let cfg = Config::default(); + ui::error_dialog(&cfg, "an error occurred") + }, + |interact| { + interact.element("close", |_style, b: &model::Button| b.click.fire(&())); + }, + ); +} + +#[test] +fn no_dump_file() { + let mut cfg = Arc::new(Config::default()); + { + let cfg = Arc::get_mut(&mut cfg).unwrap(); + cfg.strings = lang::LanguageInfo::default().load_strings().ok(); + } + assert!(try_run(&mut cfg).is_err()); + Arc::get_mut(&mut cfg).unwrap().auto_submit = true; + assert!(try_run(&mut cfg).is_ok()); +} + +#[test] +fn minidump_analyzer_error() { + mock::builder() + .set( + Command::mock("work_dir/minidump-analyzer"), + Box::new(|_| Err(ErrorKind::NotFound.into())), + ) + .set( + crate::std::env::MockCurrentExe, + "work_dir/crashreporter".into(), + ) + .run(|| { + let cfg = test_config(); + assert!(try_run(&mut Arc::new(cfg)).is_err()); + }); +} + +#[test] +fn no_extra_file() { + mock::builder() + .set( + Command::mock("work_dir/minidump-analyzer"), + Box::new(|_| Ok(crate::std::process::success_output())), + ) + .set( + crate::std::env::MockCurrentExe, + "work_dir/crashreporter".into(), + ) + .set(MockFS, { + let files = MockFiles::new(); + files.add_file_result( + "minidump.extra", + Err(ErrorKind::NotFound.into()), + ::std::time::SystemTime::UNIX_EPOCH, + ); + files + }) + .run(|| { + let cfg = test_config(); + assert!(try_run(&mut Arc::new(cfg)).is_err()); + }); +} + +#[test] +fn auto_submit() { + let mut test = GuiTest::new(); + test.config.auto_submit = true; + // auto_submit should not do any GUI things, including creating the crashreporter_settings.json + // file. + test.mock.run(|| { + assert!(try_run(&mut Arc::new(std::mem::take(&mut test.config))).is_ok()); + }); + test.assert_files().ignore_log().submitted().pending(); +} + +#[test] +fn restart() { + let mut test = GuiTest::new(); + test.config.restart_command = Some("my_process".into()); + test.config.restart_args = vec!["a".into(), "b".into()]; + let ran_process = Counter::new(); + let mock_ran_process = ran_process.clone(); + test.mock.set( + Command::mock("my_process"), + Box::new(move |cmd| { + assert_eq!(cmd.args, &["a", "b"]); + mock_ran_process.inc(); + Ok(crate::std::process::success_output()) + }), + ); + test.run(|interact| { + interact.element("restart", |_style, b: &model::Button| b.click.fire(&())); + }); + test.assert_files() + .ignore_log() + .saved_settings(Settings::default()) + .submitted() + .pending(); + ran_process.assert_one(); +} + +#[test] +fn no_restart_with_windows_error_reporting() { + let mut test = GuiTest::new(); + test.config.restart_command = Some("my_process".into()); + test.config.restart_args = vec!["a".into(), "b".into()]; + // Add the "WindowsErrorReporting" key to the extra file + const MINIDUMP_EXTRA_CONTENTS: &str = r#"{ + "Vendor": "FooCorp", + "ProductName": "Bar", + "ReleaseChannel": "release", + "BuildID": "1234", + "StackTraces": { + "status": "OK" + }, + "Version": "100.0", + "ServerURL": "https://reports.example.com", + "TelemetryServerURL": "https://telemetry.example.com", + "TelemetryClientId": "telemetry_client", + "TelemetrySessionId": "telemetry_session", + "SomeNestedJson": { "foo": "bar" }, + "URL": "https://url.example.com", + "WindowsErrorReporting": 1 + }"#; + test.files = { + let mock_files = MockFiles::new(); + mock_files + .add_file_result( + "minidump.dmp", + Ok(MOCK_MINIDUMP_FILE.into()), + current_system_time(), + ) + .add_file_result( + "minidump.extra", + Ok(MINIDUMP_EXTRA_CONTENTS.into()), + current_system_time(), + ); + test.mock.set(MockFS, mock_files.clone()); + mock_files + }; + let ran_process = Counter::new(); + let mock_ran_process = ran_process.clone(); + test.mock.set( + Command::mock("my_process"), + Box::new(move |cmd| { + assert_eq!(cmd.args, &["a", "b"]); + mock_ran_process.inc(); + Ok(crate::std::process::success_output()) + }), + ); + test.run(|interact| { + interact.element("restart", |style, b: &model::Button| { + // Check that the button is hidden, and invoke the click anyway to ensure the process + // isn't restarted (the window will still be closed). + assert_eq!(style.visible.get(), false); + b.click.fire(&()) + }); + }); + let mut assert_files = test.assert_files(); + assert_files + .ignore_log() + .saved_settings(Settings::default()) + .submitted(); + { + let dmp = assert_files.data("pending/minidump.dmp"); + let extra = assert_files.data("pending/minidump.extra"); + assert_files + .check(extra, MINIDUMP_EXTRA_CONTENTS) + .check_bytes(dmp, MOCK_MINIDUMP_FILE); + } + + assert_eq!(ran_process.count(), 0); +} + +#[test] +fn quit() { + let mut test = GuiTest::new(); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + test.assert_files() + .ignore_log() + .saved_settings(Settings::default()) + .submitted() + .pending(); +} + +#[test] +fn delete_dump() { + let mut test = GuiTest::new(); + test.config.delete_dump = true; + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + test.assert_files() + .ignore_log() + .saved_settings(Settings::default()) + .submitted(); +} + +#[test] +fn no_submit() { + let mut test = GuiTest::new(); + test.files.add_dir("data_dir").add_file( + "data_dir/crashreporter_settings.json", + Settings { + submit_report: true, + include_url: true, + } + .to_string(), + ); + test.run(|interact| { + interact.element("send", |_style, c: &model::Checkbox| { + assert!(c.checked.get()) + }); + interact.element("include-url", |_style, c: &model::Checkbox| { + assert!(c.checked.get()) + }); + interact.element("send", |_style, c: &model::Checkbox| c.checked.set(false)); + interact.element("include-url", |_style, c: &model::Checkbox| { + c.checked.set(false) + }); + + // When submission is unchecked, the following elements should be disabled. + interact.element("details", |style, _: &model::Button| { + assert!(!style.enabled.get()); + }); + interact.element("comment", |style, _: &model::TextBox| { + assert!(!style.enabled.get()); + }); + interact.element("include-url", |style, _: &model::Checkbox| { + assert!(!style.enabled.get()); + }); + + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + test.assert_files() + .ignore_log() + .saved_settings(Settings { + submit_report: false, + include_url: false, + }) + .pending(); +} + +#[test] +fn ping_and_event_files() { + let mut test = GuiTest::new(); + test.files + .add_dir("ping_dir") + .add_dir("events_dir") + .add_file( + "events_dir/minidump", + "1\n\ + 12:34:56\n\ + e0423878-8d59-4452-b82e-cad9c846836e\n\ + {\"foo\":\"bar\"}", + ); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + test.assert_files() + .ignore_log() + .saved_settings(Settings::default()) + .submitted() + .pending() + .submission_event(true) + .ping() + .check( + "events_dir/minidump", + format!( + "1\n\ + 12:34:56\n\ + e0423878-8d59-4452-b82e-cad9c846836e\n\ + {}", + serde_json::json! {{ + "foo": "bar", + "MinidumpSha256Hash": MOCK_MINIDUMP_SHA256, + "CrashPingUUID": MOCK_PING_UUID, + "StackTraces": { "status": "OK" } + }} + ), + ); +} + +#[test] +fn pingsender_failure() { + let mut test = GuiTest::new(); + test.mock.set( + Command::mock("work_dir/pingsender"), + Box::new(|_| Err(ErrorKind::NotFound.into())), + ); + test.files + .add_dir("ping_dir") + .add_dir("events_dir") + .add_file( + "events_dir/minidump", + "1\n\ + 12:34:56\n\ + e0423878-8d59-4452-b82e-cad9c846836e\n\ + {\"foo\":\"bar\"}", + ); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + test.assert_files() + .ignore_log() + .saved_settings(Settings::default()) + .submitted() + .pending() + .submission_event(true) + .ping() + .check( + "events_dir/minidump", + format!( + "1\n\ + 12:34:56\n\ + e0423878-8d59-4452-b82e-cad9c846836e\n\ + {}", + serde_json::json! {{ + "foo": "bar", + "MinidumpSha256Hash": MOCK_MINIDUMP_SHA256, + // No crash ping UUID since pingsender fails + "StackTraces": { "status": "OK" } + }} + ), + ); +} + +#[test] +fn eol_version() { + let mut test = GuiTest::new(); + test.files + .add_dir("data_dir") + .add_file("data_dir/EndOfLife100.0", ""); + // Should fail before opening the gui + let result = test.try_run(|_| ()); + assert_eq!( + result.expect_err("should fail on EOL version").to_string(), + "Version end of life: crash reports are no longer accepted." + ); + test.assert_files() + .ignore_log() + .pending() + .ignore("data_dir/EndOfLife100.0"); +} + +#[test] +fn details_window() { + let mut test = GuiTest::new(); + test.run(|interact| { + let details_visible = || { + interact.window("crash-details-window", |style, _w: &model::Window| { + style.visible.get() + }) + }; + assert_eq!(details_visible(), false); + interact.element("details", |_style, b: &model::Button| b.click.fire(&())); + assert_eq!(details_visible(), true); + let details_text = loop { + let v = interact.element("details-text", |_style, t: &model::TextBox| t.content.get()); + if v == "Loading…" { + // Wait for the details to be populated. + std::thread::sleep(std::time::Duration::from_millis(50)); + continue; + } else { + break v; + } + }; + interact.element("close-details", |_style, b: &model::Button| b.click.fire(&())); + assert_eq!(details_visible(), false); + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + assert_eq!(details_text, + "BuildID: 1234\n\ + ProductName: Bar\n\ + ReleaseChannel: release\n\ + SomeNestedJson: {\"foo\":\"bar\"}\n\ + SubmittedFrom: Client\n\ + TelemetryClientId: telemetry_client\n\ + TelemetryServerURL: https://telemetry.example.com\n\ + TelemetrySessionId: telemetry_session\n\ + Throttleable: 1\n\ + Vendor: FooCorp\n\ + Version: 100.0\n\ + This report also contains technical information about the state of the application when it crashed.\n" + ); + }); +} + +#[test] +fn data_dir_default() { + let mut test = GuiTest::new(); + test.config.data_dir = None; + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + test.assert_files() + .set_data_dir("data_dir/FooCorp/Bar/Crash Reports") + .ignore_log() + .saved_settings(Settings::default()) + .submitted() + .pending(); +} + +#[test] +fn include_url() { + for setting in [false, true] { + let mut test = GuiTest::new(); + test.files.add_dir("data_dir").add_file( + "data_dir/crashreporter_settings.json", + Settings { + submit_report: true, + include_url: setting, + } + .to_string(), + ); + test.mock + .set( + Command::mock("curl"), + Box::new(|_| Err(std::io::ErrorKind::NotFound.into())), + ) + .set( + net::report::MockLibCurl, + Box::new(move |report| { + assert_eq!( + report.extra.get("URL").and_then(|v| v.as_str()), + setting.then_some("https://url.example.com") + ); + Ok(Ok(format!("CrashID={MOCK_REMOTE_CRASH_ID}"))) + }), + ); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + } +} + +#[test] +fn comment() { + const COMMENT: &str = "My program crashed"; + + for set_comment in [false, true] { + let invoked = Counter::new(); + let mock_invoked = invoked.clone(); + let mut test = GuiTest::new(); + test.mock + .set( + Command::mock("curl"), + Box::new(|_| Err(std::io::ErrorKind::NotFound.into())), + ) + .set( + net::report::MockLibCurl, + Box::new(move |report| { + mock_invoked.inc(); + assert_eq!( + report.extra.get("Comments").and_then(|v| v.as_str()), + set_comment.then_some(COMMENT) + ); + Ok(Ok(format!("CrashID={MOCK_REMOTE_CRASH_ID}"))) + }), + ); + test.run(move |interact| { + if set_comment { + interact.element("comment", |_style, c: &model::TextBox| { + c.content.set(COMMENT.into()) + }); + } + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + + invoked.assert_one(); + } +} + +#[test] +fn curl_binary() { + let mut test = GuiTest::new(); + test.files.add_file("minidump.memory.json.gz", ""); + let ran_process = Counter::new(); + let mock_ran_process = ran_process.clone(); + test.mock.set( + Command::mock("curl"), + Box::new(move |cmd| { + if cmd.spawning { + return Ok(crate::std::process::success_output()); + } + + // Curl strings need backslashes escaped. + let curl_escaped_separator = if std::path::MAIN_SEPARATOR == '\\' { + "\\\\" + } else { + std::path::MAIN_SEPARATOR_STR + }; + + let expected_args: Vec = [ + "--user-agent", + net::report::USER_AGENT, + "--form", + "extra=@-;filename=extra.json;type=application/json", + "--form", + &format!( + "upload_file_minidump=@\"data_dir{0}pending{0}minidump.dmp\"", + curl_escaped_separator + ), + "--form", + &format!( + "memory_report=@\"data_dir{0}pending{0}minidump.memory.json.gz\"", + curl_escaped_separator + ), + "https://reports.example.com", + ] + .into_iter() + .map(Into::into) + .collect(); + assert_eq!(cmd.args, expected_args); + let mut output = crate::std::process::success_output(); + output.stdout = format!("CrashID={MOCK_REMOTE_CRASH_ID}").into(); + mock_ran_process.inc(); + Ok(output) + }), + ); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + + ran_process.assert_one(); +} + +#[test] +fn curl_library() { + let invoked = Counter::new(); + let mock_invoked = invoked.clone(); + let mut test = GuiTest::new(); + test.mock + .set( + Command::mock("curl"), + Box::new(|_| Err(std::io::ErrorKind::NotFound.into())), + ) + .set( + net::report::MockLibCurl, + Box::new(move |_| { + mock_invoked.inc(); + Ok(Ok(format!("CrashID={MOCK_REMOTE_CRASH_ID}"))) + }), + ); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + invoked.assert_one(); +} + +#[test] +fn report_not_sent() { + let mut test = GuiTest::new(); + test.files.add_dir("events_dir"); + test.mock + .set( + Command::mock("curl"), + Box::new(|_| Err(std::io::ErrorKind::NotFound.into())), + ) + .set( + net::report::MockLibCurl, + Box::new(move |_| Err(std::io::ErrorKind::NotFound.into())), + ); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + + test.assert_files() + .ignore_log() + .saved_settings(Settings::default()) + .submission_event(false) + .pending(); +} + +#[test] +fn report_response_failed() { + let mut test = GuiTest::new(); + test.files.add_dir("events_dir"); + test.mock + .set( + Command::mock("curl"), + Box::new(|_| Err(std::io::ErrorKind::NotFound.into())), + ) + .set( + net::report::MockLibCurl, + Box::new(move |_| Ok(Err(std::io::ErrorKind::NotFound.into()))), + ); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + + test.assert_files() + .ignore_log() + .saved_settings(Settings::default()) + .submission_event(false) + .pending(); +} + +#[test] +fn response_indicates_discarded() { + let mut test = GuiTest::new(); + // A response indicating discarded triggers a prune of the directory containing the minidump. + // Since there is one more minidump (the main one, minidump.dmp), pruning should keep all but + // the first 3, which will be the oldest. + const SHOULD_BE_PRUNED: usize = 3; + + for i in 0..MINIDUMP_PRUNE_SAVE_COUNT + SHOULD_BE_PRUNED - 1 { + test.files.add_dir("data_dir/pending").add_file_result( + format!("data_dir/pending/minidump{i}.dmp"), + Ok("contents".into()), + ::std::time::SystemTime::UNIX_EPOCH + ::std::time::Duration::from_secs(1234 + i as u64), + ); + if i % 2 == 0 { + test.files + .add_file(format!("data_dir/pending/minidump{i}.extra"), "{}"); + } + if i % 5 == 0 { + test.files + .add_file(format!("data_dir/pending/minidump{i}.memory.json.gz"), "{}"); + } + } + test.mock.set( + Command::mock("curl"), + Box::new(|_| { + let mut output = crate::std::process::success_output(); + output.stdout = format!("CrashID={MOCK_REMOTE_CRASH_ID}\nDiscarded=1").into(); + Ok(output) + }), + ); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + + let mut assert_files = test.assert_files(); + assert_files + .ignore_log() + .saved_settings(Settings::default()) + .pending(); + for i in SHOULD_BE_PRUNED..MINIDUMP_PRUNE_SAVE_COUNT + SHOULD_BE_PRUNED - 1 { + assert_files.check_exists(format!("data_dir/pending/minidump{i}.dmp")); + if i % 2 == 0 { + assert_files.check_exists(format!("data_dir/pending/minidump{i}.extra")); + } + if i % 5 == 0 { + assert_files.check_exists(format!("data_dir/pending/minidump{i}.memory.json.gz")); + } + } +} + +#[test] +fn response_view_url() { + let mut test = GuiTest::new(); + test.mock.set( + Command::mock("curl"), + Box::new(|_| { + let mut output = crate::std::process::success_output(); + output.stdout = + format!("CrashID={MOCK_REMOTE_CRASH_ID}\nViewURL=https://foo.bar.example").into(); + Ok(output) + }), + ); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + + test.assert_files() + .ignore_log() + .saved_settings(Settings::default()) + .pending() + .check( + format!("data_dir/submitted/{MOCK_REMOTE_CRASH_ID}.txt"), + format!( + "\ + Crash ID: {}\n\ + You can view details of this crash at {}.\n", + FluentArg(MOCK_REMOTE_CRASH_ID), + FluentArg("https://foo.bar.example") + ), + ); +} + +#[test] +fn response_stop_sending_reports() { + let mut test = GuiTest::new(); + test.mock.set( + Command::mock("curl"), + Box::new(|_| { + let mut output = crate::std::process::success_output(); + output.stdout = + format!("CrashID={MOCK_REMOTE_CRASH_ID}\nStopSendingReportsFor=100.0").into(); + Ok(output) + }), + ); + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + + test.assert_files() + .ignore_log() + .saved_settings(Settings::default()) + .submitted() + .pending() + .check_exists("data_dir/EndOfLife100.0"); +} + +/// A real temporary directory in the host filesystem. +/// +/// The directory is guaranteed to be unique to the test suite process (in case of crash, it can be +/// inspected). +/// +/// When dropped, the directory is deleted. +struct TempDir { + path: ::std::path::PathBuf, +} + +impl TempDir { + /// Create a new directory with the given identifying name. + /// + /// The name should be unique to deconflict amongst concurrent tests. + pub fn new(name: &str) -> Self { + let path = ::std::env::temp_dir().join(format!( + "{}-test-{}-{name}", + env!("CARGO_PKG_NAME"), + std::process::id() + )); + ::std::fs::create_dir_all(&path).unwrap(); + TempDir { path } + } + + /// Get the temporary directory path. + pub fn path(&self) -> &::std::path::Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + // Best-effort removal, ignore errors. + let _ = ::std::fs::remove_dir_all(&self.path); + } +} + +/// A mock crash report server. +/// +/// When dropped, the server is shutdown. +struct TestCrashReportServer { + addr: ::std::net::SocketAddr, + shutdown_and_thread: Option<( + tokio::sync::oneshot::Sender<()>, + std::thread::JoinHandle<()>, + )>, +} + +impl TestCrashReportServer { + /// Create and start a mock crash report server on an ephemeral port, returning a handle to the + /// server. + pub fn run() -> Self { + let (shutdown, rx) = tokio::sync::oneshot::channel(); + + use warp::Filter; + + let submit = warp::path("submit") + .and(warp::filters::method::post()) + .and(warp::filters::header::header("content-type")) + .and(warp::filters::body::bytes()) + .and_then(|content_type: String, body: bytes::Bytes| async move { + let Some(boundary) = content_type.strip_prefix("multipart/form-data; boundary=") + else { + return Err(warp::reject()); + }; + + let body = String::from_utf8_lossy(&*body).to_owned(); + + for part in body.split(&format!("--{boundary}")).skip(1) { + if part == "--\r\n" { + break; + } + + let (_headers, _data) = part.split_once("\r\n\r\n").unwrap_or(("", part)); + // TODO validate parts + } + Ok(format!("CrashID={MOCK_REMOTE_CRASH_ID}")) + }); + + let (addr_channel_tx, addr_channel_rx) = std::sync::mpsc::sync_channel(0); + + let thread = ::std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to create tokio runtime"); + let _guard = rt.enter(); + + let (addr, server) = + warp::serve(submit).bind_with_graceful_shutdown(([127, 0, 0, 1], 0), async move { + rx.await.ok(); + }); + + addr_channel_tx.send(addr).unwrap(); + + rt.block_on(server) + }); + + let addr = addr_channel_rx.recv().unwrap(); + + TestCrashReportServer { + addr, + shutdown_and_thread: Some((shutdown, thread)), + } + } + + /// Get the url to which to submit crash reports for this mocked server. + pub fn submit_url(&self) -> String { + format!("http://{}/submit", self.addr) + } +} + +impl Drop for TestCrashReportServer { + fn drop(&mut self) { + let (shutdown, thread) = self.shutdown_and_thread.take().unwrap(); + let _ = shutdown.send(()); + thread.join().unwrap(); + } +} + +#[test] +fn real_curl_binary() { + if ::std::process::Command::new("curl").output().is_err() { + eprintln!("no curl binary; skipping real_curl_binary test"); + return; + } + + let server = TestCrashReportServer::run(); + + let mut test = GuiTest::new(); + test.mock.set( + Command::mock("curl"), + Box::new(|cmd| cmd.output_from_real_command()), + ); + test.config.report_url = Some(server.submit_url().into()); + test.config.delete_dump = true; + + // We need the dump file to actually exist since the curl binary is passed the file path. + // The dump file needs to exist at the pending dir location. + + let tempdir = TempDir::new("real_curl_binary"); + let data_dir = tempdir.path().to_owned(); + let pending_dir = data_dir.join("pending"); + test.config.data_dir = Some(data_dir.clone().into()); + ::std::fs::create_dir_all(&pending_dir).unwrap(); + let dump_file = pending_dir.join("minidump.dmp"); + ::std::fs::write(&dump_file, MOCK_MINIDUMP_FILE).unwrap(); + + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + + test.assert_files() + .set_data_dir(data_dir.display()) + .ignore_log() + .saved_settings(Settings::default()) + .submitted(); +} + +#[test] +fn real_curl_library() { + if !crate::net::can_load_libcurl() { + eprintln!("no libcurl; skipping real_libcurl test"); + return; + } + + let server = TestCrashReportServer::run(); + + let mut test = GuiTest::new(); + test.mock + .set( + Command::mock("curl"), + Box::new(|_| Err(std::io::ErrorKind::NotFound.into())), + ) + .set(mock::MockHook::new("use_system_libcurl"), true); + test.config.report_url = Some(server.submit_url().into()); + test.config.delete_dump = true; + + // We need the dump file to actually exist since libcurl is passed the file path. + // The dump file needs to exist at the pending dir location. + + let tempdir = TempDir::new("real_libcurl"); + let data_dir = tempdir.path().to_owned(); + let pending_dir = data_dir.join("pending"); + test.config.data_dir = Some(data_dir.clone().into()); + ::std::fs::create_dir_all(&pending_dir).unwrap(); + let dump_file = pending_dir.join("minidump.dmp"); + ::std::fs::write(&dump_file, MOCK_MINIDUMP_FILE).unwrap(); + + test.run(|interact| { + interact.element("quit", |_style, b: &model::Button| b.click.fire(&())); + }); + + test.assert_files() + .set_data_dir(data_dir.display()) + .ignore_log() + .saved_settings(Settings::default()) + .submitted(); +} diff --git a/toolkit/crashreporter/client/app/src/thread_bound.rs b/toolkit/crashreporter/client/app/src/thread_bound.rs new file mode 100644 index 0000000000..28095f42f4 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/thread_bound.rs @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Encapsulate thread-bound values in a safe manner. +//! +//! This allows non-`Send`/`Sync` values to be transferred across thread boundaries, checking at +//! runtime at access sites whether it is safe to use them. + +pub struct ThreadBound { + data: T, + origin: std::thread::ThreadId, +} + +impl Default for ThreadBound { + fn default() -> Self { + ThreadBound::new(Default::default()) + } +} + +impl ThreadBound { + pub fn new(data: T) -> Self { + ThreadBound { + data, + origin: std::thread::current().id(), + } + } + + pub fn borrow(&self) -> &T { + assert!( + std::thread::current().id() == self.origin, + "unsafe access to thread-bound value" + ); + &self.data + } +} + +// # Safety +// Access to the inner value is only permitted on the originating thread. +unsafe impl Send for ThreadBound {} +unsafe impl Sync for ThreadBound {} diff --git a/toolkit/crashreporter/client/app/src/ui/crashreporter.png b/toolkit/crashreporter/client/app/src/ui/crashreporter.png new file mode 100644 index 0000000000..5e68bac17c Binary files /dev/null and b/toolkit/crashreporter/client/app/src/ui/crashreporter.png differ diff --git a/toolkit/crashreporter/client/app/src/ui/gtk.rs b/toolkit/crashreporter/client/app/src/ui/gtk.rs new file mode 100644 index 0000000000..a76f99b0bd --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/gtk.rs @@ -0,0 +1,841 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use super::model::{self, Alignment, Application, Element}; +use crate::std::{ + cell::RefCell, + ffi::{c_char, CString}, + rc::Rc, + sync::atomic::{AtomicBool, Ordering::Relaxed}, +}; +use crate::{ + data::{Event, Property, Synchronized}, + std, +}; + +/// Create a `std::ffi::CStr` directly from a literal string. +/// +/// The argument is an `expr` rather than `literal` so that other macros can be used (such as +/// `stringify!`). +macro_rules! cstr { + ( $str:expr ) => { + #[allow(unused_unsafe)] + unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked(concat!($str, "\0").as_bytes()) } + .as_ptr() + }; +} + +/// A GTK+ UI implementation. +#[derive(Default)] +pub struct UI { + running: AtomicBool, +} + +impl UI { + pub fn run_loop(&self, app: Application) { + unsafe { + let stream = gtk::g_memory_input_stream_new_from_data( + super::icon::PNG_DATA.as_ptr() as _, + // unwrap() because the PNG_DATA length will be well within gssize limits (32-bit + // at the smallest). + super::icon::PNG_DATA.len().try_into().unwrap(), + None, + ); + let icon_pixbuf = + gtk::gdk_pixbuf_new_from_stream(stream, std::ptr::null_mut(), std::ptr::null_mut()); + gtk::g_object_unref(stream as _); + + gtk::gtk_window_set_default_icon(icon_pixbuf); + + let app_ptr = gtk::gtk_application_new( + std::ptr::null(), + gtk::GApplicationFlags_G_APPLICATION_FLAGS_NONE, + ); + gtk::g_signal_connect_data( + app_ptr as *mut _, + cstr!("activate"), + Some(std::mem::transmute( + render_application + as for<'a> unsafe extern "C" fn(*mut gtk::GtkApplication, &'a Application), + )), + &app as *const Application as *mut Application as _, + None, + 0, + ); + self.running.store(true, Relaxed); + gtk::g_application_run(app_ptr as *mut gtk::GApplication, 0, std::ptr::null_mut()); + self.running.store(false, Relaxed); + gtk::g_object_unref(app_ptr as *mut _); + gtk::g_object_unref(icon_pixbuf as _); + } + } + + pub fn invoke(&self, f: model::InvokeFn) { + if !self.running.load(Relaxed) { + log::debug!("ignoring `invoke` as main loop isn't running"); + return; + } + type BoxedData = Option; + + unsafe extern "C" fn call(ptr: gtk::gpointer) -> gtk::gboolean { + let f: &mut BoxedData = ToPointer::from_ptr(ptr as _); + f.take().unwrap()(); + false.into() + } + + unsafe extern "C" fn drop(ptr: gtk::gpointer) { + let _: Box = ToPointer::from_ptr(ptr as _); + } + + let data: Box = Box::new(Some(f)); + + unsafe { + let main_context = gtk::g_main_context_default(); + gtk::g_main_context_invoke_full( + main_context, + 0, // G_PRIORITY_DEFAULT + Some(call as unsafe extern "C" fn(gtk::gpointer) -> gtk::gboolean), + data.to_ptr() as _, + Some(drop as unsafe extern "C" fn(gtk::gpointer)), + ); + } + } +} + +/// Types that can be converted to and from a pointer. +/// +/// These types must be sized to avoid fat pointers (i.e., the pointers must be FFI-compatible, the +/// same size as usize). +trait ToPointer: Sized { + fn to_ptr(self) -> *mut (); + /// # Safety + /// The caller must ensure that the pointer was created as the result of `to_ptr` on the same + /// or a compatible type, and that the data is still valid. + unsafe fn from_ptr(ptr: *mut ()) -> Self; +} + +/// Types that can be attached to a GLib object to be dropped when the widget is dropped. +trait DropWithObject: Sized { + fn drop_with_object(self, object: *mut gtk::GObject); + fn drop_with_widget(self, widget: *mut gtk::GtkWidget) { + self.drop_with_object(widget as *mut _); + } + + fn set_data(self, object: *mut gtk::GObject, key: *const c_char); +} + +impl DropWithObject for T { + fn drop_with_object(self, object: *mut gtk::GObject) { + unsafe extern "C" fn free_ptr( + ptr: gtk::gpointer, + _object: *mut gtk::GObject, + ) { + drop(T::from_ptr(ptr as *mut ())); + } + unsafe { gtk::g_object_weak_ref(object, Some(free_ptr::), self.to_ptr() as *mut _) }; + } + + fn set_data(self, object: *mut gtk::GObject, key: *const c_char) { + unsafe extern "C" fn free_ptr(ptr: gtk::gpointer) { + drop(T::from_ptr(ptr as *mut ())); + } + unsafe { + gtk::g_object_set_data_full(object, key, self.to_ptr() as *mut _, Some(free_ptr::)) + }; + } +} + +impl ToPointer for CString { + fn to_ptr(self) -> *mut () { + self.into_raw() as _ + } + + unsafe fn from_ptr(ptr: *mut ()) -> Self { + CString::from_raw(ptr as *mut c_char) + } +} + +impl ToPointer for Rc { + fn to_ptr(self) -> *mut () { + Rc::into_raw(self) as *mut T as *mut () + } + + unsafe fn from_ptr(ptr: *mut ()) -> Self { + Rc::from_raw(ptr as *mut T as *const T) + } +} + +impl ToPointer for Box { + fn to_ptr(self) -> *mut () { + Box::into_raw(self) as _ + } + + unsafe fn from_ptr(ptr: *mut ()) -> Self { + Box::from_raw(ptr as _) + } +} + +impl ToPointer for &mut T { + fn to_ptr(self) -> *mut () { + self as *mut T as _ + } + + unsafe fn from_ptr(ptr: *mut ()) -> Self { + &mut *(ptr as *mut T) + } +} + +/// Connect a GTK+ object signal to a function, providing an additional context value (by +/// reference). +macro_rules! connect_signal { + ( object $object:expr ; with $with:expr ; + signal $name:ident ($target:ident : &$type:ty $(, $argname:ident : $argtype:ty )* ) $( -> $ret:ty )? $body:block + ) => {{ + unsafe extern "C" fn $name($($argname : $argtype ,)* $target: &$type) $( -> $ret )? $body + #[allow(unused_unsafe)] + unsafe { + gtk::g_signal_connect_data( + $object as *mut _, + cstr!(stringify!($name)), + Some(std::mem::transmute( + $name + as for<'a> unsafe extern "C" fn( + $($argtype,)* + &'a $type, + ) $( -> $ret )?, + )), + $with as *const $type as *mut $type as _, + None, + 0, + ); + } + }}; +} + +/// Bind a read only (from the renderer perspective) property to a widget. +/// +/// The `set` function is called initially and when the property value changes. +macro_rules! property_read_only { + ( property $property:expr ; + fn set( $name:ident : & $type:ty ) $setbody:block + ) => {{ + let prop: &Property<$type> = $property; + match prop { + Property::Static($name) => $setbody, + Property::Binding(v) => { + { + let $name = v.borrow(); + $setbody + } + v.on_change(move |$name| $setbody); + } + Property::ReadOnly(_) => (), + } + }}; +} + +/// Bind a read/write property to a widget signal. +/// +/// This currently only allows signals which are of the form +/// `void(*)(SomeGtkObject*, gpointer user_data)`. +macro_rules! property_with_signal { + ( object $object:expr ; property $property:expr ; signal $signame:ident ; + fn set( $name:ident : & $type:ty ) $setbody:block + fn get( $getobj:ident : $gettype:ty ) -> $result:ty $getbody:block + ) => {{ + let prop: &Property<$type> = $property; + match prop { + Property::Static($name) => $setbody, + Property::Binding(v) => { + { + let $name = v.borrow(); + $setbody + } + let changing = Rc::new(RefCell::new(false)); + struct SignalData { + changing: Rc>, + value: Synchronized<$result>, + } + let signal_data = Box::new(SignalData { + changing: changing.clone(), + value: v.clone(), + }); + v.on_change(move |$name| { + if !*changing.borrow() { + *changing.borrow_mut() = true; + $setbody; + *changing.borrow_mut() = false; + } + }); + connect_signal! { + object $object; + with signal_data.as_ref(); + signal $signame(signal_data: &SignalData, $getobj: $gettype) { + let new_value = (|| $getbody)(); + if !*signal_data.changing.borrow() { + *signal_data.changing.borrow_mut() = true; + *signal_data.value.borrow_mut() = new_value; + *signal_data.changing.borrow_mut() = false; + } + } + } + signal_data.drop_with_object($object as _); + } + Property::ReadOnly(v) => { + v.register(move |target: &mut $result| { + let $getobj: $gettype = $object as _; + *target = (|| $getbody)(); + }); + } + } + }}; +} + +unsafe extern "C" fn render_application(app_ptr: *mut gtk::GtkApplication, app: &Application) { + unsafe { + gtk::gtk_widget_set_default_direction(if app.rtl { + gtk::GtkTextDirection_GTK_TEXT_DIR_RTL + } else { + gtk::GtkTextDirection_GTK_TEXT_DIR_LTR + }); + } + for window in &app.windows { + let window_ptr = render_window(&window.element_type); + let style = &window.style; + + // Set size before applying style (since the style will set the visibility and show the + // window). Note that we take the size request as an initial size here. + // + // `gtk_window_set_default_size` doesn't work; it resizes to the size request of the inner + // labels (instead of wrapping them) since it doesn't know how small they should be (that's + // dictated by the window size!). + unsafe { + gtk::gtk_window_resize( + window_ptr as _, + style + .horizontal_size_request + .map(|v| v as i32) + .unwrap_or(-1), + style.vertical_size_request.map(|v| v as i32).unwrap_or(-1), + ); + } + + apply_style(window_ptr, style); + unsafe { + gtk::gtk_application_add_window(app_ptr, window_ptr as *mut _); + } + } +} + +fn render(element: &Element) -> Option<*mut gtk::GtkWidget> { + let widget = render_element_type(&element.element_type)?; + apply_style(widget, &element.style); + Some(widget) +} + +fn apply_style(widget: *mut gtk::GtkWidget, style: &model::ElementStyle) { + unsafe { + gtk::gtk_widget_set_halign(widget, alignment(&style.horizontal_alignment)); + if style.horizontal_alignment == Alignment::Fill { + gtk::gtk_widget_set_hexpand(widget, true.into()); + } + gtk::gtk_widget_set_valign(widget, alignment(&style.vertical_alignment)); + if style.vertical_alignment == Alignment::Fill { + gtk::gtk_widget_set_vexpand(widget, true.into()); + } + gtk::gtk_widget_set_size_request( + widget, + style + .horizontal_size_request + .map(|v| v as i32) + .unwrap_or(-1), + style.vertical_size_request.map(|v| v as i32).unwrap_or(-1), + ); + + gtk::gtk_widget_set_margin_start(widget, style.margin.start as i32); + gtk::gtk_widget_set_margin_end(widget, style.margin.end as i32); + gtk::gtk_widget_set_margin_top(widget, style.margin.top as i32); + gtk::gtk_widget_set_margin_bottom(widget, style.margin.bottom as i32); + } + property_read_only! { + property &style.visible; + fn set(new_value: &bool) { + unsafe { + gtk::gtk_widget_set_visible(widget, new_value.clone().into()); + }; + } + } + property_read_only! { + property &style.enabled; + fn set(new_value: &bool) { + unsafe { + gtk::gtk_widget_set_sensitive(widget, new_value.clone().into()); + } + } + } +} + +fn alignment(align: &Alignment) -> gtk::GtkAlign { + use Alignment::*; + match align { + Start => gtk::GtkAlign_GTK_ALIGN_START, + Center => gtk::GtkAlign_GTK_ALIGN_CENTER, + End => gtk::GtkAlign_GTK_ALIGN_END, + Fill => gtk::GtkAlign_GTK_ALIGN_FILL, + } +} + +struct PangoAttrList { + list: *mut gtk::PangoAttrList, +} + +impl PangoAttrList { + pub fn new() -> Self { + PangoAttrList { + list: unsafe { gtk::pango_attr_list_new() }, + } + } + + pub fn bold(&mut self) -> &mut Self { + unsafe { + gtk::pango_attr_list_insert( + self.list, + gtk::pango_attr_weight_new(gtk::PangoWeight_PANGO_WEIGHT_BOLD), + ) + }; + self + } +} + +impl std::ops::Deref for PangoAttrList { + type Target = *mut gtk::PangoAttrList; + + fn deref(&self) -> &Self::Target { + &self.list + } +} + +impl std::ops::DerefMut for PangoAttrList { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.list + } +} + +impl Drop for PangoAttrList { + fn drop(&mut self) { + unsafe { gtk::pango_attr_list_unref(self.list) }; + } +} + +fn render_element_type(element_type: &model::ElementType) -> Option<*mut gtk::GtkWidget> { + use model::ElementType::*; + Some(match element_type { + Label(model::Label { text, bold }) => { + let label_ptr = unsafe { gtk::gtk_label_new(std::ptr::null()) }; + match text { + Property::Static(text) => { + let text = CString::new(text.clone()).ok()?; + unsafe { gtk::gtk_label_set_text(label_ptr as _, text.as_ptr()) }; + text.drop_with_widget(label_ptr); + } + Property::Binding(b) => { + let label_text = Rc::new(RefCell::new(CString::new(b.borrow().clone()).ok()?)); + unsafe { + gtk::gtk_label_set_text(label_ptr as _, label_text.borrow().as_ptr()) + }; + let lt = label_text.clone(); + label_text.drop_with_widget(label_ptr); + b.on_change(move |t| { + let Some(cstr) = CString::new(t.clone()).ok() else { + return; + }; + unsafe { gtk::gtk_label_set_text(label_ptr as _, cstr.as_ptr()) }; + *lt.borrow_mut() = cstr; + }); + } + Property::ReadOnly(_) => unimplemented!("ReadOnly not supported for Label::text"), + } + unsafe { gtk::gtk_label_set_line_wrap(label_ptr as _, true.into()) }; + // This is gtk_label_set_{xalign,yalign} in gtk 3.16+ + unsafe { gtk::gtk_misc_set_alignment(label_ptr as _, 0.0, 0.5) }; + if *bold { + unsafe { + gtk::gtk_label_set_attributes(label_ptr as _, **PangoAttrList::new().bold()) + }; + } + label_ptr + } + HBox(model::HBox { + items, + spacing, + affirmative_order, + }) => { + let box_ptr = + unsafe { gtk::gtk_box_new(gtk::GtkOrientation_GTK_ORIENTATION_HORIZONTAL, 0) }; + unsafe { gtk::gtk_box_set_spacing(box_ptr as *mut _, *spacing as i32) }; + let items_iter: Box> = if *affirmative_order { + Box::new(items.iter().rev()) + } else { + Box::new(items.iter()) + }; + for item in items_iter { + if let Some(widget) = render(item) { + unsafe { + gtk::gtk_container_add(box_ptr as *mut gtk::GtkContainer, widget); + } + // Special case horizontal alignment to pack into the end if appropriate + if item.style.horizontal_alignment == Alignment::End { + unsafe { + gtk::gtk_box_set_child_packing( + box_ptr as _, + widget, + false.into(), + false.into(), + 0, + gtk::GtkPackType_GTK_PACK_END, + ); + } + } + } + } + box_ptr + } + VBox(model::VBox { items, spacing }) => { + let box_ptr = + unsafe { gtk::gtk_box_new(gtk::GtkOrientation_GTK_ORIENTATION_VERTICAL, 0) }; + unsafe { gtk::gtk_box_set_spacing(box_ptr as *mut _, *spacing as i32) }; + for item in items { + if let Some(widget) = render(item) { + unsafe { + gtk::gtk_container_add(box_ptr as *mut gtk::GtkContainer, widget); + } + // Special case vertical alignment to pack into the end if appropriate + if item.style.vertical_alignment == Alignment::End { + unsafe { + gtk::gtk_box_set_child_packing( + box_ptr as _, + widget, + false.into(), + false.into(), + 0, + gtk::GtkPackType_GTK_PACK_END, + ); + } + } + } + } + box_ptr + } + Button(model::Button { content, click }) => { + let button_ptr = unsafe { gtk::gtk_button_new() }; + if let Some(widget) = content.as_deref().and_then(render) { + unsafe { + // Always center widgets in buttons. + gtk::gtk_widget_set_valign(widget, alignment(&Alignment::Center)); + gtk::gtk_widget_set_halign(widget, alignment(&Alignment::Center)); + gtk::gtk_container_add(button_ptr as *mut gtk::GtkContainer, widget); + } + } + connect_signal! { + object button_ptr; + with click; + signal clicked(event: &Event<()>, _button: *mut gtk::GtkButton) { + event.fire(&()); + } + } + button_ptr + } + Checkbox(model::Checkbox { checked, label }) => { + let cb_ptr = match label { + None => unsafe { gtk::gtk_check_button_new() }, + Some(s) => { + let label = CString::new(s.clone()).ok()?; + let cb = unsafe { gtk::gtk_check_button_new_with_label(label.as_ptr()) }; + label.drop_with_widget(cb); + cb + } + }; + property_with_signal! { + object cb_ptr; + property checked; + signal toggled; + fn set(new_value: &bool) { + unsafe { + gtk::gtk_toggle_button_set_active(cb_ptr as *mut _, new_value.clone().into()) + }; + } + fn get(button: *mut gtk::GtkToggleButton) -> bool { + unsafe { + gtk::gtk_toggle_button_get_active(button) == 1 + } + } + } + cb_ptr + } + TextBox(model::TextBox { + placeholder, + content, + editable, + }) => { + let text_ptr = unsafe { gtk::gtk_text_view_new() }; + unsafe { + const GTK_WRAP_WORD_CHAR: u32 = 3; + gtk::gtk_text_view_set_wrap_mode(text_ptr as *mut _, GTK_WRAP_WORD_CHAR); + gtk::gtk_text_view_set_editable(text_ptr as *mut _, editable.clone().into()); + gtk::gtk_text_view_set_accepts_tab(text_ptr as *mut _, false.into()); + } + let buffer = unsafe { gtk::gtk_text_view_get_buffer(text_ptr as *mut _) }; + + struct State { + placeholder: Option, + } + + struct Placeholder { + string: CString, + visible: RefCell, + } + + impl Placeholder { + fn focus(&self, widget: *mut gtk::GtkWidget) { + if *self.visible.borrow() { + unsafe { + let buffer = gtk::gtk_text_view_get_buffer(widget as *mut _); + gtk::gtk_text_buffer_set_text(buffer, self.string.as_ptr(), 0); + gtk::gtk_widget_override_color( + widget, + gtk::GtkStateFlags_GTK_STATE_FLAG_NORMAL, + std::ptr::null(), + ); + } + *self.visible.borrow_mut() = false; + } + } + + fn unfocus(&self, widget: *mut gtk::GtkWidget) { + unsafe { + let buffer = gtk::gtk_text_view_get_buffer(widget as *mut _); + + let mut end_iter = gtk::GtkTextIter::default(); + gtk::gtk_text_buffer_get_end_iter(buffer, &mut end_iter); + let is_empty = gtk::gtk_text_iter_get_offset(&end_iter) == 0; + + if is_empty && !*self.visible.borrow() { + gtk::gtk_text_buffer_set_text(buffer, self.string.as_ptr(), -1); + let context = gtk::gtk_widget_get_style_context(widget); + let mut color = gtk::GdkRGBA::default(); + gtk::gtk_style_context_get_color( + context, + gtk::GtkStateFlags_GTK_STATE_FLAG_INSENSITIVE, + &mut color, + ); + gtk::gtk_widget_override_color( + widget, + gtk::GtkStateFlags_GTK_STATE_FLAG_NORMAL, + &color, + ); + *self.visible.borrow_mut() = true; + } + } + } + } + + let mut state = Box::new(State { placeholder: None }); + + if let Some(placeholder) = placeholder { + state.placeholder = Some(Placeholder { + string: CString::new(placeholder.clone()).ok()?, + visible: RefCell::new(false), + }); + + let placeholder = state.placeholder.as_ref().unwrap(); + + placeholder.unfocus(text_ptr); + + connect_signal! { + object text_ptr; + with placeholder; + signal focus_in_event(placeholder: &Placeholder, widget: *mut gtk::GtkWidget, + _event: *mut gtk::GdkEventFocus) -> gtk::gboolean { + placeholder.focus(widget); + false.into() + } + } + connect_signal! { + object text_ptr; + with placeholder; + signal focus_out_event(placeholder: &Placeholder, widget: *mut gtk::GtkWidget, + _event: *mut gtk::GdkEventFocus) -> gtk::gboolean { + placeholder.unfocus(widget); + false.into() + } + } + } + + // Attach the state so that we can access it in the changed signal. + // This is kind of ugly; in the future it might be a nicer developer interface to simply + // always use a closure as the user data (which itself can capture arbitrary things). This + // would move the data management from GTK to rust. + state.set_data(buffer as *mut _, cstr!("textview-state")); + + property_with_signal! { + object buffer; + property content; + signal changed; + fn set(new_value: &String) { + unsafe { + gtk::gtk_text_buffer_set_text(buffer, new_value.as_ptr() as *const c_char, new_value.len().try_into().unwrap()); + } + } + fn get(buffer: *mut gtk::GtkTextBuffer) -> String { + let state = unsafe { + gtk::g_object_get_data(buffer as *mut _, cstr!("textview-state")) + }; + if !state.is_null() { + let s: &State = unsafe { &*(state as *mut State as *const State) }; + if let Some(placeholder) = &s.placeholder { + if *placeholder.visible.borrow() { + return "".to_owned(); + } + } + } + let mut start_iter = gtk::GtkTextIter::default(); + unsafe { + gtk::gtk_text_buffer_get_start_iter(buffer, &mut start_iter); + } + + let mut s = String::new(); + loop { + let c = unsafe { gtk::gtk_text_iter_get_char(&start_iter) }; + if c == 0 { + break; + } + // Safety: + // gunichar is guaranteed to be a valid unicode codepoint (if nonzero). + s.push(unsafe { char::from_u32_unchecked(c) }); + unsafe { + gtk::gtk_text_iter_forward_char(&mut start_iter); + } + } + s + } + } + text_ptr + } + Scroll(model::Scroll { content }) => { + let scroll_ptr = + unsafe { gtk::gtk_scrolled_window_new(std::ptr::null_mut(), std::ptr::null_mut()) }; + unsafe { + gtk::gtk_scrolled_window_set_policy( + scroll_ptr as *mut _, + gtk::GtkPolicyType_GTK_POLICY_NEVER, + gtk::GtkPolicyType_GTK_POLICY_ALWAYS, + ); + gtk::gtk_scrolled_window_set_shadow_type( + scroll_ptr as *mut _, + gtk::GtkShadowType_GTK_SHADOW_IN, + ); + }; + if let Some(widget) = content.as_deref().and_then(render) { + unsafe { + gtk::gtk_container_add(scroll_ptr as *mut gtk::GtkContainer, widget); + } + } + scroll_ptr + } + Progress(model::Progress { amount }) => { + let progress_ptr = unsafe { gtk::gtk_progress_bar_new() }; + property_read_only! { + property amount; + fn set(value: &Option) { + match &*value { + Some(v) => unsafe { + gtk::gtk_progress_bar_set_fraction( + progress_ptr as *mut _, + v.clamp(0f32,1f32) as f64, + ); + } + None => unsafe { + gtk::gtk_progress_bar_pulse(progress_ptr as *mut _); + + fn auto_pulse_progress_bar(progress: *mut gtk::GtkProgressBar) { + unsafe extern fn pulse(progress: *mut std::ffi::c_void) -> gtk::gboolean { + if gtk::gtk_widget_is_visible(progress as _) == 0 { + false.into() + } else { + gtk::gtk_progress_bar_pulse(progress as _); + true.into() + } + } + unsafe { + gtk::g_timeout_add(100, Some(pulse as unsafe extern fn(*mut std::ffi::c_void) -> gtk::gboolean), progress as _); + } + + } + + connect_signal! { + object progress_ptr; + with std::ptr::null_mut(); + signal show(_user_data: &(), progress: *mut gtk::GtkWidget) { + auto_pulse_progress_bar(progress as *mut _); + } + } + auto_pulse_progress_bar(progress_ptr as *mut _); + } + } + } + } + progress_ptr + } + }) +} + +fn render_window( + model::Window { + title, + content, + children, + modal, + close, + }: &model::Window, +) -> *mut gtk::GtkWidget { + unsafe { + let window_ptr = gtk::gtk_window_new(gtk::GtkWindowType_GTK_WINDOW_TOPLEVEL); + if !title.is_empty() { + if let Some(title) = CString::new(title.clone()).ok() { + gtk::gtk_window_set_title(window_ptr as *mut _, title.as_ptr()); + title.drop_with_widget(window_ptr); + } + } + if let Some(content) = content { + if let Some(widget) = render(content) { + gtk::gtk_container_add(window_ptr as *mut gtk::GtkContainer, widget); + } + } + for child in children { + let widget = render_window(&child.element_type); + apply_style(widget, &child.style); + gtk::gtk_window_set_transient_for(widget as *mut _, window_ptr as *mut _); + // Delete should hide the window. + gtk::g_signal_connect_data( + widget as *mut _, + cstr!("delete-event"), + Some(std::mem::transmute( + gtk::gtk_widget_hide_on_delete + as unsafe extern "C" fn(*mut gtk::GtkWidget) -> i32, + )), + std::ptr::null_mut(), + None, + 0, + ); + } + if *modal { + gtk::gtk_window_set_modal(window_ptr as *mut _, true.into()); + } + if let Some(close) = close { + close.subscribe(move |&()| gtk::gtk_window_close(window_ptr as *mut _)); + } + + window_ptr + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/macos/mod.rs b/toolkit/crashreporter/client/app/src/ui/macos/mod.rs new file mode 100644 index 0000000000..6520d16472 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/macos/mod.rs @@ -0,0 +1,1122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! A UI using the macos cocoa API. +//! +//! This UI contains some edge cases that aren't implemented, for instance: +//! * there are a few cases where specific hierarchies are handled differently (e.g. a Button +//! containing Label), etc. +//! * not all controls handle all Property variants (e.g. Checkbox doesn't handle ReadOnly, Text +//! doesn't handle Binding, etc). +//! +//! The rendering ignores `ElementStyle::margin` entirely, because +//! * `NSView` doesn't support margins (so working them into constraints would be a bit annoying), +//! and +//! * `NSView.layoutMarginsGuide` results in a layout almost identical to what the margins (at the +//! time of this writing) in the UI layouts are achieving. +//! +//! In a few cases, init or creation functions are called which _could_ return nil and are wrapped +//! in their type wrapper (as those functions return `instancetype`/`id`). We consider this safe +//! enough because it won't cause unsoundness (they are only passed to objc functions which can +//! take nil arguments) and the failure case is very unlikely. + +#![allow(non_upper_case_globals)] + +use self::objc::*; +use super::model::{self, Alignment, Application, Element, TypedElement}; +use crate::data::Property; +use cocoa::{ + INSApplication, INSBox, INSButton, INSColor, INSControl, INSFont, INSLayoutAnchor, + INSLayoutConstraint, INSLayoutDimension, INSLayoutGuide, INSMenu, INSMenuItem, + INSMutableParagraphStyle, INSObject, INSProcessInfo, INSProgressIndicator, INSRunLoop, + INSScrollView, INSStackView, INSText, INSTextContainer, INSTextField, INSTextView, INSView, + INSWindow, NSArray_NSArrayCreation, NSAttributedString_NSExtendedAttributedString, + NSDictionary_NSDictionaryCreation, NSRunLoop_NSRunLoopConveniences, + NSStackView_NSStackViewGravityAreas, NSString_NSStringExtensionMethods, + NSTextField_NSTextFieldConvenience, NSView_NSConstraintBasedLayoutInstallingConstraints, + NSView_NSConstraintBasedLayoutLayering, NSView_NSSafeAreas, PNSObject, +}; +use once_cell::sync::Lazy; + +/// https://developer.apple.com/documentation/foundation/1497293-string_encodings/nsutf8stringencoding?language=objc +const NSUTF8StringEncoding: cocoa::NSStringEncoding = 4; + +/// Constant from NSCell.h +const NSControlStateValueOn: cocoa::NSControlStateValue = 1; + +/// Constant from NSLayoutConstraint.h +const NSLayoutPriorityDefaultHigh: cocoa::NSLayoutPriority = 750.0; + +mod objc; + +/// A MacOS Cocoa UI implementation. +#[derive(Default)] +pub struct UI; + +impl UI { + pub fn run_loop(&self, app: Application) { + let nsapp = unsafe { cocoa::NSApplication::sharedApplication() }; + + Objc::::register(); + Objc::