diff options
Diffstat (limited to '')
60 files changed, 11310 insertions, 0 deletions
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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDevelopmentRegion</key> + <string>English</string> + <key>CFBundleDisplayName</key> + <string>crashreporter</string> + <key>CFBundleExecutable</key> + <string>crashreporter</string> + <key>CFBundleIconFile</key> + <string>crashreporter.icns</string> + <key>CFBundleIdentifier</key> + <string>org.mozilla.crashreporter</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>crashreporter</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleSignature</key> + <string>????</string> + <key>CFBundleVersion</key> + <string>1.0</string> + <key>LSHasLocalizedDisplayName</key> + <true/> + <key>NSRequiresAquaSystemAppearance</key> + <false/> + <key>NSPrincipalClass</key> + <string>NSApplication</string> + <key>LSUIElement</key> + <true/> +</dict> +</plist> diff --git a/toolkit/crashreporter/client/macbuild/Contents/PkgInfo b/toolkit/crashreporter/client/app/macos_app_bundle/PkgInfo index cae6d0a58f..cae6d0a58f 100644 --- a/toolkit/crashreporter/client/macbuild/Contents/PkgInfo +++ b/toolkit/crashreporter/client/app/macos_app_bundle/PkgInfo diff --git a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in b/toolkit/crashreporter/client/app/macos_app_bundle/Resources/English.lproj/InfoPlist.strings.in index e08ce59eb6..e08ce59eb6 100644 --- a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in +++ b/toolkit/crashreporter/client/app/macos_app_bundle/Resources/English.lproj/InfoPlist.strings.in diff --git a/toolkit/crashreporter/client/macbuild/Contents/Resources/crashreporter.icns b/toolkit/crashreporter/client/app/macos_app_bundle/Resources/crashreporter.icns Binary files differindex 341cd05a4d..341cd05a4d 100644 --- a/toolkit/crashreporter/client/macbuild/Contents/Resources/crashreporter.icns +++ b/toolkit/crashreporter/client/app/macos_app_bundle/Resources/crashreporter.icns 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<T> = Box<dyn FnOnce(&T) + Send + 'static>; + +pub struct AsyncTask<T> { + send: Box<dyn Fn(TaskFn<T>) + Send + Sync>, +} + +impl<T> AsyncTask<T> { + pub fn new<F: Fn(TaskFn<T>) + Send + Sync + 'static>(send: F) -> Self { + AsyncTask { + send: Box::new(send), + } + } + + pub fn push<F: FnOnce(&T) + Send + 'static>(&self, f: F) { + (self.send)(Box::new(f)); + } + + pub fn wait<R: Send + 'static, F: FnOnce(&T) -> 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<PathBuf>, + /// The events directory. + pub events_dir: Option<PathBuf>, + /// The ping directory. + pub ping_dir: Option<PathBuf>, + /// The dump file. + /// + /// If missing, an error dialog is displayed. + pub dump_file: Option<PathBuf>, + /// The XUL_APP_FILE to define if restarting the application. + pub app_file: Option<OsString>, + /// The path to the application to use when restarting the crashed process. + pub restart_command: Option<OsString>, + /// The arguments to pass if restarting the application. + pub restart_args: Vec<OsString>, + /// The URL to which to send reports. + pub report_url: Option<OsString>, + /// The localized strings to use. + pub strings: Option<lang::LangStrings>, + /// The log target. + pub log_target: Option<LogTarget>, +} + +pub struct ConfigStringBuilder<'a>(lang::LangStringBuilder<'a>); + +impl<'a> ConfigStringBuilder<'a> { + /// Set an argument for the string. + pub fn arg<V: Into<Cow<'a, str>>>(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<serde_json::Value> { + 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<PathBuf> { + 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<PathBuf> { + 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<str> { + 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<PathBuf> { + 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<N: AsRef<OsStr>>(&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<PathBuf> { + 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<PathBuf> { + // 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<PathBuf> { + 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<NSString*>* */; + } + #[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<NSString*>* */ = 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<PathBuf> { + 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<K: AsRef<OsStr>>(name: K) -> bool { + std::env::var(name).map(|s| !s.is_empty()).unwrap_or(false) +} + +fn env_path<K: AsRef<OsStr>>(name: K) -> Option<PathBuf> { + 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<T> { + subscribers: Rc<RefCell<Vec<Box<dyn Fn(&T)>>>>, +} + +impl<T> Clone for Event<T> { + fn clone(&self) -> Self { + Event { + subscribers: self.subscribers.clone(), + } + } +} + +impl<T> Default for Event<T> { + fn default() -> Self { + Event { + subscribers: Default::default(), + } + } +} + +impl<T> std::fmt::Debug for Event<T> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{} {{ {} subscribers }}", + std::any::type_name::<Self>(), + self.subscribers.borrow().len() + ) + } +} + +impl<T> Event<T> { + /// Add a callback for when the event is fired. + pub fn subscribe<F>(&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<T> { + inner: Rc<SynchronizedInner<T>>, +} + +impl<T> Clone for Synchronized<T> { + fn clone(&self) -> Self { + Synchronized { + inner: self.inner.clone(), + } + } +} + +impl<T: std::fmt::Debug> std::fmt::Debug for Synchronized<T> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct(std::any::type_name::<Self>()) + .field("current", &*self.inner.current.borrow()) + .field("change", &self.inner.change) + .finish() + } +} + +#[derive(Default)] +struct SynchronizedInner<T> { + current: RefCell<T>, + change: Event<T>, +} + +impl<T> Synchronized<T> { + /// 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<T> { + 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<F: Fn(&T) + 'static>(&self, f: F) { + self.inner.change.subscribe(f); + } + + /// Update another synchronized value when this one changes. + pub fn update_on_change<U: 'static, F: Fn(&T) -> U + 'static>( + &self, + other: &Synchronized<U>, + 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, F: Fn(&T) -> U + 'static>(&self, f: F) -> Synchronized<U> { + let s = Synchronized::new(f(&*self.borrow())); + self.update_on_change(&s, f); + s + } + + pub fn join<A: 'static, B: 'static, F: Fn(&A, &B) -> T + Clone + 'static>( + a: &Synchronized<A>, + b: &Synchronized<B>, + 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<T> { + get: Rc<RefCell<Option<Box<dyn Fn(&mut T) + 'static>>>>, +} + +impl<T> Default for OnDemand<T> { + fn default() -> Self { + OnDemand { + get: Default::default(), + } + } +} + +impl<T> Clone for OnDemand<T> { + fn clone(&self) -> Self { + OnDemand { + get: self.get.clone(), + } + } +} + +impl<T> std::fmt::Debug for OnDemand<T> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{} {{ {} }}", + std::any::type_name::<Self>(), + if self.get.borrow().is_none() { + "not registered" + } else { + "registered" + } + ) + } +} + +impl<T> OnDemand<T> { + /// 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<T>` can be converted to dynamic bindings which will be updated +/// bidirectionally. +/// * `OnDemand<T>` can be converted to dynamic bindings which can be queried on an as-needed +/// basis. +#[derive(Clone, Debug)] +pub enum Property<T> { + Static(T), + Binding(Synchronized<T>), + ReadOnly(OnDemand<T>), +} + +#[cfg(test)] +impl<T: Clone + Default + 'static> Property<T> { + 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<T: Default> Default for Property<T> { + fn default() -> Self { + Property::Static(Default::default()) + } +} + +impl<T> From<T> for Property<T> { + fn from(value: T) -> Self { + Property::Static(value) + } +} + +impl<T> From<&Synchronized<T>> for Property<T> { + fn from(value: &Synchronized<T>) -> Self { + Property::Binding(value.clone()) + } +} + +impl<T> From<&OnDemand<T>> for Property<T> { + fn from(value: &OnDemand<T>) -> 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<std::cell::RefMut<'a, T>>, + inner: &'a SynchronizedInner<T>, +} + +impl<T> std::ops::Deref for ValueRefMut<'_, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + &*self.value + } +} + +impl<T> std::ops::DerefMut for ValueRefMut<'_, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut *self.value + } +} + +impl<T> 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<AtomicUsize>, + } + + 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::<usize>::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::<usize>::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<LangStrings> { + let Self { + identifier: lang, + ftl_definitions: definitions, + ftl_branding: branding, + } = self; + + let langid = lang + .parse::<LanguageIdentifier>() + .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<M>( + bundle: &mut FluentBundle<FluentResource, M>, + 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<LangStrings> { + // 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<FluentResource, IntlLangMemoizer>, + 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<FluentResource, IntlLangMemoizer>, 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<String> { + 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<V: Into<Cow<'a, str>>>(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<String> { + 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<LanguageInfo> { + 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::<Vec<_>>() + }; + + 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<File>) -> anyhow::Result<String> { + 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<File>) -> anyhow::Result<String> { + 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<File>> { + 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<Mutex<Box<dyn std::io::Write + Send + 'static>>>, +} + +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<usize> { + 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<Settings>, + config: Arc<Config>, + extra: serde_json::Value, + settings_file: PathBuf, + attempted_to_send: AtomicBool, + ui: Option<AsyncTask<ReportCrashUIState>>, +} + +impl ReportCrash { + pub fn new(config: Arc<Config>, extra: serde_json::Value) -> anyhow::Result<Self> { + 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<bool> { + 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<String> { + 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<Option<Uuid>> { + 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<Uuid>, + ) -> 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<Self> { + let mut lines = (&mut reader).lines(); + + let mut read_field = move |name: &str| -> std::io::Result<String> { + 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<Option<String>> { + 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<String>) -> 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, "<serialization error: {e}>"); + } + } + } + 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<bool> { + 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<ReportCrashUIState> { + 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<Config>) -> anyhow::Result<bool> { + 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<String>, + #[serde(skip_serializing_if = "Option::is_none")] + xpcom_abi: Option<String>, +} + +impl<'a> Ping<'a> { + pub fn crash( + extra: &'a serde_json::Value, + crash_id: &'a str, + minidump_sha256_hash: Option<&'a str>, + ) -> anyhow::Result<Self> { + 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<String> { + 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<E>(error: E) -> std::io::Error +where + E: Into<Box<dyn std::error::Error + Send + Sync>>, +{ + 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::<unsafe extern fn $args $(->$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<Self> { + // 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<std::io::Result<Curl>> = Lazy::new(Curl::load); + CURL.as_ref().map_err(error_other) +} + +#[derive(Debug)] +pub struct Error { + code: CurlCode, + error: Option<String>, +} + +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<Error> for std::io::Error { + fn from(e: Error) -> Self { + error_other(e) + } +} + +pub type Result<T> = std::result::Result<T, Error>; + +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<Easy> { + 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<Mime<'a>>, +} + +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<Mime<'a>> { + 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<Vec<u8>> { + // 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<u8> = Vec::new(); + extern "C" fn write_callback( + data: *const u8, + size: usize, + nmemb: usize, + dest: &mut Vec<u8>, + ) -> 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<u8>) -> 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<u64> { + 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<MimePart<'a>> { + 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<dyn Fn(&CrashReport) -> std::io::Result<std::io::Result<String>> + 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<CrashReportSender> { + // 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<CrashReportSender> { + 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<CrashReportSender> { + #[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<CrashReportSender> { + 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<String>, + }, +} + +impl CrashReportSender { + pub fn finish(self) -> anyhow::Result<Response> { + 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<String>, + pub stop_sending_reports_for: Option<String>, + pub view_url: Option<String>, + pub discarded: bool, +} + +impl Response { + /// Parse a server response. + /// + /// The response should be newline-separated `<key>=<value>` pairs. + fn parse<S: AsRef<str>>(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<S: AsRef<OsStr>>(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<W: std::io::Write>(&self, writer: W) -> anyhow::Result<()> { + Ok(serde_json::to_writer_pretty(writer, self)?) + } + + /// Read the settings from the given reader. + pub fn from_reader<R: std::io::Read>(reader: R) -> anyhow::Result<Self> { + 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<OsString>, +} + +impl Iterator for ArgsOs { + type Item = OsString; + + fn next(&mut self) -> Option<Self::Item> { + Some( + self.argv0 + .take() + .expect("only argv[0] is available when mocked"), + ) + } +} + +pub fn var<K: AsRef<OsStr>>(_key: K) -> Result<String, VarError> { + unimplemented!("no var access in tests") +} + +pub fn var_os<K: AsRef<OsStr>>(_key: K) -> Option<OsString> { + 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<super::path::PathBuf> { + 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<Mutex<Vec<u8>>>); + +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<u8>) -> Self { + MockFileContent(Arc::new(Mutex::new(data))) + } +} + +impl From<()> for MockFileContent { + fn from(_: ()) -> Self { + Self::empty() + } +} + +impl From<String> 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<Vec<u8>> for MockFileContent { + fn from(bytes: Vec<u8>) -> 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<OsString, MockFSItem>; + +/// The content of a mock filesystem item. +pub enum MockFSContent { + /// File content. + File(Result<MockFileContent>), + /// 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<MockFSContent> for MockFSItem { + fn from(content: MockFSContent) -> Self { + MockFSItem { + content, + modified: SystemTime::UNIX_EPOCH, + } + } +} + +/// A mock filesystem. +#[derive(Debug, Clone)] +pub struct MockFiles { + root: Arc<Mutex<MockFSItem>>, +} + +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<P: AsRef<Path>, C: Into<MockFileContent>>(&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<P: AsRef<Path>>(&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<P: AsRef<Path>>( + &self, + path: P, + result: Result<MockFileContent>, + 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<P: AsRef<Path>, F, R>(&self, path: P, create_dirs: bool, f: F) -> Result<R> + 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<P: AsRef<Path>, F, R>(&self, path: P, f: F) -> Result<R> + 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<PathBuf, MockFileContent>, 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<PathBuf, MockFileContent>, +} + +// 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<P: AsRef<Path>, S: AsRef<str>>(&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<P: AsRef<Path>, 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<P: AsRef<Path>>(&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<P: AsRef<Path>>(&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<P: AsRef<Path>>(path: P) -> Result<File> { + 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<P: AsRef<Path>>(path: P) -> Result<File> { + 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<usize> { + 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<u64> { + 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<usize> { + 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<P: AsRef<Path>>(path: P) -> Result<()> { + MockFS.get(move |files| files.path(path, true, |_| ())) +} + +pub fn rename<P: AsRef<Path>, Q: AsRef<Path>>(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<P: AsRef<Path>>(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<P: AsRef<Path>, 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<OsString>, +} + +impl ReadDir { + pub fn new(path: &Path) -> Result<Self> { + 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<DirEntry>; + fn next(&mut self) -> Option<Self::Item> { + 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<Metadata> { + 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<super::time::SystemTime> { + 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<dyn MockKeyStored>, Box<dyn Any + Send + Sync>>; + +thread_local! { + static MOCK_DATA: AtomicPtr<MockDataMap> = 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<H: Hasher>(&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<T: Any>(&self) -> Option<&T> { + if self.type_id() == TypeId::of::<T>() { + 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<F, R>(&self, f: F) -> Option<R> + 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<F, R>(&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<MockDataMap>); + +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<K: MockKey>(&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<F, R>(&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<T> { + name: &'static str, + _p: std::marker::PhantomData<fn() -> T>, +} + +impl<T> std::fmt::Debug for MockHook<T> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct(&format!("MockHook<{}>", std::any::type_name::<T>())) + .field("name", &self.name) + .finish() + } +} + +impl<T: 'static> MockKeyStored for MockHook<T> { + fn eq(&self, other: &dyn MockKeyStored) -> bool { + std::any::TypeId::of::<Self>() == other.type_id() + && self.name == other.downcast_ref::<Self>().unwrap().name + } + fn hash(&self, state: &mut DefaultHasher) { + self.name.hash(state) + } +} + +impl<T: Any + Send + Sync + 'static> MockKey for MockHook<T> { + type Value = T; +} + +impl<T> MockHook<T> { + /// 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<T: Any + Send + Sync + Clone>(_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<T: Any + Send + Sync + Clone>(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: `<visibility> struct NAME => VALUE_TYPE` +/// * Tuple struct: `<visibility> struct NAME(ITEMS) => VALUE_TYPE` +/// * Normal struct: `<visibility> 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::<Self>() == other.type_id() + && PartialEq::eq(self, other.downcast_ref::<Self>().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<T: std::any::Any + Send + Sync + Clone>(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<T: std::any::Any + Send + Sync + Clone>(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<std::path::Path> for Path { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef<OsStr> for Path { + fn as_ref(&self) -> &OsStr { + self.0.as_ref() + } +} + +impl AsRef<Path> for &str { + fn as_ref(&self) -> &Path { + Path::from_path(self.as_ref()) + } +} + +impl AsRef<Path> for String { + fn as_ref(&self) -> &Path { + Path::from_path(self.as_ref()) + } +} + +impl AsRef<Path> 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> { + 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<P: AsRef<Path>>(&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<S: AsRef<OsStr>>(&mut self, extension: S) -> bool { + self.0.set_extension(extension) + } + + pub fn push<P: AsRef<Path>>(&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<Path> for PathBuf { + fn as_ref(&self) -> &Path { + Path::from_path(self.0.as_ref()) + } +} + +impl AsRef<std::path::Path> for PathBuf { + fn as_ref(&self) -> &std::path::Path { + self.0.as_ref() + } +} + +impl AsRef<OsStr> for PathBuf { + fn as_ref(&self) -> &OsStr { + self.0.as_ref() + } +} + +impl From<std::ffi::OsString> for PathBuf { + fn from(os_str: std::ffi::OsString) -> Self { + PathBuf(os_str.into()) + } +} + +impl From<std::path::PathBuf> for PathBuf { + fn from(pathbuf: std::path::PathBuf) -> Self { + PathBuf(pathbuf) + } +} + +impl From<PathBuf> 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<dyn Fn(&Command) -> Result<Output> + Send + Sync> +} + +#[derive(Debug)] +pub struct Command { + pub program: OsString, + pub args: Vec<OsString>, + pub env: std::collections::HashMap<OsString, OsString>, + pub stdin: Vec<u8>, + // 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<Option<::std::process::Child>>, +} + +impl Command { + pub fn mock<S: AsRef<OsStr>>(program: S) -> MockCommand { + MockCommand(program.as_ref().into()) + } + + pub fn new<S: AsRef<OsStr>>(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<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self { + self.args.push(arg.as_ref().into()); + self + } + + pub fn args<I, S>(&mut self, args: I) -> &mut Self + where + I: IntoIterator<Item = S>, + S: AsRef<OsStr>, + { + for arg in args.into_iter() { + self.arg(arg); + } + self + } + + pub fn env<K, V>(&mut self, key: K, val: V) -> &mut Self + where + K: AsRef<OsStr>, + V: AsRef<OsStr>, + { + self.env.insert(key.as_ref().into(), val.as_ref().into()); + self + } + + pub fn stdin<T: Into<Stdio>>(&mut self, _cfg: T) -> &mut Self { + self + } + + pub fn stdout<T: Into<Stdio>>(&mut self, _cfg: T) -> &mut Self { + self + } + + pub fn stderr<T: Into<Stdio>>(&mut self, _cfg: T) -> &mut Self { + self + } + + pub fn output(&mut self) -> std::io::Result<Output> { + MockCommand(self.program.as_os_str().into()).get(|f| f(self)) + } + + pub fn spawn(&mut self) -> std::io::Result<Child> { + 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<Output> { + 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<ChildStdin>, + cmd: Command, + stdin_data: Option<Arc<Mutex<Vec<u8>>>>, +} + +impl Child { + pub fn wait_with_output(mut self) -> std::io::Result<Output> { + self.ref_wait_with_output().unwrap() + } + + fn ref_wait_with_output(&mut self) -> Option<std::io::Result<Output>> { + 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<Mutex<Vec<u8>>>, +} + +impl std::io::Write for ChildStdin { + fn write(&mut self, buf: &[u8]) -> Result<usize> { + 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, T>(f: F) -> JoinHandle<T> +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<F, T>(&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<SystemTime> 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<Duration, SystemTimeError> { + 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<AtomicUsize>); + +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>(T); + +impl<T: std::fmt::Display> std::fmt::Display for FluentArg<T> { + 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<G, I, R>(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<F: FnOnce(Interact) + Send + 'static>( + &mut self, + interact: F, + ) -> anyhow::Result<bool> { + 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<F: FnOnce(Interact) + Send + 'static>(&mut self, interact: F) { + if let Err(e) = self.try_run(interact) { + panic!( + "gui failure:{}", + e.chain().map(|e| format!("\n {e}")).collect::<String>() + ); + } + } + + /// 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<S: ToString>(&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<OsString> = [ + "--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<T> { + data: T, + origin: std::thread::ThreadId, +} + +impl<T: Default> Default for ThreadBound<T> { + fn default() -> Self { + ThreadBound::new(Default::default()) + } +} + +impl<T> ThreadBound<T> { + 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<T> Send for ThreadBound<T> {} +unsafe impl<T> Sync for ThreadBound<T> {} diff --git a/toolkit/crashreporter/client/app/src/ui/crashreporter.png b/toolkit/crashreporter/client/app/src/ui/crashreporter.png Binary files differnew file mode 100644 index 0000000000..5e68bac17c --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/crashreporter.png 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<model::InvokeFn>; + + 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<BoxedData> = ToPointer::from_ptr(ptr as _); + } + + let data: Box<BoxedData> = 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<T: ToPointer> DropWithObject for T { + fn drop_with_object(self, object: *mut gtk::GObject) { + unsafe extern "C" fn free_ptr<T: ToPointer>( + ptr: gtk::gpointer, + _object: *mut gtk::GObject, + ) { + drop(T::from_ptr(ptr as *mut ())); + } + unsafe { gtk::g_object_weak_ref(object, Some(free_ptr::<T>), self.to_ptr() as *mut _) }; + } + + fn set_data(self, object: *mut gtk::GObject, key: *const c_char) { + unsafe extern "C" fn free_ptr<T: ToPointer>(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::<T>)) + }; + } +} + +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<T> ToPointer for Rc<T> { + 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<T> ToPointer for Box<T> { + fn to_ptr(self) -> *mut () { + Box::into_raw(self) as _ + } + + unsafe fn from_ptr(ptr: *mut ()) -> Self { + Box::from_raw(ptr as _) + } +} + +impl<T: Sized> 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<RefCell<bool>>, + 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<dyn Iterator<Item = &Element>> = 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<Placeholder>, + } + + struct Placeholder { + string: CString, + visible: RefCell<bool>, + } + + 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<f32>) { + 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::<AppDelegate>::register(); + Objc::<Button>::register(); + Objc::<Checkbox>::register(); + Objc::<TextView>::register(); + Objc::<Window>::register(); + + rc::autoreleasepool(|| { + let delegate = AppDelegate::new(app).into_object(); + // Set delegate + unsafe { nsapp.setDelegate_(delegate.instance as *mut _) }; + + // Set up the main menu + unsafe { + let appname = read_nsstring(cocoa::NSProcessInfo::processInfo().processName()); + let mainmenu = StrongRef::new(cocoa::NSMenu::alloc()); + mainmenu.init(); + + { + // We don't need a title for the app menu item nor menu; it will always come from + // the process name regardless of what we set. + let appmenuitem = StrongRef::new(cocoa::NSMenuItem::alloc()).autorelease(); + appmenuitem.init(); + mainmenu.addItem_(appmenuitem); + + let appmenu = StrongRef::new(cocoa::NSMenu::alloc()); + appmenu.init(); + + { + let quit = StrongRef::new(cocoa::NSMenuItem::alloc()); + quit.initWithTitle_action_keyEquivalent_( + nsstring(&format!("Quit {appname}")), + sel!(terminate:), + nsstring("q"), + ); + appmenu.addItem_(quit.autorelease()); + } + appmenuitem.setSubmenu_(appmenu.autorelease()); + } + { + let editmenuitem = StrongRef::new(cocoa::NSMenuItem::alloc()).autorelease(); + editmenuitem.initWithTitle_action_keyEquivalent_( + nsstring("Edit"), + runtime::Sel::from_ptr(std::ptr::null()), + nsstring(""), + ); + mainmenu.addItem_(editmenuitem); + + let editmenu = StrongRef::new(cocoa::NSMenu::alloc()); + editmenu.initWithTitle_(nsstring("Edit")); + + let add_item = |name, selector, shortcut| { + let item = StrongRef::new(cocoa::NSMenuItem::alloc()); + item.initWithTitle_action_keyEquivalent_( + nsstring(name), + selector, + nsstring(shortcut), + ); + editmenu.addItem_(item.autorelease()); + }; + + add_item("Undo", sel!(undo:), "z"); + add_item("Redo", sel!(redo:), "Z"); + editmenu.addItem_(cocoa::NSMenuItem::separatorItem()); + add_item("Cut", sel!(cut:), "x"); + add_item("Copy", sel!(copy:), "c"); + add_item("Paste", sel!(paste:), "v"); + add_item("Delete", sel!(delete:), ""); + add_item("Select All", sel!(selectAll:), "a"); + + editmenuitem.setSubmenu_(editmenu.autorelease()); + } + + nsapp.setMainMenu_(mainmenu.autorelease()); + } + + // Run the main application loop + unsafe { nsapp.run() }; + }); + } + + pub fn invoke(&self, f: model::InvokeFn) { + // Blocks only take `Fn`, so we have to wrap the boxed function. + let f = std::cell::RefCell::new(Some(f)); + enqueue(move || { + if let Some(f) = f.borrow_mut().take() { + f(); + } + }); + } +} + +fn enqueue<F: Fn() + 'static>(f: F) { + let block = block::ConcreteBlock::new(f); + // The block must be an RcBlock so addOperationWithBlock can retain it. + // https://docs.rs/block/latest/block/#creating-blocks + let block = block.copy(); + + // We need to explicitly signal that the enqueued blocks can run in both the default mode (the + // main loop) and modal mode, otherwise when modal windows are opened things get stuck. + struct RunloopModes(cocoa::NSArray); + + impl RunloopModes { + pub fn new() -> Self { + unsafe { + let objects: [cocoa::id; 2] = [ + cocoa::NSDefaultRunLoopMode.0, + cocoa::NSModalPanelRunLoopMode.0, + ]; + RunloopModes( + cocoa::NSArray(<cocoa::NSArray as NSArray_NSArrayCreation< + cocoa::NSRunLoopMode, + >>::arrayWithObjects_count_( + objects.as_slice().as_ptr() as *const *mut u64, + objects + .as_slice() + .len() + .try_into() + .expect("usize can't fit in u64"), + )), + ) + } + } + } + + // # Safety + // The array is static and cannot be changed. + unsafe impl Sync for RunloopModes {} + unsafe impl Send for RunloopModes {} + + static RUNLOOP_MODES: Lazy<RunloopModes> = Lazy::new(RunloopModes::new); + + unsafe { + cocoa::NSRunLoop::mainRunLoop().performInModes_block_(RUNLOOP_MODES.0, &*block); + } +} + +#[repr(transparent)] +struct Rect(pub cocoa::NSRect); + +unsafe impl Encode for Rect { + fn encode() -> Encoding { + unsafe { Encoding::from_str("{CGRect={CGPoint=dd}{CGSize=dd}}") } + } +} + +/// Create an NSString by copying a str. +fn nsstring(v: &str) -> cocoa::NSString { + unsafe { + StrongRef::new(cocoa::NSString( + cocoa::NSString::alloc().initWithBytes_length_encoding_( + v.as_ptr() as *const _, + v.len().try_into().expect("usize can't fit in u64"), + NSUTF8StringEncoding, + ), + )) + } + .autorelease() +} + +/// Create a String by copying an NSString +fn read_nsstring(s: cocoa::NSString) -> String { + let c_str = unsafe { std::ffi::CStr::from_ptr(s.UTF8String()) }; + c_str.to_str().expect("NSString isn't UTF8").to_owned() +} + +fn nsrect<X: Into<f64>, Y: Into<f64>, W: Into<f64>, H: Into<f64>>( + x: X, + y: Y, + w: W, + h: H, +) -> cocoa::NSRect { + cocoa::NSRect { + origin: cocoa::NSPoint { + x: x.into(), + y: y.into(), + }, + size: cocoa::NSSize { + width: w.into(), + height: h.into(), + }, + } +} + +struct AppDelegate { + app: Option<Application>, + windows: Vec<StrongRef<cocoa::NSWindow>>, +} + +impl AppDelegate { + pub fn new(app: Application) -> Self { + AppDelegate { + app: Some(app), + windows: Default::default(), + } + } +} + +objc_class! { + impl AppDelegate: NSObject /*<NSApplicationDelegate>*/ { + #[sel(applicationDidFinishLaunching:)] + fn application_did_finish_launching(&mut self, _notification: Ptr<cocoa::NSNotification>) { + // Activate the application (bringing windows to the active foreground later) + unsafe { cocoa::NSApplication::sharedApplication().activateIgnoringOtherApps_(runtime::YES) }; + + let mut first = true; + let mut windows = WindowRenderer::default(); + let app = self.app.take().unwrap(); + windows.rtl = app.rtl; + for window in app.windows { + let w = windows.render(window); + unsafe { + if first { + w.makeKeyAndOrderFront_(self.instance); + w.makeMainWindow(); + first = false; + } + } + } + self.windows = windows.unwrap(); + + } + + #[sel(applicationShouldTerminateAfterLastWindowClosed:)] + fn application_should_terminate_after_window_closed(&mut self, _app: Ptr<cocoa::NSApplication>) -> runtime::BOOL { + runtime::YES + } + } +} + +struct Window { + modal: bool, + title: String, + style: model::ElementStyle, +} + +objc_class! { + impl Window: NSWindow /*<NSWindowDelegate>*/ { + #[sel(init)] + fn init(&mut self) -> cocoa::id { + let style = &self.style; + let title = &self.title; + let w = cocoa::NSWindow(self.instance); + + unsafe { + if w.initWithContentRect_styleMask_backing_defer_( + nsrect( + 0, + 0, + style.horizontal_size_request.unwrap_or(800), + style.vertical_size_request.unwrap_or(500), + ), + cocoa::NSWindowStyleMaskTitled + | cocoa::NSWindowStyleMaskClosable + | cocoa::NSWindowStyleMaskResizable + | cocoa::NSWindowStyleMaskMiniaturizable, + cocoa::NSBackingStoreBuffered, + runtime::NO, + ).is_null() { + return std::ptr::null_mut(); + } + + w.setDelegate_(self.instance as _); + w.setMinSize_(cocoa::NSSize { + width: style.horizontal_size_request.unwrap_or(0) as f64, + height: style.vertical_size_request.unwrap_or(0) as f64, + }); + + if !title.is_empty() { + w.setTitle_(nsstring(title.as_str())); + } + } + + self.instance + } + + #[sel(windowWillClose:)] + fn window_will_close(&mut self, _notification: Ptr<cocoa::NSNotification>) { + if self.modal { + unsafe { + let nsapp = cocoa::NSApplication::sharedApplication(); + nsapp.stopModal(); + } + } + } + } +} + +impl From<Objc<Window>> for cocoa::NSWindow { + fn from(ptr: Objc<Window>) -> Self { + cocoa::NSWindow(ptr.instance) + } +} + +struct Button { + element: model::Button, +} + +impl Button { + pub fn with_title(self, title: &str) -> cocoa::NSButton { + let obj = self.into_object(); + unsafe { + let () = msg_send![obj.instance, setTitle: nsstring(title)]; + } + // # Safety + // NSButton is the superclass of Objc<Button>. + unsafe { std::mem::transmute(obj.autorelease()) } + } +} + +objc_class! { + impl Button: NSButton { + #[sel(initWithFrame:)] + fn init_with_frame(&mut self, frame_rect: Rect) -> cocoa::id { + unsafe { + let object: cocoa::id = msg_send![super(self.instance, class!(NSButton)), initWithFrame: frame_rect.0]; + if object.is_null() { + return object; + } + let () = msg_send![object, setBezelStyle: cocoa::NSBezelStyleRounded]; + let () = msg_send![object, setAction: sel!(didClick)]; + let () = msg_send![object, setTarget: object]; + object + } + } + + #[sel(didClick)] + fn did_click(&mut self) { + self.element.click.fire(&()); + } + } +} + +struct Checkbox { + element: model::Checkbox, +} + +objc_class! { + impl Checkbox: NSButton { + #[sel(initWithFrame:)] + fn init_with_frame(&mut self, frame_rect: Rect) -> cocoa::id { + unsafe { + let object: cocoa::id = msg_send![super(self.instance, class!(NSButton)), initWithFrame: frame_rect.0]; + if object.is_null() { + return object; + } + let () = msg_send![object, setButtonType: cocoa::NSButtonTypeSwitch]; + if let Some(label) = &self.element.label { + let () = msg_send![object, setTitle: nsstring(label.as_str())]; + } + let () = msg_send![object, setAction: sel!(didClick:)]; + let () = msg_send![object, setTarget: object]; + + match &self.element.checked { + Property::Binding(s) => { + if *s.borrow() { + let () = msg_send![object, setState: NSControlStateValueOn]; + } + } + Property::ReadOnly(_) => (), + Property::Static(_) => (), + } + + object + } + } + + #[sel(didClick:)] + fn did_click(&mut self, button: Objc<Checkbox>) { + match &self.element.checked { + Property::Binding(s) => { + let state = unsafe { std::mem::transmute::<_, cocoa::NSButton>(button).state() }; + *s.borrow_mut() = state == NSControlStateValueOn; + } + Property::ReadOnly(_) => (), + Property::Static(_) => (), + } + } + } +} + +impl Checkbox { + pub fn into_button(self) -> cocoa::NSButton { + let obj = self.into_object(); + // # Safety + // NSButton is the superclass of Objc<Checkbox>. + unsafe { std::mem::transmute(obj.autorelease()) } + } +} + +struct TextView; + +objc_class! { + impl TextView: NSTextView /*<NSTextViewDelegate>*/ { + #[sel(initWithFrame:)] + fn init_with_frame(&mut self, frame_rect: Rect) -> cocoa::id { + unsafe { + let object: cocoa::id = msg_send![super(self.instance, class!(NSTextView)), initWithFrame: frame_rect.0]; + if object.is_null() { + return object; + } + let () = msg_send![object, setDelegate: self.instance]; + object + } + } + + #[sel(textView:doCommandBySelector:)] + fn do_command_by_selector(&mut self, text_view: Ptr<cocoa::NSTextView>, selector: runtime::Sel) -> runtime::BOOL { + let Ptr(text_view) = text_view; + // Make Tab/Backtab navigate to key views rather than inserting tabs in the text view. + // We can't use the `NSText` `fieldEditor` property to implement this behavior because + // that will disable the Enter key. + if selector == sel!(insertTab:) { + unsafe { text_view.window().selectNextKeyView_(text_view.0) }; + return runtime::YES; + } else if selector == sel!(insertBacktab:) { + unsafe { text_view.window().selectPreviousKeyView_(text_view.0) }; + return runtime::YES; + } + runtime::NO + } + } +} + +impl From<Objc<TextView>> for cocoa::NSTextView { + fn from(tv: Objc<TextView>) -> Self { + // # Safety + // NSTextView is the superclass of Objc<TextView>. + unsafe { std::mem::transmute(tv) } + } +} + +// For some reason the bindgen code for the nslayoutanchor subclasses doesn't have +// `Into<NSLayoutAnchor>`, so we add our own. +trait IntoNSLayoutAnchor { + fn into_layout_anchor(self) -> cocoa::NSLayoutAnchor; +} + +impl IntoNSLayoutAnchor for cocoa::NSLayoutXAxisAnchor { + fn into_layout_anchor(self) -> cocoa::NSLayoutAnchor { + // # Safety + // NSLayoutXAxisAnchor is a subclass of NSLayoutAnchor + cocoa::NSLayoutAnchor(self.0) + } +} + +impl IntoNSLayoutAnchor for cocoa::NSLayoutYAxisAnchor { + fn into_layout_anchor(self) -> cocoa::NSLayoutAnchor { + // # Safety + // NSLayoutYAxisAnchor is a subclass of NSLayoutAnchor + cocoa::NSLayoutAnchor(self.0) + } +} + +unsafe fn constraint_equal<T, O>(anchor: T, to: O) +where + T: INSLayoutAnchor<()> + std::ops::Deref, + T::Target: Message + Sized, + O: IntoNSLayoutAnchor, +{ + anchor + .constraintEqualToAnchor_(to.into_layout_anchor()) + .setActive_(runtime::YES); +} + +#[derive(Default)] +struct WindowRenderer { + windows_to_retain: Vec<StrongRef<cocoa::NSWindow>>, + rtl: bool, +} + +impl WindowRenderer { + pub fn unwrap(self) -> Vec<StrongRef<cocoa::NSWindow>> { + self.windows_to_retain + } + + pub fn render(&mut self, window: TypedElement<model::Window>) -> StrongRef<cocoa::NSWindow> { + let style = window.style; + let model::Window { + close, + children, + content, + modal, + title, + } = window.element_type; + + let w = Window { + modal, + title, + style, + } + .into_object(); + + let nswindow: StrongRef<cocoa::NSWindow> = w.clone().cast(); + + unsafe { + // Don't release windows when closed: we retain windows at the top-level. + nswindow.setReleasedWhenClosed_(runtime::NO); + + if let Some(close) = close { + let nswindow = nswindow.weak(); + close.subscribe(move |&()| { + if let Some(nswindow) = nswindow.lock() { + nswindow.close(); + } + }); + } + + if let Some(e) = content { + // Use an NSBox as a container view so that the window's content can easily have + // constraints set up relative to the parent (they can't be set relative to the + // window). + let content_parent: StrongRef<cocoa::NSBox> = msg_send![class!(NSBox), new]; + content_parent.setTitlePosition_(cocoa::NSNoTitle); + content_parent.setTransparent_(runtime::YES); + content_parent.setContentViewMargins_(cocoa::NSSize { + width: 5.0, + height: 5.0, + }); + if ViewRenderer::new_with_selector(self.rtl, *content_parent, sel!(setContentView:)) + .render(*e) + { + nswindow.setContentView_((*content_parent).into()); + } + } + + for child in children { + let modal = child.element_type.modal; + let visible = child.style.visible.clone(); + let child_window = self.render(child); + + #[derive(Clone, Copy)] + struct ShowChild { + modal: bool, + } + + impl ShowChild { + pub fn show(&self, parent: cocoa::NSWindow, child: cocoa::NSWindow) { + unsafe { + parent.addChildWindow_ordered_(child, cocoa::NSWindowAbove); + child.makeKeyAndOrderFront_(parent.0); + if self.modal { + // Run the modal from the main nsapp.run() loop to prevent binding + // updates from being nested (as this will block until the modal is + // stopped). + enqueue(move || { + let nsapp = cocoa::NSApplication::sharedApplication(); + nsapp.runModalForWindow_(child); + }); + } + } + } + } + + let show_child = ShowChild { modal }; + + match visible { + Property::Static(visible) => { + if visible { + show_child.show(*nswindow, *child_window); + } + } + Property::Binding(b) => { + let child = child_window.weak(); + let parent = nswindow.weak(); + b.on_change(move |visible| { + let Some((w, child_window)) = parent.lock().zip(child.lock()) else { + return; + }; + if *visible { + show_child.show(*w, *child_window); + } else { + child_window.close(); + } + }); + if *b.borrow() { + show_child.show(*nswindow, *child_window); + } + } + Property::ReadOnly(_) => panic!("window visibility cannot be ReadOnly"), + } + } + } + self.windows_to_retain.push(nswindow.clone()); + nswindow + } +} + +struct ViewRenderer { + parent: cocoa::NSView, + add_subview: Box<dyn Fn(cocoa::NSView, &model::ElementStyle, cocoa::NSView)>, + ignore_vertical: bool, + ignore_horizontal: bool, + rtl: bool, +} + +impl ViewRenderer { + /// add_subview should add the rendered child view. + pub fn new<F>(rtl: bool, parent: impl Into<cocoa::NSView>, add_subview: F) -> Self + where + F: Fn(cocoa::NSView, &model::ElementStyle, cocoa::NSView) + 'static, + { + ViewRenderer { + parent: parent.into(), + add_subview: Box::new(add_subview), + ignore_vertical: false, + ignore_horizontal: false, + rtl, + } + } + + /// add_subview should be the selector to call on the parent view to add the rendered child view. + pub fn new_with_selector( + rtl: bool, + parent: impl Into<cocoa::NSView>, + add_subview: runtime::Sel, + ) -> Self { + Self::new(rtl, parent, move |parent, _style, child| { + let () = unsafe { (*parent.0).send_message(add_subview, (child,)) }.unwrap(); + }) + } + + /// Ignore vertical layout settings when rendering views. + pub fn ignore_vertical(mut self, setting: bool) -> Self { + self.ignore_vertical = setting; + self + } + + /// Ignore horizontal layout settings when rendering views. + pub fn ignore_horizontal(mut self, setting: bool) -> Self { + self.ignore_horizontal = setting; + self + } + + /// Render the given element. + /// + /// Returns whether the element was rendered. + pub fn render( + &self, + Element { + style, + element_type, + }: Element, + ) -> bool { + let Some(view) = render_element(element_type, &style, self.rtl) else { + return false; + }; + + (self.add_subview)(self.parent, &style, view); + + // Setting the content hugging priority to a high value causes stackviews to not stretch + // subviews during autolayout. + unsafe { + view.setContentHuggingPriority_forOrientation_( + NSLayoutPriorityDefaultHigh, + cocoa::NSLayoutConstraintOrientationHorizontal, + ); + view.setContentHuggingPriority_forOrientation_( + NSLayoutPriorityDefaultHigh, + cocoa::NSLayoutConstraintOrientationVertical, + ); + } + + // Set layout and writing direction based on RTL. + unsafe { + view.setUserInterfaceLayoutDirection_(if self.rtl { + cocoa::NSUserInterfaceLayoutDirectionRightToLeft + } else { + cocoa::NSUserInterfaceLayoutDirectionLeftToRight + }); + if let Ok(control) = cocoa::NSControl::try_from(view) { + control.setBaseWritingDirection_(if self.rtl { + cocoa::NSWritingDirectionRightToLeft + } else { + cocoa::NSWritingDirectionLeftToRight + }); + } + } + + let lmg = unsafe { self.parent.layoutMarginsGuide() }; + + if !matches!(style.horizontal_alignment, Alignment::Fill) { + if let Some(size) = style.horizontal_size_request { + unsafe { + view.widthAnchor() + .constraintGreaterThanOrEqualToConstant_(size as _) + .setActive_(runtime::YES); + } + } + } + + if !self.ignore_horizontal { + unsafe { + let la = view.leadingAnchor(); + let ta = view.trailingAnchor(); + let pla = lmg.leadingAnchor(); + let pta = lmg.trailingAnchor(); + match style.horizontal_alignment { + Alignment::Fill => { + constraint_equal(la, pla); + constraint_equal(ta, pta); + // Without the autoresizing mask set, Text within Scroll doesn't display + // properly (it shrinks to 0-width, likely due to some specific interaction + // of NSScrollView with autolayout). + view.setAutoresizingMask_(cocoa::NSViewWidthSizable); + } + Alignment::Start => { + constraint_equal(la, pla); + } + Alignment::Center => { + let ca = view.centerXAnchor(); + let pca = lmg.centerXAnchor(); + constraint_equal(ca, pca); + } + Alignment::End => { + constraint_equal(ta, pta); + } + } + } + } + + if !matches!(style.vertical_alignment, Alignment::Fill) { + if let Some(size) = style.vertical_size_request { + unsafe { + view.heightAnchor() + .constraintGreaterThanOrEqualToConstant_(size as _) + .setActive_(runtime::YES); + } + } + } + + if !self.ignore_vertical { + unsafe { + let ta = view.topAnchor(); + let ba = view.bottomAnchor(); + let pta = lmg.topAnchor(); + let pba = lmg.bottomAnchor(); + match style.vertical_alignment { + Alignment::Fill => { + constraint_equal(ta, pta); + constraint_equal(ba, pba); + // Set the autoresizing mask to be consistent with the horizontal settings + // (see the comment there as to why it's necessary). + view.setAutoresizingMask_(cocoa::NSViewHeightSizable); + } + Alignment::Start => { + constraint_equal(ta, pta); + } + Alignment::Center => { + let ca = view.centerYAnchor(); + let pca = lmg.centerYAnchor(); + constraint_equal(ca, pca); + } + Alignment::End => { + constraint_equal(ba, pba); + } + } + } + } + + match &style.visible { + Property::Static(ref v) => { + unsafe { view.setHidden_((!v).into()) }; + } + Property::Binding(b) => { + b.on_change(move |&visible| unsafe { + view.setHidden_((!visible).into()); + }); + unsafe { view.setHidden_((!*b.borrow()).into()) }; + } + Property::ReadOnly(_) => { + unimplemented!("ElementStyle::visible doesn't support ReadOnly") + } + } + + if let Ok(control) = cocoa::NSControl::try_from(view) { + match &style.enabled { + Property::Static(e) => { + unsafe { control.setEnabled_((*e).into()) }; + } + Property::Binding(b) => { + b.on_change(move |&enabled| unsafe { + control.setEnabled_(enabled.into()); + }); + unsafe { control.setEnabled_((*b.borrow()).into()) }; + } + Property::ReadOnly(_) => { + unimplemented!("ElementStyle::enabled doesn't support ReadOnly") + } + } + } else if let Ok(text) = cocoa::NSText::try_from(view) { + let normally_editable = unsafe { text.isEditable() } == runtime::YES; + let normally_selectable = unsafe { text.isSelectable() } == runtime::YES; + let set_enabled = move |enabled: bool| unsafe { + if !enabled { + let mut range = text.selectedRange(); + range.length = 0; + text.setSelectedRange_(range); + } + text.setEditable_((enabled && normally_editable).into()); + text.setSelectable_((enabled && normally_selectable).into()); + text.setBackgroundColor_(if enabled { + cocoa::NSColor::textBackgroundColor() + } else { + cocoa::NSColor::windowBackgroundColor() + }); + text.setTextColor_(if enabled { + cocoa::NSColor::textColor() + } else { + cocoa::NSColor::disabledControlTextColor() + }); + }; + match &style.enabled { + Property::Static(e) => set_enabled(*e), + Property::Binding(b) => { + b.on_change(move |&enabled| set_enabled(enabled)); + set_enabled(*b.borrow()); + } + Property::ReadOnly(_) => { + unimplemented!("ElementStyle::enabled doesn't support ReadOnly") + } + } + } + + unsafe { view.setNeedsDisplay_(runtime::YES) }; + + true + } +} + +fn render_element( + element_type: model::ElementType, + style: &model::ElementStyle, + rtl: bool, +) -> Option<cocoa::NSView> { + use model::ElementType::*; + Some(match element_type { + VBox(model::VBox { items, spacing }) => { + let sv = unsafe { StrongRef::new(cocoa::NSStackView::alloc()) }.autorelease(); + unsafe { + sv.init(); + sv.setOrientation_(cocoa::NSUserInterfaceLayoutOrientationVertical); + sv.setAlignment_(cocoa::NSLayoutAttributeLeading); + sv.setSpacing_(spacing as _); + if style.vertical_alignment != Alignment::Fill { + // Make sure the vbox stays as small as its content. + sv.setHuggingPriority_forOrientation_( + NSLayoutPriorityDefaultHigh, + cocoa::NSLayoutConstraintOrientationVertical, + ); + } + } + let renderer = ViewRenderer::new(rtl, sv, |parent, style, child| { + let gravity: cocoa::NSInteger = match style.vertical_alignment { + Alignment::Start | Alignment::Fill => 1, + Alignment::Center => 2, + Alignment::End => 3, + }; + let parent: cocoa::NSStackView = parent.try_into().unwrap(); + unsafe { parent.addView_inGravity_(child, gravity) }; + }) + .ignore_vertical(true); + for item in items { + renderer.render(item); + } + sv.into() + } + HBox(model::HBox { + mut items, + spacing, + affirmative_order, + }) => { + if affirmative_order { + items.reverse(); + } + let sv = unsafe { StrongRef::new(cocoa::NSStackView::alloc()) }.autorelease(); + unsafe { + sv.init(); + sv.setOrientation_(cocoa::NSUserInterfaceLayoutOrientationHorizontal); + sv.setAlignment_(cocoa::NSLayoutAttributeTop); + sv.setSpacing_(spacing as _); + if style.horizontal_alignment != Alignment::Fill { + // Make sure the hbox stays as small as its content. + sv.setHuggingPriority_forOrientation_( + NSLayoutPriorityDefaultHigh, + cocoa::NSLayoutConstraintOrientationHorizontal, + ); + } + } + let renderer = ViewRenderer::new(rtl, sv, |parent, style, child| { + let gravity: cocoa::NSInteger = match style.horizontal_alignment { + Alignment::Start | Alignment::Fill => 1, + Alignment::Center => 2, + Alignment::End => 3, + }; + let parent: cocoa::NSStackView = parent.try_into().unwrap(); + unsafe { parent.addView_inGravity_(child, gravity) }; + }) + .ignore_horizontal(true); + for item in items { + renderer.render(item); + } + sv.into() + } + Button(mut b) => { + if let Some(Label(model::Label { + text: Property::Static(text), + .. + })) = b.content.take().map(|e| e.element_type) + { + let button = self::Button { element: b }.with_title(text.as_str()); + button.into() + } else { + return None; + } + } + Checkbox(cb) => { + let button = self::Checkbox { element: cb }.into_button(); + button.into() + } + Label(model::Label { text, bold }) => { + let tf = cocoa::NSTextField(unsafe { + cocoa::NSTextField::wrappingLabelWithString_(nsstring("")) + }); + unsafe { tf.setSelectable_(runtime::NO) }; + if bold { + unsafe { tf.setFont_(cocoa::NSFont::boldSystemFontOfSize_(0.0)) }; + } + match text { + Property::Static(text) => { + unsafe { tf.setStringValue_(nsstring(text.as_str())) }; + } + Property::Binding(b) => { + unsafe { tf.setStringValue_(nsstring(b.borrow().as_str())) }; + b.on_change(move |s| unsafe { tf.setStringValue_(nsstring(s)) }); + } + Property::ReadOnly(_) => unimplemented!("ReadOnly not supported for Label::text"), + } + tf.into() + } + Progress(model::Progress { amount }) => { + fn update(progress: cocoa::NSProgressIndicator, value: Option<f32>) { + unsafe { + match value { + None => { + progress.setIndeterminate_(runtime::YES); + progress.startAnimation_(progress.0); + } + Some(v) => { + progress.setDoubleValue_(v as f64); + progress.setIndeterminate_(runtime::NO); + } + } + } + } + + let progress = unsafe { StrongRef::new(cocoa::NSProgressIndicator::alloc()) }; + unsafe { + progress.init(); + progress.setMinValue_(0.0); + progress.setMaxValue_(1.0); + } + match amount { + Property::Static(v) => update(*progress, v), + Property::Binding(s) => { + update(*progress, *s.borrow()); + let weak = progress.weak(); + s.on_change(move |v| { + if let Some(r) = weak.lock() { + update(*r, *v); + } + }); + } + Property::ReadOnly(_) => (), + } + progress.autorelease().into() + } + Scroll(model::Scroll { content }) => { + let sv = unsafe { StrongRef::new(cocoa::NSScrollView::alloc()) }.autorelease(); + unsafe { + sv.init(); + sv.setHasVerticalScroller_(runtime::YES); + } + if let Some(content) = content { + ViewRenderer::new_with_selector(rtl, sv, sel!(setDocumentView:)) + .ignore_vertical(true) + .render(*content); + } + sv.into() + } + TextBox(model::TextBox { + placeholder, + content, + editable, + }) => { + let tv: StrongRef<cocoa::NSTextView> = TextView.into_object().cast(); + unsafe { + tv.setEditable_(editable.into()); + + cocoa::NSTextView_NSSharing::setAllowsUndo_(&*tv, runtime::YES); + tv.setVerticallyResizable_(runtime::YES); + if rtl { + let ps = StrongRef::new(cocoa::NSMutableParagraphStyle::alloc()); + ps.init(); + ps.setAlignment_(cocoa::NSTextAlignmentRight); + // We don't `use cocoa::NSTextView_NSSharing` because it has some methods which + // conflict with others that make it inconvenient. + cocoa::NSTextView_NSSharing::setDefaultParagraphStyle_(&*tv, (*ps).into()); + } + { + let container = tv.textContainer(); + container.setSize_(cocoa::NSSize { + width: f64::MAX, + height: f64::MAX, + }); + container.setWidthTracksTextView_(runtime::YES); + } + if let Some(placeholder) = placeholder { + // It's unclear why dictionaryWithObject_forKey_ takes `u64` rather than `id` + // arguments. + let attrs = cocoa::NSDictionary( + <cocoa::NSDictionary as NSDictionary_NSDictionaryCreation< + cocoa::NSAttributedStringKey, + cocoa::id, + >>::dictionaryWithObject_forKey_( + cocoa::NSColor::placeholderTextColor().0 as u64, + cocoa::NSForegroundColorAttributeName.0 as u64, + ), + ); + let string = StrongRef::new(cocoa::NSAttributedString( + cocoa::NSAttributedString::alloc() + .initWithString_attributes_(nsstring(placeholder.as_str()), attrs), + )); + // XXX: `setPlaceholderAttributedString` is undocumented (discovered at + // https://stackoverflow.com/a/43028577 and works identically to NSTextField), + // though hopefully it will be exposed in a public API some day. + tv.performSelector_withObject_(sel!(setPlaceholderAttributedString:), string.0); + } + } + match content { + Property::Static(s) => unsafe { tv.setString_(nsstring(s.as_str())) }, + Property::ReadOnly(od) => { + let weak = tv.weak(); + od.register(move |s| { + if let Some(tv) = weak.lock() { + *s = read_nsstring(unsafe { tv.string() }); + } + }); + } + Property::Binding(b) => { + let weak = tv.weak(); + b.on_change(move |s| { + if let Some(tv) = weak.lock() { + unsafe { tv.setString_(nsstring(s.as_str())) }; + } + }); + unsafe { tv.setString_(nsstring(b.borrow().as_str())) }; + } + } + tv.autorelease().into() + } + }) +} diff --git a/toolkit/crashreporter/client/app/src/ui/macos/objc.rs b/toolkit/crashreporter/client/app/src/ui/macos/objc.rs new file mode 100644 index 0000000000..d4f3f1c419 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/macos/objc.rs @@ -0,0 +1,242 @@ +/* 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/. */ + +//! Objective-C bindings and helpers. + +// Forward all exports from the `objc` crate. +pub use objc::*; + +/// An objc class instance which contains rust data `T`. +#[derive(Clone, Copy)] +#[repr(transparent)] +pub struct Objc<T> { + pub instance: cocoa::id, + _phantom: std::marker::PhantomData<*mut T>, +} + +impl<T> Objc<T> { + pub fn new(instance: cocoa::id) -> Self { + Objc { + instance, + _phantom: std::marker::PhantomData, + } + } + + pub fn data(&self) -> &T { + let data = *unsafe { (*self.instance).get_ivar::<usize>("rust_self") } as *mut T; + unsafe { &*data } + } + + pub fn data_mut(&mut self) -> &mut T { + let data = *unsafe { (*self.instance).get_ivar::<usize>("rust_self") } as *mut T; + unsafe { &mut *data } + } +} + +impl<T> std::ops::Deref for Objc<T> { + type Target = T; + fn deref(&self) -> &Self::Target { + self.data() + } +} + +impl<T> std::ops::DerefMut for Objc<T> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.data_mut() + } +} + +unsafe impl<T> Encode for Objc<T> { + fn encode() -> Encoding { + cocoa::id::encode() + } +} + +/// Wrapper to provide `Encode` for bindgen-generated types (bindgen should do this in the future). +#[repr(transparent)] +pub struct Ptr<T>(pub T); + +unsafe impl<T> Encode for Ptr<T> { + fn encode() -> Encoding { + cocoa::id::encode() + } +} + +/// A strong objective-c reference to `T`. +#[repr(transparent)] +pub struct StrongRef<T> { + ptr: rc::StrongPtr, + _phantom: std::marker::PhantomData<T>, +} + +impl<T> Clone for StrongRef<T> { + fn clone(&self) -> Self { + StrongRef { + ptr: self.ptr.clone(), + _phantom: self._phantom, + } + } +} + +impl<T> std::ops::Deref for StrongRef<T> { + type Target = T; + fn deref(&self) -> &Self::Target { + let obj: &cocoa::id = &*self.ptr; + unsafe { std::mem::transmute(obj) } + } +} + +impl<T> StrongRef<T> { + /// Assume the given pointer-wrapper is an already-retained strong reference. + /// + /// # Safety + /// The type _must_ be the same size as cocoa::id and contain only a cocoa::id. + pub unsafe fn new(v: T) -> Self { + std::mem::transmute_copy(&v) + } + + /// Retain the given pointer-wrapper. + /// + /// # Safety + /// The type _must_ be the same size as cocoa::id and contain only a cocoa::id. + #[allow(dead_code)] + pub unsafe fn retain(v: T) -> Self { + let obj: cocoa::id = std::mem::transmute_copy(&v); + StrongRef { + ptr: rc::StrongPtr::retain(obj), + _phantom: std::marker::PhantomData, + } + } + + pub fn autorelease(self) -> T { + let obj = self.ptr.autorelease(); + unsafe { std::mem::transmute_copy(&obj) } + } + + pub fn weak(&self) -> WeakRef<T> { + WeakRef { + ptr: self.ptr.weak(), + _phantom: std::marker::PhantomData, + } + } + + /// Unwrap the StrongRef value without affecting reference counts. + /// + /// This is the opposite of `new`. + #[allow(dead_code)] + pub fn unwrap(self: Self) -> T { + let v = unsafe { std::mem::transmute_copy(&self) }; + std::mem::forget(self); + v + } + + /// Cast to a base class. + /// + /// Bindgen pointer-wrappers have trival `From<Derived> for Base` implementations. + pub fn cast<U: From<T>>(self) -> StrongRef<U> { + StrongRef { + ptr: self.ptr, + _phantom: std::marker::PhantomData, + } + } +} + +/// A weak objective-c reference to `T`. +#[derive(Clone)] +#[repr(transparent)] +pub struct WeakRef<T> { + ptr: rc::WeakPtr, + _phantom: std::marker::PhantomData<T>, +} + +impl<T> WeakRef<T> { + pub fn lock(&self) -> Option<StrongRef<T>> { + let ptr = self.ptr.load(); + if ptr.is_null() { + None + } else { + Some(StrongRef { + ptr, + _phantom: std::marker::PhantomData, + }) + } + } +} + +/// A macro for creating an objc class. +/// +/// Classes _must_ be registered before use (`Objc<T>::register()`). +/// +/// Example: +/// ``` +/// struct Foo(u8); +/// +/// objc_class! { +/// impl Foo: NSObject { +/// #[sel(mySelector:)] +/// fn my_selector(&mut self, arg: u8) -> u8 { +/// self.0 + arg +/// } +/// } +/// } +/// +/// fn make_foo() -> StrongRef<Objc<Foo>> { +/// Foo(42).into_object() +/// } +/// ``` +/// +/// Call `T::into_object()` to create the objective-c class instance. +macro_rules! objc_class { + ( impl $name:ident : $base:ident $(<$($protocol:ident),+>)? { + $( + #[sel($($sel:tt)+)] + fn $mname:ident (&mut $self:ident $(, $argname:ident : $argtype:ty )*) $(-> $rettype:ty)? $body:block + )* + }) => { + impl Objc<$name> { + pub fn register() { + let mut decl = declare::ClassDecl::new(concat!("CR", stringify!($name)), class!($base)).expect(concat!("failed to declare ", stringify!($name), " class")); + $($(decl.add_protocol(runtime::Protocol::get(stringify!($protocol)).expect(concat!("failed to find ",stringify!($protocol)," protocol")));)+)? + decl.add_ivar::<usize>("rust_self"); + $({ + extern fn method_impl(obj: &mut runtime::Object, _: runtime::Sel $(, $argname: $argtype )*) $(-> $rettype)? { + Objc::<$name>::new(obj).$mname($($argname),*) + } + unsafe { + decl.add_method(sel!($($sel)+), method_impl as extern fn(&mut runtime::Object, runtime::Sel $(, $argname: $argtype )*) $(-> $rettype)?); + } + })* + { + extern fn dealloc_impl(obj: &runtime::Object, _: runtime::Sel) { + drop(unsafe { Box::from_raw(*obj.get_ivar::<usize>("rust_self") as *mut $name) }); + unsafe { + let _: () = msg_send![super(obj, class!(NSObject)), dealloc]; + } + } + unsafe { + decl.add_method(sel!(dealloc), dealloc_impl as extern fn(&runtime::Object, runtime::Sel)); + } + } + decl.register(); + } + + pub fn class() -> &'static runtime::Class { + runtime::Class::get(concat!("CR", stringify!($name))).expect("class not registered") + } + + $(fn $mname (&mut $self $(, $argname : $argtype )*) $(-> $rettype)? $body)* + } + + impl $name { + pub fn into_object(self) -> StrongRef<Objc<$name>> { + let obj: *mut runtime::Object = unsafe { msg_send![Objc::<Self>::class(), alloc] }; + unsafe { (*obj).set_ivar("rust_self", Box::into_raw(Box::new(self)) as usize) }; + let obj: *mut runtime::Object = unsafe { msg_send![obj, init] }; + unsafe { StrongRef::new(Objc::new(obj)) } + } + } + } +} + +pub(crate) use objc_class; diff --git a/toolkit/crashreporter/client/app/src/ui/macos/plist.rs b/toolkit/crashreporter/client/app/src/ui/macos/plist.rs new file mode 100644 index 0000000000..a5bbe0aa0a --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/macos/plist.rs @@ -0,0 +1,44 @@ +/* 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/. */ + +//! The embedded Info.plist file. + +const DATA: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDevelopmentRegion</key> + <string>English</string> + <key>CFBundleDisplayName</key> + <string>Crash Reporter</string> + <key>CFBundleExecutable</key> + <string>crashreporter</string> + <key>CFBundleIdentifier</key> + <string>org.mozilla.crashreporter</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>Crash Reporter</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleVersion</key> + <string>1.0</string> + <key>LSHasLocalizedDisplayName</key> + <true/> + <key>NSRequiresAquaSystemAppearance</key> + <false/> + <key>NSPrincipalClass</key> + <string>NSApplication</string> +</dict> +</plist>"#; + +const N: usize = DATA.len(); + +const PTR: *const [u8; N] = DATA.as_ptr() as *const [u8; N]; + +#[used] +#[link_section = "__TEXT,__info_plist"] +// # Safety +// The array pointer is created from `DATA` (a slice pointer) with `DATA.len()` as the length. +static PLIST: [u8; N] = unsafe { *PTR }; diff --git a/toolkit/crashreporter/client/app/src/ui/mod.rs b/toolkit/crashreporter/client/app/src/ui/mod.rs new file mode 100644 index 0000000000..8464b6a9b3 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/mod.rs @@ -0,0 +1,295 @@ +/* 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/. */ + +//! The UI model, UI implementations, and functions using them. +//! +//! UIs must implement: +//! * a `fn run_loop(&self, app: model::Application)` method which should display the UI and block while +//! handling events until the application terminates, +//! * a `fn invoke(&self, f: model::InvokeFn)` method which invokes the given function +//! asynchronously (without blocking) on the UI loop thread. + +use crate::std::{rc::Rc, sync::Arc}; +use crate::{ + async_task::AsyncTask, config::Config, data, logic::ReportCrash, settings::Settings, std, + thread_bound::ThreadBound, +}; +use model::{ui, Application}; +use ui_impl::UI; + +mod model; + +#[cfg(all(not(test), any(target_os = "linux", target_os = "windows")))] +mod icon { + // Must be DWORD-aligned for Win32 CreateIconFromResource. + #[repr(align(4))] + struct Aligned<Bytes: ?Sized>(Bytes); + static PNG_DATA_ALIGNMENT: &'static Aligned<[u8]> = + &Aligned(*include_bytes!("crashreporter.png")); + pub static PNG_DATA: &'static [u8] = &PNG_DATA_ALIGNMENT.0; +} + +#[cfg(test)] +pub mod test { + pub mod model { + pub use crate::ui::model::*; + } +} + +cfg_if::cfg_if! { + if #[cfg(test)] { + #[path = "test.rs"] + pub mod ui_impl; + } else if #[cfg(target_os = "linux")] { + #[path = "gtk.rs"] + mod ui_impl; + } else if #[cfg(target_os = "windows")] { + #[path = "windows/mod.rs"] + mod ui_impl; + } else if #[cfg(target_os = "macos")] { + #[path = "macos/mod.rs"] + mod ui_impl; + } else { + mod ui_impl { + #[derive(Default)] + pub struct UI; + + impl UI { + pub fn run_loop(&self, _app: super::model::Application) { + unimplemented!(); + } + + pub fn invoke(&self, _f: super::model::InvokeFn) { + unimplemented!(); + } + } + } + } +} + +/// Display an error dialog with the given message. +#[cfg_attr(mock, allow(unused))] +pub fn error_dialog<M: std::fmt::Display>(config: &Config, message: M) { + let close = data::Event::default(); + // Config may not have localized strings + let string_or = |name, fallback: &str| { + if config.strings.is_none() { + fallback.into() + } else { + config.string(name) + } + }; + + let details = if config.strings.is_none() { + format!("Details: {}", message) + } else { + config + .build_string("crashreporter-error-details") + .arg("details", message.to_string()) + .get() + }; + + let window = ui! { + Window title(string_or("crashreporter-branded-title", "Firefox Crash Reporter")) hsize(600) vsize(400) + close_when(&close) halign(Alignment::Fill) valign(Alignment::Fill) { + VBox margin(10) spacing(10) halign(Alignment::Fill) valign(Alignment::Fill) { + Label text(string_or( + "crashreporter-error", + "The application had a problem and crashed. \ + Unfortunately, the crash reporter is unable to submit a report for the crash." + )), + Label text(details), + Button["close"] halign(Alignment::End) on_click(move || close.fire(&())) { + Label text(string_or("crashreporter-button-close", "Close")) + } + } + } + }; + + UI::default().run_loop(Application { + windows: vec![window], + rtl: config.is_rtl(), + }); +} + +#[derive(Default, Debug, PartialEq, Eq)] +pub enum SubmitState { + #[default] + Initial, + InProgress, + Success, + Failure, +} + +/// The UI for the main crash reporter windows. +pub struct ReportCrashUI { + state: Arc<ThreadBound<ReportCrashUIState>>, + ui: Arc<UI>, + config: Arc<Config>, + logic: Rc<AsyncTask<ReportCrash>>, +} + +/// The state of the creash UI. +pub struct ReportCrashUIState { + pub send_report: data::Synchronized<bool>, + pub include_address: data::Synchronized<bool>, + pub show_details: data::Synchronized<bool>, + pub details: data::Synchronized<String>, + pub comment: data::OnDemand<String>, + pub submit_state: data::Synchronized<SubmitState>, + pub close_window: data::Event<()>, +} + +impl ReportCrashUI { + pub fn new( + initial_settings: &Settings, + config: Arc<Config>, + logic: AsyncTask<ReportCrash>, + ) -> Self { + let send_report = data::Synchronized::new(initial_settings.submit_report); + let include_address = data::Synchronized::new(initial_settings.include_url); + + ReportCrashUI { + state: Arc::new(ThreadBound::new(ReportCrashUIState { + send_report, + include_address, + show_details: Default::default(), + details: Default::default(), + comment: Default::default(), + submit_state: Default::default(), + close_window: Default::default(), + })), + ui: Default::default(), + config, + logic: Rc::new(logic), + } + } + + pub fn async_task(&self) -> AsyncTask<ReportCrashUIState> { + let state = self.state.clone(); + let ui = Arc::downgrade(&self.ui); + AsyncTask::new(move |f| { + let Some(ui) = ui.upgrade() else { return }; + ui.invoke(Box::new(cc! { (state) move || { + f(state.borrow()); + }})); + }) + } + + pub fn run(&self) { + let ReportCrashUI { + state, + ui, + config, + logic, + } = self; + let ReportCrashUIState { + send_report, + include_address, + show_details, + details, + comment, + submit_state, + close_window, + } = state.borrow(); + + send_report.on_change(cc! { (logic) move |v| { + let v = *v; + logic.push(move |s| s.settings.borrow_mut().submit_report = v); + }}); + include_address.on_change(cc! { (logic) move |v| { + let v = *v; + logic.push(move |s| s.settings.borrow_mut().include_url = v); + }}); + + let input_enabled = submit_state.mapped(|s| s == &SubmitState::Initial); + let send_report_and_input_enabled = + data::Synchronized::join(send_report, &input_enabled, |s, e| *s && *e); + + let submit_status_text = submit_state.mapped(cc! { (config) move |s| { + config.string(match s { + SubmitState::Initial => "crashreporter-submit-status", + SubmitState::InProgress => "crashreporter-submit-in-progress", + SubmitState::Success => "crashreporter-submit-success", + SubmitState::Failure => "crashreporter-submit-failure", + }) + }}); + + let progress_visible = submit_state.mapped(|s| s == &SubmitState::InProgress); + + let details_window = ui! { + Window["crash-details-window"] title(config.string("crashreporter-view-report-title")) + visible(show_details) modal(true) hsize(600) vsize(400) + halign(Alignment::Fill) valign(Alignment::Fill) + { + VBox margin(10) spacing(10) halign(Alignment::Fill) valign(Alignment::Fill) { + Scroll halign(Alignment::Fill) valign(Alignment::Fill) { + TextBox["details-text"] content(details) halign(Alignment::Fill) valign(Alignment::Fill) + }, + Button["close-details"] halign(Alignment::End) on_click(cc! { (show_details) move || *show_details.borrow_mut() = false }) { + Label text(config.string("crashreporter-button-ok")) + } + } + } + }; + + let main_window = ui! { + Window title(config.string("crashreporter-branded-title")) hsize(600) vsize(400) + halign(Alignment::Fill) valign(Alignment::Fill) close_when(close_window) + child_window(details_window) + { + VBox margin(10) spacing(10) halign(Alignment::Fill) valign(Alignment::Fill) { + Label text(config.string("crashreporter-apology")) bold(true), + Label text(config.string("crashreporter-crashed-and-restore")), + Label text(config.string("crashreporter-plea")), + Checkbox["send"] checked(send_report) label(config.string("crashreporter-send-report")) + enabled(&input_enabled), + VBox margin_start(20) spacing(5) halign(Alignment::Fill) valign(Alignment::Fill) { + Button["details"] enabled(&send_report_and_input_enabled) on_click(cc! { (config, details, show_details, logic) move || { + // Immediately display the window to feel responsive, even if forming + // the details string takes a little while (it really shouldn't + // though). + *details.borrow_mut() = config.string("crashreporter-loading-details"); + logic.push(|s| s.update_details()); + *show_details.borrow_mut() = true; + }}) + { + Label text(config.string("crashreporter-button-details")) + }, + Scroll halign(Alignment::Fill) valign(Alignment::Fill) { + TextBox["comment"] placeholder(config.string("crashreporter-comment-prompt")) + content(comment) + editable(true) + enabled(&send_report_and_input_enabled) + halign(Alignment::Fill) valign(Alignment::Fill) + }, + Checkbox["include-url"] checked(include_address) + label(config.string("crashreporter-include-url")) enabled(&send_report_and_input_enabled), + Label text(&submit_status_text) margin_top(20), + Progress halign(Alignment::Fill) visible(&progress_visible), + }, + HBox valign(Alignment::End) halign(Alignment::End) spacing(10) affirmative_order(true) + { + Button["restart"] visible(config.restart_command.is_some()) + on_click(cc! { (logic) move || logic.push(|s| s.restart()) }) + enabled(&input_enabled) hsize(160) + { + Label text(config.string("crashreporter-button-restart")) + }, + Button["quit"] on_click(cc! { (logic) move || logic.push(|s| s.quit()) }) + enabled(&input_enabled) hsize(160) + { + Label text(config.string("crashreporter-button-quit")) + } + } + } + } + }; + + ui.run_loop(Application { + windows: vec![main_window], + rtl: config.is_rtl(), + }); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/button.rs b/toolkit/crashreporter/client/app/src/ui/model/button.rs new file mode 100644 index 0000000000..d522fad6fc --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/button.rs @@ -0,0 +1,26 @@ +/* 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::{Element, ElementBuilder}; +use crate::data::Event; + +/// A clickable button. +#[derive(Default, Debug)] +pub struct Button { + pub content: Option<Box<Element>>, + pub click: Event<()>, +} + +impl ElementBuilder<Button> { + pub fn on_click<F>(&mut self, f: F) + where + F: Fn() + 'static, + { + self.element_type.click.subscribe(move |_| f()); + } + + pub fn add_child(&mut self, child: Element) { + Self::single_child(&mut self.element_type.content, child); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/checkbox.rs b/toolkit/crashreporter/client/app/src/ui/model/checkbox.rs new file mode 100644 index 0000000000..8923e33558 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/checkbox.rs @@ -0,0 +1,22 @@ +/* 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::data::Property; + +/// A checkbox (with optional label). +#[derive(Default, Debug)] +pub struct Checkbox { + pub checked: Property<bool>, + pub label: Option<String>, +} + +impl super::ElementBuilder<Checkbox> { + pub fn checked(&mut self, value: impl Into<Property<bool>>) { + self.element_type.checked = value.into(); + } + + pub fn label<S: Into<String>>(&mut self, label: S) { + self.element_type.label = Some(label.into()); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/hbox.rs b/toolkit/crashreporter/client/app/src/ui/model/hbox.rs new file mode 100644 index 0000000000..b6c0e27e8c --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/hbox.rs @@ -0,0 +1,34 @@ +/* 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::{Element, ElementBuilder}; + +/// A box which lays out contents horizontally. +#[derive(Default, Debug)] +pub struct HBox { + pub items: Vec<Element>, + pub spacing: u32, + pub affirmative_order: bool, +} + +impl ElementBuilder<HBox> { + pub fn spacing(&mut self, value: u32) { + self.element_type.spacing = value; + } + + /// Whether children are in affirmative order (and should be reordered based on platform + /// conventions). + /// + /// The children passed to `add_child` should be in most-affirmative to least-affirmative order + /// (e.g., "OK" then "Cancel" buttons). + /// + /// This is mainly useful for dialog buttons. + pub fn affirmative_order(&mut self, value: bool) { + self.element_type.affirmative_order = value; + } + + pub fn add_child(&mut self, child: Element) { + self.element_type.items.push(child); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/label.rs b/toolkit/crashreporter/client/app/src/ui/model/label.rs new file mode 100644 index 0000000000..096ce022e3 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/label.rs @@ -0,0 +1,22 @@ +/* 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::data::Property; + +/// A text label. +#[derive(Debug, Default)] +pub struct Label { + pub text: Property<String>, + pub bold: bool, +} + +impl super::ElementBuilder<Label> { + pub fn text(&mut self, s: impl Into<Property<String>>) { + self.element_type.text = s.into(); + } + + pub fn bold(&mut self, value: bool) { + self.element_type.bold = value; + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/mod.rs b/toolkit/crashreporter/client/app/src/ui/model/mod.rs new file mode 100644 index 0000000000..5ea2ddc59a --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/mod.rs @@ -0,0 +1,344 @@ +/* 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/. */ + +//! The UI model. +//! +//! Model elements should generally be declared as types with all fields `pub` (to be accessed by +//! UI implementations), though accessor methods are acceptable if needed. An +//! `ElementBuilder<TYPE>` impl should be provided to create methods that will be used in the +//! [`ui!`] macro. The model types are accessible when being _consumed_ by a UI implementation, +//! whereas the `ElementBuilder` types are accessible when the model is being _created_. +//! +//! All elements should be listed in the `element_types!` macro in this file (note that [`Window`], +//! while an element, isn't listed here as it cannot be a child element). This populates the +//! `ElementType` enum and generates `From<Element>` for `ElementType`, and `TryFrom<ElementType>` +//! for the element (as well as reference `TryFrom`). +//! +//! The model is written to accommodate layout and text direction differences (e.g. for RTL +//! languages), and UI implementations are expected to account for this correctly. + +use crate::data::Property; +pub use button::Button; +pub use checkbox::Checkbox; +pub use hbox::HBox; +pub use label::Label; +pub use progress::Progress; +pub use scroll::Scroll; +pub use textbox::TextBox; +pub use vbox::VBox; +pub use window::Window; + +mod button; +mod checkbox; +mod hbox; +mod label; +mod progress; +mod scroll; +mod textbox; +mod vbox; +mod window; + +/// A GUI element, including general style attributes and a more specific type. +/// +/// `From<ElementBuilder<...>>` is implemented for all elements listed in `element_types!`. +#[derive(Debug)] +pub struct Element { + pub style: ElementStyle, + pub element_type: ElementType, +} + +// This macro creates the `ElementType` enum and corresponding `From<ElementBuilder>` impls for +// Element. The `ElementType` discriminants match the element type names. +macro_rules! element_types { + ( $($name:ident),* ) => { + /// A type of GUI element. + #[derive(Debug)] + pub enum ElementType { + $($name($name)),* + } + + $( + impl From<$name> for ElementType { + fn from(e: $name) -> ElementType { + ElementType::$name(e) + } + } + + impl TryFrom<ElementType> for $name { + type Error = &'static str; + + fn try_from(et: ElementType) -> Result<Self, Self::Error> { + if let ElementType::$name(v) = et { + Ok(v) + } else { + Err(concat!("ElementType was not ", stringify!($name))) + } + } + } + + impl<'a> TryFrom<&'a ElementType> for &'a $name { + type Error = &'static str; + + fn try_from(et: &'a ElementType) -> Result<Self, Self::Error> { + if let ElementType::$name(v) = et { + Ok(v) + } else { + Err(concat!("ElementType was not ", stringify!($name))) + } + } + } + + impl From<ElementBuilder<$name>> for Element { + fn from(b: ElementBuilder<$name>) -> Self { + Element { + style: b.style, + element_type: b.element_type.into(), + } + } + } + )* + } +} +element_types! { + Button, Checkbox, HBox, Label, Progress, Scroll, TextBox, VBox +} + +/// Common element style values. +#[derive(Debug)] +pub struct ElementStyle { + pub horizontal_alignment: Alignment, + pub vertical_alignment: Alignment, + pub horizontal_size_request: Option<u32>, + pub vertical_size_request: Option<u32>, + pub margin: Margin, + pub visible: Property<bool>, + pub enabled: Property<bool>, + #[cfg(test)] + pub id: Option<String>, +} + +impl Default for ElementStyle { + fn default() -> Self { + ElementStyle { + horizontal_alignment: Default::default(), + vertical_alignment: Default::default(), + horizontal_size_request: Default::default(), + vertical_size_request: Default::default(), + margin: Default::default(), + visible: true.into(), + enabled: true.into(), + #[cfg(test)] + id: Default::default(), + } + } +} + +/// A builder for `Element`s. +/// +/// Each element should add an `impl ElementBuilder<TYPE>` to add methods to their builder. +#[derive(Debug, Default)] +pub struct ElementBuilder<T> { + pub style: ElementStyle, + pub element_type: T, +} + +impl<T> ElementBuilder<T> { + /// Set horizontal alignment. + pub fn halign(&mut self, alignment: Alignment) { + self.style.horizontal_alignment = alignment; + } + + /// Set vertical alignment. + pub fn valign(&mut self, alignment: Alignment) { + self.style.vertical_alignment = alignment; + } + + /// Set the horizontal size request. + pub fn hsize(&mut self, value: u32) { + assert!(value <= i32::MAX as u32); + self.style.horizontal_size_request = Some(value); + } + + /// Set the vertical size request. + pub fn vsize(&mut self, value: u32) { + assert!(value <= i32::MAX as u32); + self.style.vertical_size_request = Some(value); + } + + /// Set start margin. + pub fn margin_start(&mut self, amount: u32) { + self.style.margin.start = amount; + } + + /// Set end margin. + pub fn margin_end(&mut self, amount: u32) { + self.style.margin.end = amount; + } + + /// Set start and end margins. + pub fn margin_horizontal(&mut self, amount: u32) { + self.margin_start(amount); + self.margin_end(amount) + } + + /// Set top margin. + pub fn margin_top(&mut self, amount: u32) { + self.style.margin.top = amount; + } + + /// Set bottom margin. + pub fn margin_bottom(&mut self, amount: u32) { + self.style.margin.bottom = amount; + } + + /// Set top and bottom margins. + pub fn margin_vertical(&mut self, amount: u32) { + self.margin_top(amount); + self.margin_bottom(amount) + } + + /// Set all margins. + pub fn margin(&mut self, amount: u32) { + self.margin_horizontal(amount); + self.margin_vertical(amount) + } + + /// Set visibility. + pub fn visible(&mut self, value: impl Into<Property<bool>>) { + self.style.visible = value.into(); + } + + /// Set whether an element is enabled. + /// + /// This generally should enable/disable interaction with an element. + pub fn enabled(&mut self, value: impl Into<Property<bool>>) { + self.style.enabled = value.into(); + } + + /// Set the element identifier. + #[cfg(test)] + pub fn id(&mut self, value: impl Into<String>) { + self.style.id = Some(value.into()); + } + + /// Set the element identifier (stub). + #[cfg(not(test))] + pub fn id(&mut self, _value: impl Into<String>) {} + + fn single_child(slot: &mut Option<Box<Element>>, child: Element) { + if slot.replace(Box::new(child)).is_some() { + panic!("{} can only have one child", std::any::type_name::<T>()); + } + } +} + +/// A typed [`Element`]. +/// +/// This is useful for the [`ui!`] macro when a method should accept a specific element type, since +/// the macro always creates [`ElementBuilder<T>`](ElementBuilder) and ends with a `.into()` (and this implements +/// `From<ElementBuilder<T>>`). +#[derive(Debug, Default)] +pub struct TypedElement<T> { + pub style: ElementStyle, + pub element_type: T, +} + +impl<T> From<ElementBuilder<T>> for TypedElement<T> { + fn from(b: ElementBuilder<T>) -> Self { + TypedElement { + style: b.style, + element_type: b.element_type, + } + } +} + +/// The alignment of an element in one direction. +/// +/// Note that rather than `Left`/`Right`, this class has `Start`/`End` as it is meant to be +/// layout-direction-aware. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum Alignment { + /// Align to the start of the direction. + #[default] + Start, + /// Align to the center of the direction. + Center, + /// Align to the end of the direction. + End, + /// Fill all available space. + Fill, +} + +/// The margins of an element. +/// +/// These are RTL-aware: for instance, `start` is the left margin in left-to-right languages and +/// the right margin in right-to-left languages. +#[derive(Default, Debug)] +pub struct Margin { + pub start: u32, + pub end: u32, + pub top: u32, + pub bottom: u32, +} + +/// A macro to allow a convenient syntax for creating elements. +/// +/// The macro expects the following syntax: +/// ``` +/// ElementTypeName some_method(arg1, arg2) other_method() { +/// Child ..., +/// Child2 ... +/// } +/// ``` +/// +/// The type is wrapped in an `ElementBuilder`, and methods are called on this builder with a +/// mutable reference. This means that element types must implement Default and must implement +/// builder methods on `ElementBuilder<ElementTypeName>`. The children block is optional, and calls +/// `add_child(child: Element)` for each provided child (so implement this method if desired). +/// +/// For testing, a string identifier can be set on any element with a `["my_identifier"]` following +/// the element type. +macro_rules! ui { + ( $el:ident + $([ $id:literal ])? + $( $method:ident $methodargs:tt )* + $({ $($contents:tt)* })? + ) => { + { + #[allow(unused_imports)] + use $crate::ui::model::*; + let mut el: ElementBuilder<$el> = Default::default(); + $( el.id($id); )? + $( el.$method $methodargs ; )* + $( ui! { @children (el) $($contents)* } )? + el.into() + } + }; + ( @children ($parent:expr) ) => {}; + ( @children ($parent:expr) + $el:ident + $([ $id:literal ])? + $( $method:ident $methodargs:tt )* + $({ $($contents:tt)* })? + $(, $($rest:tt)* )? + ) => { + $parent.add_child(ui!( $el $([$id])? $( $method $methodargs )* $({ $($contents)* })? )); + $(ui!( @children ($parent) $($rest)* ))? + }; +} + +pub(crate) use ui; + +/// An application, defined as a set of windows. +/// +/// When all windows are closed, the application is considered complete (and loops should exit). +pub struct Application { + pub windows: Vec<TypedElement<Window>>, + /// Whether the text direction should be right-to-left. + pub rtl: bool, +} + +/// A function to be invoked in the UI loop. +pub type InvokeFn = Box<dyn FnOnce() + Send + 'static>; diff --git a/toolkit/crashreporter/client/app/src/ui/model/progress.rs b/toolkit/crashreporter/client/app/src/ui/model/progress.rs new file mode 100644 index 0000000000..f3e4e4bf77 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/progress.rs @@ -0,0 +1,19 @@ +/* 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::data::Property; + +/// A progress indicator. +#[derive(Debug, Default)] +pub struct Progress { + /// Progress between 0 and 1, or None if indeterminate. + pub amount: Property<Option<f32>>, +} + +impl super::ElementBuilder<Progress> { + #[allow(dead_code)] + pub fn amount(&mut self, value: impl Into<Property<Option<f32>>>) { + self.element_type.amount = value.into(); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/scroll.rs b/toolkit/crashreporter/client/app/src/ui/model/scroll.rs new file mode 100644 index 0000000000..47efa4a81e --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/scroll.rs @@ -0,0 +1,17 @@ +/* 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::{Element, ElementBuilder}; + +/// A scrollable region. +#[derive(Debug, Default)] +pub struct Scroll { + pub content: Option<Box<Element>>, +} + +impl ElementBuilder<Scroll> { + pub fn add_child(&mut self, child: Element) { + Self::single_child(&mut self.element_type.content, child); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/textbox.rs b/toolkit/crashreporter/client/app/src/ui/model/textbox.rs new file mode 100644 index 0000000000..08cd9ca1bc --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/textbox.rs @@ -0,0 +1,27 @@ +/* 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::data::Property; + +/// A text box. +#[derive(Debug, Default)] +pub struct TextBox { + pub placeholder: Option<String>, + pub content: Property<String>, + pub editable: bool, +} + +impl super::ElementBuilder<TextBox> { + pub fn placeholder(&mut self, text: impl Into<String>) { + self.element_type.placeholder = Some(text.into()); + } + + pub fn content(&mut self, value: impl Into<Property<String>>) { + self.element_type.content = value.into(); + } + + pub fn editable(&mut self, value: bool) { + self.element_type.editable = value; + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/vbox.rs b/toolkit/crashreporter/client/app/src/ui/model/vbox.rs new file mode 100644 index 0000000000..6f1b09b1e2 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/vbox.rs @@ -0,0 +1,22 @@ +/* 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::{Element, ElementBuilder}; + +/// A box which lays out contents vertically. +#[derive(Debug, Default)] +pub struct VBox { + pub items: Vec<Element>, + pub spacing: u32, +} + +impl ElementBuilder<VBox> { + pub fn spacing(&mut self, value: u32) { + self.element_type.spacing = value; + } + + pub fn add_child(&mut self, child: Element) { + self.element_type.items.push(child); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/model/window.rs b/toolkit/crashreporter/client/app/src/ui/model/window.rs new file mode 100644 index 0000000000..b56071ca19 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/model/window.rs @@ -0,0 +1,46 @@ +/* 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::{Element, ElementBuilder, TypedElement}; +use crate::data::Event; + +/// A window. +#[derive(Debug, Default)] +pub struct Window { + pub title: String, + /// The window content is the first element. + pub content: Option<Box<Element>>, + /// Logical child windows. + pub children: Vec<TypedElement<Self>>, + pub modal: bool, + pub close: Option<Event<()>>, +} + +impl ElementBuilder<Window> { + /// Set the window title. + pub fn title(&mut self, s: impl Into<String>) { + self.element_type.title = s.into(); + } + + /// Set whether the window is modal (blocking interaction with other windows when displayed). + pub fn modal(&mut self, value: bool) { + self.element_type.modal = value; + } + + /// Register an event to close the window. + pub fn close_when(&mut self, event: &Event<()>) { + self.element_type.close = Some(event.clone()); + } + + /// Add a window as a logical child of this one. + /// + /// Logical children are always displayed above their parents. + pub fn child_window(&mut self, window: TypedElement<Window>) { + self.element_type.children.push(window); + } + + pub fn add_child(&mut self, child: Element) { + Self::single_child(&mut self.element_type.content, child); + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/test.rs b/toolkit/crashreporter/client/app/src/ui/test.rs new file mode 100644 index 0000000000..db98c072da --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/test.rs @@ -0,0 +1,270 @@ +/* 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 renderer for use in tests, which doesn't actually render a GUI but allows programmatic +//! interaction. +//! +//! The [`ui!`](super::ui) macro supports labeling any element with a string identifier, which can +//! be used to access the element in this UI. +//! +//! The [`Interact`] hook must be created to interact with the test UI, before the UI is run and on +//! the same thread as the UI. +//! +//! See how this UI is used in [`crate::test`]. + +use super::model::{self, Application, Element}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::{ + atomic::{AtomicBool, AtomicU8, Ordering::Relaxed}, + mpsc, Arc, Condvar, Mutex, +}; + +thread_local! { + static INTERACT: RefCell<Option<Arc<State>>> = Default::default(); +} + +/// A test UI which allows access to the UI elements. +#[derive(Default)] +pub struct UI { + interface: Mutex<Option<UIInterface>>, +} + +impl UI { + pub fn run_loop(&self, app: Application) { + let (tx, rx) = mpsc::channel(); + let interface = UIInterface { work: tx }; + + let elements = id_elements(&app); + INTERACT.with(cc! { (interface) move |r| { + if let Some(state) = &*r.borrow() { + state.set_interface(interface); + } + }}); + *self.interface.lock().unwrap() = Some(interface.clone()); + + // Close the UI when the root windows are closed. + // Use a bitfield rather than a count in case the `close` event is fired multiple times. + assert!(app.windows.len() <= 8); + let mut windows = 0u8; + for i in 0..app.windows.len() { + windows |= 1 << i; + } + let windows = Arc::new(AtomicU8::new(windows)); + for (index, window) in app.windows.iter().enumerate() { + if let Some(c) = &window.element_type.close { + c.subscribe(cc! { (interface, windows) move |&()| { + let old = windows + .fetch_update(Relaxed, Relaxed, |x| Some(x & !(1u8 << index))) + .unwrap(); + if old == 1u8 << index { + interface.work.send(Command::Finish).unwrap(); + } + }}); + } else { + // No close event, so we must assume a closed state (and assume that _some_ window + // will have a close event registered so we don't drop the interface now). + windows + .fetch_update(Relaxed, Relaxed, |x| Some(x & !(1u8 << index))) + .unwrap(); + } + } + + while let Ok(f) = rx.recv() { + match f { + Command::Invoke(f) => f(), + Command::Interact(f) => f(&elements), + Command::Finish => break, + } + } + + *self.interface.lock().unwrap() = None; + INTERACT.with(|r| { + if let Some(state) = &*r.borrow() { + state.clear_interface(); + } + }); + } + + pub fn invoke(&self, f: model::InvokeFn) { + let guard = self.interface.lock().unwrap(); + if let Some(interface) = &*guard { + let _ = interface.work.send(Command::Invoke(f)); + } + } +} + +/// Test interaction hook. +#[derive(Clone)] +pub struct Interact { + state: Arc<State>, +} + +impl Interact { + /// Create an interaction hook for the test UI. + /// + /// This should be done before running the UI, and must be done on the same thread that + /// later runs it. + pub fn hook() -> Self { + let v = Interact { + state: Default::default(), + }; + { + let state = v.state.clone(); + INTERACT.with(move |r| *r.borrow_mut() = Some(state)); + } + v + } + + /// Wait for the render thread to be ready for interaction. + pub fn wait_for_ready(&self) { + let mut guard = self.state.interface.lock().unwrap(); + while guard.is_none() && !self.state.cancel.load(Relaxed) { + guard = self.state.waiting_for_interface.wait(guard).unwrap(); + } + } + + /// Cancel an Interact (which causes `wait_for_ready` to always return). + pub fn cancel(&self) { + self.state.cancel.store(true, Relaxed); + self.state.waiting_for_interface.notify_all(); + } + + /// Run the given function on the element with the given type and identity. + /// + /// Panics if either the id is missing or the type is incorrect. + pub fn element<'a, 'b, T: 'b, F, R>(&self, id: &'a str, f: F) -> R + where + &'b T: TryFrom<&'b model::ElementType>, + F: FnOnce(&model::ElementStyle, &T) -> R + Send + 'a, + R: Send + 'a, + { + self.interact(id, move |element: &IdElement| match element { + IdElement::Generic(e) => Some(f(&e.style, (&e.element_type).try_into().ok()?)), + IdElement::Window(_) => None, + }) + .expect("incorrect element type") + } + + /// Run the given function on the window with the given identity. + /// + /// Panics if the id is missing or the type is incorrect. + pub fn window<'a, F, R>(&self, id: &'a str, f: F) -> R + where + F: FnOnce(&model::ElementStyle, &model::Window) -> R + Send + 'a, + R: Send + 'a, + { + self.interact(id, move |element| match element { + IdElement::Window(e) => Some(f(&e.style, &e.element_type)), + IdElement::Generic(_) => None, + }) + .expect("incorrect element type") + } + + fn interact<'a, 'b, F, R>(&self, id: &'a str, f: F) -> R + where + F: FnOnce(&IdElement<'b>) -> R + Send + 'a, + R: Send + 'a, + { + let (send, recv) = std::sync::mpsc::sync_channel(0); + { + let f: Box<dyn FnOnce(&IdElements<'b>) + Send + 'a> = Box::new(move |elements| { + let _ = send.send(elements.get(id).map(f)); + }); + + // # Safety + // The function is run while `'a` is still valid (we wait here for it to complete). + let f: Box<dyn FnOnce(&IdElements) + Send + 'static> = + unsafe { std::mem::transmute(f) }; + + let guard = self.state.interface.lock().unwrap(); + let interface = guard.as_ref().expect("renderer is not running"); + let _ = interface.work.send(Command::Interact(f)); + } + recv.recv().unwrap().expect("failed to get element") + } +} + +#[derive(Clone)] +struct UIInterface { + work: mpsc::Sender<Command>, +} + +enum Command { + Invoke(Box<dyn FnOnce() + Send + 'static>), + Interact(Box<dyn FnOnce(&IdElements) + Send + 'static>), + Finish, +} + +enum IdElement<'a> { + Generic(&'a Element), + Window(&'a model::TypedElement<model::Window>), +} + +type IdElements<'a> = HashMap<String, IdElement<'a>>; + +#[derive(Default)] +struct State { + interface: Mutex<Option<UIInterface>>, + waiting_for_interface: Condvar, + cancel: AtomicBool, +} + +impl State { + /// Set the interface for the interaction client to use. + pub fn set_interface(&self, interface: UIInterface) { + *self.interface.lock().unwrap() = Some(interface); + self.waiting_for_interface.notify_all(); + } + + /// Clear the UI interface. + pub fn clear_interface(&self) { + *self.interface.lock().unwrap() = None; + } +} + +fn id_elements<'a>(app: &'a Application) -> IdElements<'a> { + let mut elements: IdElements<'a> = Default::default(); + + let mut windows_to_visit: Vec<_> = app.windows.iter().collect(); + + let mut to_visit: Vec<&'a Element> = Vec::new(); + while let Some(window) = windows_to_visit.pop() { + if let Some(id) = &window.style.id { + elements.insert(id.to_owned(), IdElement::Window(window)); + } + windows_to_visit.extend(&window.element_type.children); + + if let Some(content) = &window.element_type.content { + to_visit.push(content); + } + } + + while let Some(el) = to_visit.pop() { + if let Some(id) = &el.style.id { + elements.insert(id.to_owned(), IdElement::Generic(el)); + } + + use model::ElementType::*; + match &el.element_type { + Button(model::Button { + content: Some(content), + .. + }) + | Scroll(model::Scroll { + content: Some(content), + }) => { + to_visit.push(content); + } + VBox(model::VBox { items, .. }) | HBox(model::HBox { items, .. }) => { + for item in items { + to_visit.push(item) + } + } + _ => (), + } + } + + elements +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/font.rs b/toolkit/crashreporter/client/app/src/ui/windows/font.rs new file mode 100644 index 0000000000..3ec48316eb --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/font.rs @@ -0,0 +1,56 @@ +/* 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 windows_sys::Win32::{Foundation::S_OK, Graphics::Gdi, UI::Controls}; + +/// Windows font handle (`HFONT`). +pub struct Font(Gdi::HFONT); + +impl Font { + /// Get the system theme caption font. + /// + /// Panics if the font cannot be retrieved. + pub fn caption() -> Self { + unsafe { + let mut font = std::mem::zeroed::<Gdi::LOGFONTW>(); + success!(hresult + Controls::GetThemeSysFont(0, Controls::TMT_CAPTIONFONT as i32, &mut font) + ); + Font(success!(pointer Gdi::CreateFontIndirectW(&font))) + } + } + + /// Get the system theme bold caption font. + /// + /// Returns `None` if the font cannot be retrieved. + pub fn caption_bold() -> Option<Self> { + unsafe { + let mut font = std::mem::zeroed::<Gdi::LOGFONTW>(); + if Controls::GetThemeSysFont(0, Controls::TMT_CAPTIONFONT as i32, &mut font) != S_OK { + return None; + } + font.lfWeight = Gdi::FW_BOLD as i32; + + let ptr = Gdi::CreateFontIndirectW(&font); + if ptr == 0 { + return None; + } + Some(Font(ptr)) + } + } +} + +impl std::ops::Deref for Font { + type Target = Gdi::HFONT; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Drop for Font { + fn drop(&mut self) { + unsafe { Gdi::DeleteObject(self.0 as _) }; + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/gdi.rs b/toolkit/crashreporter/client/app/src/ui/windows/gdi.rs new file mode 100644 index 0000000000..89828987bc --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/gdi.rs @@ -0,0 +1,43 @@ +/* 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/. */ + +//! GDI helpers. + +use windows_sys::Win32::{ + Foundation::HWND, + Graphics::Gdi::{self, GDI_ERROR, HDC, HGDIOBJ}, +}; + +/// A GDI drawing context. +pub struct DC { + hwnd: HWND, + hdc: HDC, +} + +impl DC { + /// Create a new DC. + pub fn new(hwnd: HWND) -> Option<Self> { + let hdc = unsafe { Gdi::GetDC(hwnd) }; + (hdc != 0).then_some(DC { hwnd, hdc }) + } + + /// Call the given function with a gdi object selected. + pub fn with_object_selected<R>(&self, object: HGDIOBJ, f: impl FnOnce(HDC) -> R) -> Option<R> { + let old_object = unsafe { Gdi::SelectObject(self.hdc, object) }; + if old_object == 0 || old_object == GDI_ERROR as isize { + return None; + } + let ret = f(self.hdc); + // The prior object must be selected before releasing the DC. Ignore errors; this is + // best-effort. + unsafe { Gdi::SelectObject(self.hdc, old_object) }; + Some(ret) + } +} + +impl Drop for DC { + fn drop(&mut self) { + unsafe { Gdi::ReleaseDC(self.hwnd, self.hdc) }; + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/layout.rs b/toolkit/crashreporter/client/app/src/ui/windows/layout.rs new file mode 100644 index 0000000000..7563b6b2f0 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/layout.rs @@ -0,0 +1,436 @@ +/* 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/. */ + +//! Helpers for window layout. + +use super::{ + model::{self, Alignment, Element, ElementStyle, Margin}, + ElementRef, WideString, +}; +use crate::data::Property; +use std::collections::HashMap; +use windows_sys::Win32::{ + Foundation::{HWND, SIZE}, + Graphics::Gdi, + UI::WindowsAndMessaging as win, +}; + +pub(super) type ElementMapping = HashMap<ElementRef, HWND>; + +/// Handles the layout of windows. +/// +/// This is done in two passes. The first pass calculates the sizes for all elements in the tree. +/// Once sizes are known, the second pass can appropriately position the elements (taking alignment +/// into account). +/// +/// Currently, the resize/reposition logic is tied into the methods here, which is an inconvenient +/// design because when adding support for a new element type you have to add new information in +/// disparate locations. +pub struct Layout<'a> { + elements: &'a ElementMapping, + sizes: HashMap<ElementRef, Size>, + last_positioned: Option<HWND>, +} + +// Unfortunately, there's no good way to get these margins. I just guessed and the first guesses +// seemed to be close enough. +const BUTTON_MARGIN: Margin = Margin { + start: 5, + end: 5, + top: 5, + bottom: 5, +}; +const CHECKBOX_MARGIN: Margin = Margin { + start: 15, + end: 0, + top: 0, + bottom: 0, +}; + +impl<'a> Layout<'a> { + pub(super) fn new(elements: &'a ElementMapping) -> Self { + Layout { + elements, + sizes: Default::default(), + last_positioned: None, + } + } + + /// Perform a layout of the element and all child elements. + pub fn layout(mut self, element: &Element, max_width: u32, max_height: u32) { + let max_size = Size { + width: max_width, + height: max_height, + }; + self.resize(element, &max_size); + self.reposition(element, &Position::default(), &max_size); + } + + fn resize(&mut self, element: &Element, max_size: &Size) -> Size { + let style = &element.style; + + let mut inner_size = max_size.inner_size(style); + let mut content_size = None; + + if !is_visible(style) { + self.sizes + .insert(ElementRef::new(element), Default::default()); + return Default::default(); + } + + // Resize inner content. + // + // These cases should result in `content_size` being set, if relevant. + use model::ElementType::*; + match &element.element_type { + Button(model::Button { + content: Some(content), + .. + }) => { + // Special case for buttons with a label. + if let Label(model::Label { + text: Property::Static(text), + .. + }) = &content.element_type + { + let mut size = inner_size.less_margin(&BUTTON_MARGIN); + self.measure_text(text.as_str(), element, &mut size); + content_size = Some(size.plus_margin(&BUTTON_MARGIN)); + } + } + Checkbox(model::Checkbox { + label: Some(label), .. + }) => { + let mut size = inner_size.less_margin(&CHECKBOX_MARGIN); + self.measure_text(label.as_str(), element, &mut size); + content_size = Some(size.plus_margin(&CHECKBOX_MARGIN)); + } + Label(model::Label { text, bold: _ }) => { + let mut size = inner_size.clone(); + match text { + Property::Static(text) => self.measure_text(text.as_str(), element, &mut size), + Property::Binding(b) => { + self.measure_text(b.borrow().as_str(), element, &mut size) + } + Property::ReadOnly(_) => { + unimplemented!("Label::text does not support ReadOnly") + } + } + content_size = Some(size); + } + VBox(model::VBox { items, spacing }) => { + let mut height = 0; + let mut max_width = 0; + let mut remaining_size = inner_size.clone(); + let mut resize_child = |c| { + let child_size = self.resize(c, &remaining_size); + height += child_size.height; + max_width = std::cmp::max(child_size.width, max_width); + remaining_size.height = remaining_size + .height + .saturating_sub(child_size.height + spacing); + }; + // First resize all non-Fill items; Fill items get the remaining space. + for item in items + .iter() + .filter(|i| i.style.vertical_alignment != Alignment::Fill) + { + resize_child(item); + } + for item in items + .iter() + .filter(|i| i.style.vertical_alignment == Alignment::Fill) + { + resize_child(item); + } + content_size = Some(Size { + width: max_width, + height: height + spacing * (items.len().saturating_sub(1) as u32), + }); + } + HBox(model::HBox { + items, + spacing, + affirmative_order: _, + }) => { + let mut width = 0; + let mut max_height = 0; + let mut remaining_size = inner_size.clone(); + let mut resize_child = |c| { + let child_size = self.resize(c, &remaining_size); + width += child_size.width; + max_height = std::cmp::max(child_size.height, max_height); + remaining_size.width = remaining_size + .width + .saturating_sub(child_size.width + spacing); + }; + // First resize all non-Fill items; Fill items get the remaining space. + for item in items + .iter() + .filter(|i| i.style.horizontal_alignment != Alignment::Fill) + { + resize_child(item); + } + for item in items + .iter() + .filter(|i| i.style.horizontal_alignment == Alignment::Fill) + { + resize_child(item); + } + content_size = Some(Size { + width: width + spacing * (items.len().saturating_sub(1) as u32), + height: max_height, + }); + } + Scroll(model::Scroll { + content: Some(content), + }) => { + content_size = Some(self.resize(content, &inner_size)); + } + Progress(model::Progress { .. }) => { + // Min size recommended by windows uxguide + content_size = Some(Size { + width: 160, + height: 15, + }); + } + // We don't support sizing by textbox content yet (need to read from the HWND due to + // Property::ReadOnly). + TextBox(_) => (), + _ => (), + } + + // Adjust from content size. + if let Some(content_size) = content_size { + inner_size.from_content_size(style, &content_size); + } + + // Compute/store (outer) size and return. + let size = inner_size.plus_margin(&style.margin); + self.sizes.insert(ElementRef::new(element), size); + size + } + + fn get_size(&self, element: &Element) -> &Size { + self.sizes + .get(&ElementRef::new(element)) + .expect("element not resized") + } + + fn reposition(&mut self, element: &Element, position: &Position, parent_size: &Size) { + let style = &element.style; + if !is_visible(style) { + return; + } + let size = self.get_size(element); + + let start_offset = match style.horizontal_alignment { + Alignment::Fill | Alignment::Start => 0, + Alignment::Center => parent_size.width.saturating_sub(size.width) / 2, + Alignment::End => parent_size.width.saturating_sub(size.width), + }; + let top_offset = match style.vertical_alignment { + Alignment::Fill | Alignment::Start => 0, + Alignment::Center => parent_size.height.saturating_sub(size.height) / 2, + Alignment::End => parent_size.height.saturating_sub(size.height), + }; + + let inner_position = Position { + start: position.start + start_offset, + top: position.top + top_offset, + } + .less_margin(&style.margin); + let inner_size = size.less_margin(&style.margin); + + // Set the window size/position if there is a handle associated with the element. + if let Some(&hwnd) = self.elements.get(&ElementRef::new(element)) { + unsafe { + win::SetWindowPos( + hwnd, + self.last_positioned.unwrap_or(win::HWND_TOP), + inner_position.start.try_into().unwrap(), + inner_position.top.try_into().unwrap(), + inner_size.width.try_into().unwrap(), + inner_size.height.try_into().unwrap(), + 0, + ); + Gdi::InvalidateRect(hwnd, std::ptr::null(), 1); + } + self.last_positioned = Some(hwnd); + } + + // Reposition content. + match &element.element_type { + model::ElementType::VBox(model::VBox { items, spacing }) => { + let mut position = inner_position; + let mut size = inner_size; + for item in items { + self.reposition(item, &position, &size); + let consumed = self.get_size(item).height + spacing; + if item.style.vertical_alignment != Alignment::End { + position.top += consumed; + } + size.height = size.height.saturating_sub(consumed); + } + } + model::ElementType::HBox(model::HBox { + items, + spacing, + // The default ordering matches the windows platform order + affirmative_order: _, + }) => { + let mut position = inner_position; + let mut size = inner_size; + for item in items { + self.reposition(item, &position, &inner_size); + let consumed = self.get_size(item).width + spacing; + if item.style.horizontal_alignment != Alignment::End { + position.start += consumed; + } + size.width = size.width.saturating_sub(consumed); + } + } + model::ElementType::Scroll(model::Scroll { + content: Some(content), + }) => { + self.reposition(content, &inner_position, &inner_size); + } + _ => (), + } + } + + /// The `size` represents the maximum size permitted for the text (which is used for word + /// breaking), and it will be set to the precise width and height of the text. The width should + /// not exceed the input `size` width, but the height may. + fn measure_text(&mut self, text: &str, element: &Element, size: &mut Size) { + let Some(&window) = self.elements.get(&ElementRef::new(element)) else { + return; + }; + let hdc = unsafe { Gdi::GetDC(window) }; + unsafe { Gdi::SelectObject(hdc, win::SendMessageW(window, win::WM_GETFONT, 0, 0) as _) }; + let mut height: u32 = 0; + let mut max_width: u32 = 0; + let mut char_fit = 0i32; + let mut win_size = unsafe { std::mem::zeroed::<SIZE>() }; + for mut line in text.lines() { + if line.is_empty() { + line = " "; + } + let text = WideString::new(line); + let mut text = text.as_slice(); + let mut extents = vec![0i32; text.len()]; + while !text.is_empty() { + unsafe { + Gdi::GetTextExtentExPointW( + hdc, + text.as_ptr(), + text.len() as i32, + size.width.try_into().unwrap(), + &mut char_fit, + extents.as_mut_ptr(), + &mut win_size, + ); + } + if char_fit == 0 { + return; + } + let mut split = char_fit as usize; + let mut split_end = split.saturating_sub(1); + if (char_fit as usize) < text.len() { + for i in (0..char_fit as usize).rev() { + // FIXME safer utf16 handling? + if text[i] == b' ' as u16 { + split = i + 1; + split_end = i.saturating_sub(1); + break; + } + } + } + text = &text[split..]; + max_width = std::cmp::max(max_width, extents[split_end].try_into().unwrap()); + let measured_height: u32 = win_size.cy.try_into().unwrap(); + height += measured_height; + } + } + unsafe { Gdi::ReleaseDC(window, hdc) }; + + assert!(max_width <= size.width); + size.width = max_width; + size.height = height; + } +} + +#[derive(Debug, Default, Clone, Copy)] +struct Size { + pub width: u32, + pub height: u32, +} + +impl Size { + pub fn inner_size(&self, style: &ElementStyle) -> Self { + let mut ret = self.less_margin(&style.margin); + if let Some(width) = style.horizontal_size_request { + ret.width = width; + } + if let Some(height) = style.vertical_size_request { + ret.height = height; + } + ret + } + + pub fn from_content_size(&mut self, style: &ElementStyle, content_size: &Self) { + if style.horizontal_size_request.is_none() && style.horizontal_alignment != Alignment::Fill + { + self.width = content_size.width; + } + if style.vertical_size_request.is_none() && style.vertical_alignment != Alignment::Fill { + self.height = content_size.height; + } + } + + pub fn plus_margin(&self, margin: &Margin) -> Self { + let mut ret = self.clone(); + ret.width += margin.start + margin.end; + ret.height += margin.top + margin.bottom; + ret + } + + pub fn less_margin(&self, margin: &Margin) -> Self { + let mut ret = self.clone(); + ret.width = ret.width.saturating_sub(margin.start + margin.end); + ret.height = ret.height.saturating_sub(margin.top + margin.bottom); + ret + } +} + +#[derive(Debug, Default, Clone, Copy)] +struct Position { + pub start: u32, + pub top: u32, +} + +impl Position { + #[allow(dead_code)] + pub fn plus_margin(&self, margin: &Margin) -> Self { + let mut ret = self.clone(); + ret.start = ret.start.saturating_sub(margin.start); + ret.top = ret.top.saturating_sub(margin.top); + ret + } + + pub fn less_margin(&self, margin: &Margin) -> Self { + let mut ret = self.clone(); + ret.start += margin.start; + ret.top += margin.top; + ret + } +} + +fn is_visible(style: &ElementStyle) -> bool { + match &style.visible { + Property::Static(v) => *v, + Property::Binding(s) => *s.borrow(), + _ => true, + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/mod.rs b/toolkit/crashreporter/client/app/src/ui/windows/mod.rs new file mode 100644 index 0000000000..c2f396b80d --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/mod.rs @@ -0,0 +1,949 @@ +/* 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 windows 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, Scroll behavior, etc). +//! * not all controls handle all Property variants (e.g. Checkbox doesn't handle ReadOnly, TextBox +//! doesn't handle Binding, etc). +//! +//! The error handling is also a _little_ fast-and-loose, as many functions return an error value +//! that is acceptable to following logic (though it still would be a good idea to improve this). +//! +//! The rendering treats VBox, HBox, and Scroll as strictly layout-only: they do not create any +//! associated windows, and the layout logic handles their behavior. + +// Our windows-targets doesn't link uxtheme correctly for GetThemeSysFont/GetThemeSysColor. +// This was working in windows-sys 0.48. +#[link(name = "uxtheme", kind = "static")] +extern "C" {} + +use super::model::{self, Application, Element, ElementStyle, TypedElement}; +use crate::data::Property; +use font::Font; +use once_cell::sync::Lazy; +use quit_token::QuitToken; +use std::cell::RefCell; +use std::collections::HashMap; +use std::pin::Pin; +use std::rc::Rc; +use widestring::WideString; +use window::{CustomWindowClass, Window, WindowBuilder}; +use windows_sys::Win32::{ + Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM}, + Graphics::Gdi, + System::{LibraryLoader::GetModuleHandleW, SystemServices, Threading::GetCurrentThreadId}, + UI::{Controls, Input::KeyboardAndMouse, Shell, WindowsAndMessaging as win}, +}; + +macro_rules! success { + ( nonzero $e:expr ) => {{ + let value = $e; + assert_ne!(value, 0); + value + }}; + ( lasterror $e:expr ) => {{ + unsafe { windows_sys::Win32::Foundation::SetLastError(0) }; + let value = $e; + assert!(value != 0 || windows_sys::Win32::Foundation::GetLastError() == 0); + value + }}; + ( hresult $e:expr ) => { + assert_eq!($e, windows_sys::Win32::Foundation::S_OK); + }; + ( pointer $e:expr ) => {{ + let ptr = $e; + assert_ne!(ptr, 0); + ptr + }}; +} + +mod font; +mod gdi; +mod layout; +mod quit_token; +mod twoway; +mod widestring; +mod window; + +/// A Windows API UI implementation. +pub struct UI { + thread_id: u32, +} + +/// Custom user messages. +#[repr(u32)] +enum UserMessage { + Invoke = win::WM_USER, +} + +fn get_invoke(msg: &win::MSG) -> Option<Box<model::InvokeFn>> { + if msg.message == UserMessage::Invoke as u32 { + Some(unsafe { Box::from_raw(msg.lParam as *mut model::InvokeFn) }) + } else { + None + } +} + +impl UI { + pub fn run_loop(&self, app: Application) { + // Initialize common controls. + { + let icc = Controls::INITCOMMONCONTROLSEX { + dwSize: std::mem::size_of::<Controls::INITCOMMONCONTROLSEX>() as _, + // Buttons, edit controls, and static controls are all included in 'standard'. + dwICC: Controls::ICC_STANDARD_CLASSES | Controls::ICC_PROGRESS_CLASS, + }; + success!(nonzero unsafe { Controls::InitCommonControlsEx(&icc) }); + } + + // Enable font smoothing (per + // https://learn.microsoft.com/en-us/windows/win32/gdi/cleartype-antialiasing ). + unsafe { + // We don't check for failure on these, they are best-effort. + win::SystemParametersInfoW( + win::SPI_SETFONTSMOOTHING, + 1, + std::ptr::null_mut(), + win::SPIF_UPDATEINIFILE | win::SPIF_SENDCHANGE, + ); + win::SystemParametersInfoW( + win::SPI_SETFONTSMOOTHINGTYPE, + 0, + win::FE_FONTSMOOTHINGCLEARTYPE as _, + win::SPIF_UPDATEINIFILE | win::SPIF_SENDCHANGE, + ); + } + + // Enable correct layout direction. + if unsafe { win::SetProcessDefaultLayout(if app.rtl { Gdi::LAYOUT_RTL } else { 0 }) } == 0 { + log::warn!("failed to set process layout direction"); + } + + let module: HINSTANCE = unsafe { GetModuleHandleW(std::ptr::null()) }; + + // Register custom classes. + AppWindow::register(module).expect("failed to register AppWindow window class"); + + { + // The quit token is cloned for each top-level window and dropped at the end of this + // scope. + let quit_token = QuitToken::new(); + + for window in app.windows { + let name = WideString::new(window.element_type.title.as_str()); + let w = top_level_window( + module, + AppWindow::new( + WindowRenderer::new(module, window.element_type, &window.style), + Some(quit_token.clone()), + ), + &name, + &window.style, + ); + + unsafe { win::ShowWindow(w.handle, win::SW_NORMAL) }; + unsafe { Gdi::UpdateWindow(w.handle) }; + } + } + + // Run the event loop. + let mut msg = unsafe { std::mem::zeroed::<win::MSG>() }; + while unsafe { win::GetMessageW(&mut msg, 0, 0, 0) } > 0 { + if let Some(f) = get_invoke(&msg) { + f(); + continue; + } + + unsafe { + // IsDialogMessageW is necessary to handle niceties like tab navigation + if win::IsDialogMessageW(win::GetAncestor(msg.hwnd, win::GA_ROOT), &mut msg) == 0 { + win::TranslateMessage(&msg); + win::DispatchMessageW(&msg); + } + } + } + + // Flush queue to properly drop late invokes (this is a very unlikely case) + while unsafe { win::PeekMessageW(&mut msg, 0, 0, 0, win::PM_REMOVE) } > 0 { + if let Some(f) = get_invoke(&msg) { + drop(f); + } + } + } + + pub fn invoke(&self, f: model::InvokeFn) { + let ptr: *mut model::InvokeFn = Box::into_raw(Box::new(f)); + if unsafe { + win::PostThreadMessageW(self.thread_id, UserMessage::Invoke as u32, 0, ptr as _) + } == 0 + { + let _ = unsafe { Box::from_raw(ptr) }; + log::warn!("failed to invoke function on thread message queue"); + } + } +} + +impl Default for UI { + fn default() -> Self { + UI { + thread_id: unsafe { GetCurrentThreadId() }, + } + } +} + +/// A reference to an Element. +#[derive(PartialEq, Eq, Hash, Clone, Copy)] +struct ElementRef(*const Element); + +impl ElementRef { + pub fn new(element: &Element) -> Self { + ElementRef(element as *const Element) + } + + /// # Safety + /// You must ensure the reference is still valid. + pub unsafe fn get(&self) -> &Element { + &*self.0 + } +} + +// Equivalent of win32 HIWORD macro +fn hiword(v: u32) -> u16 { + (v >> 16) as u16 +} + +// Equivalent of win32 LOWORD macro +fn loword(v: u32) -> u16 { + v as u16 +} + +// Equivalent of win32 MAKELONG macro +fn makelong(low: u16, high: u16) -> u32 { + (high as u32) << 16 | low as u32 +} + +fn top_level_window<W: window::WindowClass + window::WindowData>( + module: HINSTANCE, + class: W, + title: &WideString, + style: &ElementStyle, +) -> Window<W> { + class + .builder(module) + .name(title) + .style(win::WS_OVERLAPPEDWINDOW) + .pos(win::CW_USEDEFAULT, win::CW_USEDEFAULT) + .size( + style + .horizontal_size_request + .and_then(|i| i.try_into().ok()) + .unwrap_or(win::CW_USEDEFAULT), + style + .vertical_size_request + .and_then(|i| i.try_into().ok()) + .unwrap_or(win::CW_USEDEFAULT), + ) + .create() +} + +window::basic_window_classes! { + /// Static control (text, image, etc) class. + struct Static => "STATIC"; + + /// Button control class. + struct Button => "BUTTON"; + + /// Edit control class. + struct Edit => "EDIT"; + + /// Progress control class. + struct Progress => "msctls_progress32"; +} + +/// A top-level application window. +/// +/// This is used for the main window and modal windows. +struct AppWindow { + renderer: WindowRenderer, + _quit_token: Option<QuitToken>, +} + +impl AppWindow { + pub fn new(renderer: WindowRenderer, quit_token: Option<QuitToken>) -> Self { + AppWindow { + renderer, + _quit_token: quit_token, + } + } +} + +impl window::WindowClass for AppWindow { + fn class_name() -> WideString { + WideString::new("App Window") + } +} + +impl CustomWindowClass for AppWindow { + fn icon() -> win::HICON { + static ICON: Lazy<win::HICON> = Lazy::new(|| unsafe { + // If CreateIconFromResource fails it returns NULL, which is fine (a default icon will be + // used). + win::CreateIconFromResource( + // We take advantage of the fact that since Windows Vista, an RT_ICON resource entry + // can simply be a PNG image. + super::icon::PNG_DATA.as_ptr(), + super::icon::PNG_DATA.len() as u32, + true.into(), + // The 0x00030000 constant isn't available anywhere; the docs basically say to just + // pass it... + 0x00030000, + ) + }); + + *ICON + } + + fn message( + data: &RefCell<Self>, + hwnd: HWND, + umsg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> Option<LRESULT> { + let me = data.borrow(); + let model = me.renderer.model(); + match umsg { + win::WM_CREATE => { + if let Some(close) = &model.close { + close.subscribe(move |&()| unsafe { + win::SendMessageW(hwnd, win::WM_CLOSE, 0, 0); + }); + } + + let mut renderer = me.renderer.child_renderer(hwnd); + if let Some(child) = &model.content { + renderer.render_child(child); + } + + drop(model); + let children = std::mem::take(&mut me.renderer.model_mut().children); + for child in children { + renderer.render_window(child); + } + } + win::WM_CLOSE => { + if model.modal { + // Modal windows should hide themselves rather than closing/destroying. + unsafe { win::ShowWindow(hwnd, win::SW_HIDE) }; + return Some(0); + } + } + win::WM_SHOWWINDOW => { + if model.modal { + // Modal windows should disable/enable their parent as they are shown/hid, + // respectively. + let shown = wparam != 0; + unsafe { + KeyboardAndMouse::EnableWindow( + win::GetWindow(hwnd, win::GW_OWNER), + (!shown).into(), + ) + }; + return Some(0); + } + } + win::WM_GETMINMAXINFO => { + let minmaxinfo = unsafe { (lparam as *mut win::MINMAXINFO).as_mut().unwrap() }; + minmaxinfo.ptMinTrackSize.x = me.renderer.min_size.0.try_into().unwrap(); + minmaxinfo.ptMinTrackSize.y = me.renderer.min_size.1.try_into().unwrap(); + return Some(0); + } + win::WM_SIZE => { + // When resized, recompute the layout. + let width = loword(lparam as _) as u32; + let height = hiword(lparam as _) as u32; + + if let Some(child) = &model.content { + me.renderer.layout(child, width, height); + unsafe { Gdi::UpdateWindow(hwnd) }; + } + return Some(0); + } + win::WM_GETFONT => return Some(**me.renderer.font() as _), + win::WM_COMMAND => { + let child = lparam as HWND; + let windows = me.renderer.windows.borrow(); + if let Some(&element) = windows.reverse().get(&child) { + // # Safety + // The ElementRefs all pertain to the model stored in the renderer. + let element = unsafe { element.get() }; + // Handle button presses. + use model::ElementType::*; + match &element.element_type { + Button(model::Button { click, .. }) => { + let code = hiword(wparam as _) as u32; + if code == win::BN_CLICKED { + click.fire(&()); + return Some(0); + } + } + Checkbox(model::Checkbox { checked, .. }) => { + let code = hiword(wparam as _) as u32; + if code == win::BN_CLICKED { + let check_state = + unsafe { win::SendMessageW(child, win::BM_GETCHECK, 0, 0) }; + if let Property::Binding(s) = checked { + *s.borrow_mut() = check_state == Controls::BST_CHECKED as isize; + } + return Some(0); + } + } + _ => (), + } + } + } + _ => (), + } + None + } +} + +/// State used while creating and updating windows. +struct WindowRenderer { + // We wrap with an Rc to get weak references in property callbacks (like that of + // `ElementStyle::visible`). + inner: Rc<WindowRendererInner>, +} + +impl std::ops::Deref for WindowRenderer { + type Target = WindowRendererInner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +struct WindowRendererInner { + pub module: HINSTANCE, + /// The model is pinned and boxed to ensure that references in `windows` remain valid. + /// + /// We need to keep the model around so we can correctly perform layout as the window size + /// changes. Unfortunately the win32 API doesn't have any nice ways to automatically perform + /// layout. + pub model: RefCell<Pin<Box<model::Window>>>, + pub min_size: (u32, u32), + /// Mapping between model elements and windows. + /// + /// Element references pertain to elements in `model`. + pub windows: RefCell<twoway::TwoWay<ElementRef, HWND>>, + pub font: Font, + pub bold_font: Font, +} + +impl WindowRenderer { + pub fn new(module: HINSTANCE, model: model::Window, style: &model::ElementStyle) -> Self { + WindowRenderer { + inner: Rc::new(WindowRendererInner { + module, + model: RefCell::new(Box::pin(model)), + min_size: ( + style.horizontal_size_request.unwrap_or(0), + style.vertical_size_request.unwrap_or(0), + ), + windows: Default::default(), + font: Font::caption(), + bold_font: Font::caption_bold().unwrap_or_else(Font::caption), + }), + } + } + + pub fn child_renderer(&self, window: HWND) -> WindowChildRenderer { + WindowChildRenderer { + renderer: &self.inner, + window, + child_id: 0, + scroll: false, + } + } + + pub fn layout(&self, element: &Element, max_width: u32, max_height: u32) { + layout::Layout::new(self.inner.windows.borrow().forward()) + .layout(element, max_width, max_height); + } + + pub fn model(&self) -> std::cell::Ref<'_, model::Window> { + std::cell::Ref::map(self.inner.model.borrow(), |b| &**b) + } + + pub fn model_mut(&self) -> std::cell::RefMut<'_, model::Window> { + std::cell::RefMut::map(self.inner.model.borrow_mut(), |b| &mut **b) + } + + pub fn font(&self) -> &Font { + &self.inner.font + } +} + +struct WindowChildRenderer<'a> { + renderer: &'a Rc<WindowRendererInner>, + window: HWND, + child_id: i32, + scroll: bool, +} + +impl<'a> WindowChildRenderer<'a> { + fn add_child<W: window::WindowClass>(&mut self, class: W) -> WindowBuilder<W> { + let builder = class + .builder(self.renderer.module) + .style(win::WS_CHILD | win::WS_VISIBLE) + .parent(self.window) + .child_id(self.child_id); + self.child_id += 1; + builder + } + + fn add_window<W: window::WindowClass>(&mut self, class: W) -> WindowBuilder<W> { + class + .builder(self.renderer.module) + .style(win::WS_OVERLAPPEDWINDOW) + .pos(win::CW_USEDEFAULT, win::CW_USEDEFAULT) + .parent(self.window) + } + + fn render_window(&mut self, model: TypedElement<model::Window>) -> Window { + let name = WideString::new(model.element_type.title.as_str()); + let style = model.style; + let w = self + .add_window(AppWindow::new( + WindowRenderer::new(self.renderer.module, model.element_type, &style), + None, + )) + .size( + style + .horizontal_size_request + .and_then(|i| i.try_into().ok()) + .unwrap_or(win::CW_USEDEFAULT), + style + .vertical_size_request + .and_then(|i| i.try_into().ok()) + .unwrap_or(win::CW_USEDEFAULT), + ) + .name(&name) + .create(); + + enabled_property(&style.enabled, w.handle); + + let hwnd = w.handle; + let set_visible = move |visible| unsafe { + win::ShowWindow(hwnd, if visible { win::SW_SHOW } else { win::SW_HIDE }); + }; + + match &style.visible { + Property::Static(false) => set_visible(false), + Property::Binding(s) => { + s.on_change(move |v| set_visible(*v)); + if !*s.borrow() { + set_visible(false); + } + } + _ => (), + } + + w.generic() + } + + fn render_child(&mut self, element: &Element) { + if let Some(mut window) = self.render_element_type(&element.element_type) { + window.set_default_font(&self.renderer.font); + + // Store the element to handle mapping. + self.renderer + .windows + .borrow_mut() + .insert(ElementRef::new(element), window.handle); + + enabled_property(&element.style.enabled, window.handle); + } + + // Handle visibility properties. + match &element.style.visible { + Property::Static(false) => { + set_visibility(element, false, self.renderer.windows.borrow().forward()) + } + Property::Binding(s) => { + let weak_renderer = Rc::downgrade(self.renderer); + let element_ref = ElementRef::new(element); + let parent = self.window; + s.on_change(move |visible| { + let Some(renderer) = weak_renderer.upgrade() else { + return; + }; + // # Safety + // ElementRefs are valid as long as the renderer is (and we have a strong + // reference to it). + let element = unsafe { element_ref.get() }; + set_visibility(element, *visible, renderer.windows.borrow().forward()); + // Send WM_SIZE so that the parent recomputes the layout. + unsafe { + let mut rect = std::mem::zeroed::<RECT>(); + win::GetClientRect(parent, &mut rect); + win::SendMessageW( + parent, + win::WM_SIZE, + 0, + makelong( + (rect.right - rect.left) as u16, + (rect.bottom - rect.top) as u16, + ) as isize, + ); + } + }); + if !*s.borrow() { + set_visibility(element, false, self.renderer.windows.borrow().forward()); + } + } + _ => (), + } + } + + fn render_element_type(&mut self, element_type: &model::ElementType) -> Option<Window> { + use model::ElementType as ET; + match element_type { + ET::Label(model::Label { text, bold }) => { + let mut window = match text { + Property::Static(text) => { + let text = WideString::new(text.as_str()); + self.add_child(Static) + .name(&text) + .add_style(SystemServices::SS_LEFT | SystemServices::SS_NOPREFIX) + .create() + } + Property::Binding(b) => { + let text = WideString::new(b.borrow().as_str()); + let window = self + .add_child(Static) + .name(&text) + .add_style(SystemServices::SS_LEFT | SystemServices::SS_NOPREFIX) + .create(); + let handle = window.handle; + b.on_change(move |text| { + let text = WideString::new(text.as_str()); + unsafe { win::SetWindowTextW(handle, text.pcwstr()) }; + }); + window + } + Property::ReadOnly(_) => { + unimplemented!("ReadOnly property not supported for Label::text") + } + }; + if *bold { + window.set_font(&self.renderer.bold_font); + } + Some(window.generic()) + } + ET::TextBox(model::TextBox { + placeholder, + content, + editable, + }) => { + let scroll = self.scroll; + let window = self + .add_child(Edit) + .add_style( + (win::ES_LEFT + | win::ES_MULTILINE + | win::ES_WANTRETURN + | if *editable { 0 } else { win::ES_READONLY }) + as u32 + | win::WS_BORDER + | win::WS_TABSTOP + | if scroll { win::WS_VSCROLL } else { 0 }, + ) + .create(); + + fn to_control_text(s: &str) -> String { + s.replace("\n", "\r\n") + } + + fn from_control_text(s: &str) -> String { + s.replace("\r\n", "\n") + } + + struct SubClassData { + placeholder: Option<WideString>, + } + + // EM_SETCUEBANNER doesn't work with multiline edit controls (for no particular + // reason?), so we have to draw it ourselves. + unsafe extern "system" fn subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + _uidsubclass: usize, + dw_ref_data: usize, + ) -> LRESULT { + let ret = Shell::DefSubclassProc(hwnd, msg, wparam, lparam); + if msg == win::WM_PAINT + && KeyboardAndMouse::GetFocus() != hwnd + && win::GetWindowTextLengthW(hwnd) == 0 + { + let data = (dw_ref_data as *const SubClassData).as_ref().unwrap(); + if let Some(placeholder) = &data.placeholder { + let mut rect = std::mem::zeroed::<RECT>(); + win::GetClientRect(hwnd, &mut rect); + Gdi::InflateRect(&mut rect, -2, -2); + + let dc = gdi::DC::new(hwnd).expect("failed to create GDI DC"); + dc.with_object_selected( + win::SendMessageW(hwnd, win::WM_GETFONT, 0, 0) as _, + |hdc| { + Gdi::SetTextColor( + hdc, + Controls::GetThemeSysColor(0, Gdi::COLOR_GRAYTEXT), + ); + Gdi::SetBkMode(hdc, Gdi::TRANSPARENT as i32); + success!(nonzero Gdi::DrawTextW( + hdc, + placeholder.pcwstr(), + -1, + &mut rect, + Gdi::DT_LEFT | Gdi::DT_TOP | Gdi::DT_WORDBREAK, + )); + }, + ) + .expect("failed to select font gdi object"); + } + } + + // Multiline edit controls capture the tab key. We want it to work as usual in + // the dialog (focusing the next input control). + if msg == win::WM_GETDLGCODE && wparam == KeyboardAndMouse::VK_TAB as usize { + return 0; + } + + if msg == win::WM_DESTROY { + drop(unsafe { Box::from_raw(dw_ref_data as *mut SubClassData) }); + } + return ret; + } + + let subclassdata = Box::into_raw(Box::new(SubClassData { + placeholder: placeholder + .as_ref() + .map(|s| WideString::new(to_control_text(s))), + })); + + unsafe { + Shell::SetWindowSubclass( + window.handle, + Some(subclass_proc), + 0, + subclassdata as _, + ); + } + + // Set up content property. + match content { + Property::ReadOnly(od) => { + let handle = window.handle; + od.register(move |target| { + // GetWindowText requires the buffer be large enough for the terminating + // null character (otherwise it truncates the string), but + // GetWindowTextLength returns the length without the null character, so we + // add 1. + let length = unsafe { win::GetWindowTextLengthW(handle) } + 1; + let mut buf = vec![0u16; length as usize]; + unsafe { win::GetWindowTextW(handle, buf.as_mut_ptr(), length) }; + buf.pop(); // null character; `String` doesn't want that + *target = from_control_text(&String::from_utf16_lossy(&buf)); + }); + } + Property::Static(s) => { + let text = WideString::new(to_control_text(s)); + unsafe { win::SetWindowTextW(window.handle, text.pcwstr()) }; + } + Property::Binding(b) => { + let handle = window.handle; + b.on_change(move |text| { + let text = WideString::new(to_control_text(text.as_str())); + unsafe { win::SetWindowTextW(handle, text.pcwstr()) }; + }); + let text = WideString::new(to_control_text(b.borrow().as_str())); + unsafe { win::SetWindowTextW(window.handle, text.pcwstr()) }; + } + } + Some(window.generic()) + } + ET::Scroll(model::Scroll { content }) => { + if let Some(content) = content { + // Scrolling is implemented in a cooperative, non-universal way right now. + self.scroll = true; + self.render_child(content); + self.scroll = false; + } + None + } + ET::Button(model::Button { content, .. }) => { + if let Some(ET::Label(model::Label { + text: Property::Static(text), + .. + })) = content.as_ref().map(|e| &e.element_type) + { + let text = WideString::new(text); + + let window = self + .add_child(Button) + .add_style(win::BS_PUSHBUTTON as u32 | win::WS_TABSTOP) + .name(&text) + .create(); + Some(window.generic()) + } else { + None + } + } + ET::Checkbox(model::Checkbox { checked, label }) => { + let label = label.as_ref().map(WideString::new); + let mut builder = self + .add_child(Button) + .add_style((win::BS_AUTOCHECKBOX | win::BS_MULTILINE) as u32 | win::WS_TABSTOP); + if let Some(label) = &label { + builder = builder.name(label); + } + let window = builder.create(); + + fn set_check(handle: HWND, value: bool) { + unsafe { + win::SendMessageW( + handle, + win::BM_SETCHECK, + if value { + Controls::BST_CHECKED + } else { + Controls::BST_UNCHECKED + } as usize, + 0, + ); + } + } + + match checked { + Property::Static(checked) => set_check(window.handle, *checked), + Property::Binding(s) => { + let handle = window.handle; + s.on_change(move |v| { + set_check(handle, *v); + }); + set_check(window.handle, *s.borrow()); + } + _ => unimplemented!("ReadOnly properties not supported for Checkbox"), + } + + Some(window.generic()) + } + ET::Progress(model::Progress { amount }) => { + let window = self + .add_child(Progress) + .add_style(Controls::PBS_MARQUEE) + .create(); + + fn set_amount(handle: HWND, value: Option<f32>) { + match value { + None => unsafe { + win::SendMessageW(handle, Controls::PBM_SETMARQUEE, 1, 0); + }, + Some(v) => unsafe { + win::SendMessageW(handle, Controls::PBM_SETMARQUEE, 0, 0); + win::SendMessageW( + handle, + Controls::PBM_SETPOS, + (v.clamp(0f32, 1f32) * 100f32) as usize, + 0, + ); + }, + } + } + + match amount { + Property::Static(v) => set_amount(window.handle, *v), + Property::Binding(s) => { + let handle = window.handle; + s.on_change(move |v| set_amount(handle, *v)); + set_amount(window.handle, *s.borrow()); + } + _ => unimplemented!("ReadOnly properties not supported for Progress"), + } + + Some(window.generic()) + } + // VBox/HBox are virtual, their behaviors are implemented entirely in the renderer layout. + // No need for additional windows. + ET::VBox(model::VBox { items, .. }) => { + for item in items { + self.render_child(item); + } + None + } + ET::HBox(model::HBox { items, .. }) => { + for item in items { + self.render_child(item); + } + None + } + } + } +} + +/// Handle the enabled property. +/// +/// This function assumes the default state of the window is enabled. +fn enabled_property(enabled: &Property<bool>, window: HWND) { + match enabled { + Property::Static(false) => unsafe { + KeyboardAndMouse::EnableWindow(window, false.into()); + }, + Property::Binding(s) => { + let handle = window; + s.on_change(move |enabled| { + unsafe { KeyboardAndMouse::EnableWindow(handle, (*enabled).into()) }; + }); + if !*s.borrow() { + unsafe { KeyboardAndMouse::EnableWindow(window, false.into()) }; + } + } + _ => (), + } +} + +/// Set the visibility of the given element. This recurses down the element tree and hides children +/// as necessary. +fn set_visibility(element: &Element, visible: bool, windows: &HashMap<ElementRef, HWND>) { + if let Some(&hwnd) = windows.get(&ElementRef::new(element)) { + unsafe { + win::ShowWindow(hwnd, if visible { win::SW_SHOW } else { win::SW_HIDE }); + } + } else { + match &element.element_type { + model::ElementType::VBox(model::VBox { items, .. }) => { + for item in items { + set_visibility(item, visible, windows); + } + } + model::ElementType::HBox(model::HBox { items, .. }) => { + for item in items { + set_visibility(item, visible, windows); + } + } + model::ElementType::Scroll(model::Scroll { + content: Some(content), + }) => { + set_visibility(&*content, visible, windows); + } + _ => (), + } + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/quit_token.rs b/toolkit/crashreporter/client/app/src/ui/windows/quit_token.rs new file mode 100644 index 0000000000..f952db3db4 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/quit_token.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/. */ + +use std::rc::Rc; +use windows_sys::Win32::UI::WindowsAndMessaging::PostQuitMessage; + +/// A Cloneable token which will post a quit message (with code 0) to the main loop when the last +/// instance is dropped. +#[derive(Clone, Default)] +pub struct QuitToken(#[allow(dead_code)] Rc<QuitTokenInternal>); + +impl QuitToken { + pub fn new() -> Self { + Self::default() + } +} + +impl std::fmt::Debug for QuitToken { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct(std::any::type_name::<Self>()) + .finish_non_exhaustive() + } +} + +#[derive(Default)] +struct QuitTokenInternal; + +impl Drop for QuitTokenInternal { + fn drop(&mut self) { + unsafe { PostQuitMessage(0) }; + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/twoway.rs b/toolkit/crashreporter/client/app/src/ui/windows/twoway.rs new file mode 100644 index 0000000000..8a18162e08 --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/twoway.rs @@ -0,0 +1,36 @@ +/* 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::collections::HashMap; + +/// A two-way hashmap. +#[derive(Debug)] +pub struct TwoWay<K, V> { + forward: HashMap<K, V>, + reverse: HashMap<V, K>, +} + +impl<K, V> Default for TwoWay<K, V> { + fn default() -> Self { + TwoWay { + forward: Default::default(), + reverse: Default::default(), + } + } +} + +impl<K: Eq + std::hash::Hash + Clone, V: Eq + std::hash::Hash + Clone> TwoWay<K, V> { + pub fn insert(&mut self, key: K, value: V) { + self.forward.insert(key.clone(), value.clone()); + self.reverse.insert(value, key); + } + + pub fn forward(&self) -> &HashMap<K, V> { + &self.forward + } + + pub fn reverse(&self) -> &HashMap<V, K> { + &self.reverse + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/widestring.rs b/toolkit/crashreporter/client/app/src/ui/windows/widestring.rs new file mode 100644 index 0000000000..0dc713352b --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/widestring.rs @@ -0,0 +1,36 @@ +/* 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::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use windows_sys::core::PCWSTR; + +/// Windows wide strings. +/// +/// These are utf16 encoded with a terminating null character (0). +pub struct WideString(Vec<u16>); + +impl WideString { + pub fn new(os_str: impl AsRef<OsStr>) -> Self { + // TODO: doesn't check whether the OsStr contains a null character, which could be treated + // as an error (as `CString::new` does). + WideString( + os_str + .as_ref() + .encode_wide() + .chain(std::iter::once(0)) + // Remove unicode BIDI markers (from fluent) which aren't rendered correctly. + .filter(|c| *c != 0x2068 && *c != 0x2069) + .collect(), + ) + } + + pub fn pcwstr(&self) -> PCWSTR { + self.0.as_ptr() + } + + pub fn as_slice(&self) -> &[u16] { + &self.0 + } +} diff --git a/toolkit/crashreporter/client/app/src/ui/windows/window.rs b/toolkit/crashreporter/client/app/src/ui/windows/window.rs new file mode 100644 index 0000000000..7e5e8f3f2a --- /dev/null +++ b/toolkit/crashreporter/client/app/src/ui/windows/window.rs @@ -0,0 +1,302 @@ +/* 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/. */ + +//! Types and helpers relating to windows and window classes. + +use super::Font; +use super::WideString; +use std::cell::RefCell; +use windows_sys::Win32::{ + Foundation::{HINSTANCE, HWND, LPARAM, LRESULT, WPARAM}, + Graphics::Gdi::{self, HBRUSH}, + UI::WindowsAndMessaging::{self as win, HCURSOR, HICON}, +}; + +/// Types representing a window class. +pub trait WindowClass: Sized + 'static { + fn class_name() -> WideString; + + fn builder(self, module: HINSTANCE) -> WindowBuilder<'static, Self> { + WindowBuilder { + name: None, + style: None, + x: 0, + y: 0, + width: 0, + height: 0, + parent: None, + child_id: 0, + module, + data: self, + } + } +} + +/// Window classes which have their own message handler. +/// +/// A type implementing this trait provides its data to a single window. +/// +/// `register` must be called before use. +pub trait CustomWindowClass: WindowClass { + /// Handle a message. + fn message( + data: &RefCell<Self>, + hwnd: HWND, + umsg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> Option<LRESULT>; + + /// The class's default background brush. + fn background() -> HBRUSH { + (Gdi::COLOR_3DFACE + 1) as HBRUSH + } + + /// The class's default cursor. + fn cursor() -> HCURSOR { + unsafe { win::LoadCursorW(0, win::IDC_ARROW) } + } + + /// The class's default icon. + fn icon() -> HICON { + 0 + } + + /// Register the class. + fn register(module: HINSTANCE) -> anyhow::Result<()> { + unsafe extern "system" fn wnd_proc<W: CustomWindowClass>( + hwnd: HWND, + umsg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + if umsg == win::WM_CREATE { + let create_struct = &*(lparam as *const win::CREATESTRUCTW); + success!(lasterror win::SetWindowLongPtrW(hwnd, 0, create_struct.lpCreateParams as _)); + // Changes made with SetWindowLongPtr don't take effect until SetWindowPos is called... + success!(nonzero win::SetWindowPos( + hwnd, + win::HWND_TOP, + 0, + 0, + 0, + 0, + win::SWP_NOMOVE | win::SWP_NOSIZE | win::SWP_NOZORDER | win::SWP_FRAMECHANGED, + )); + } + + let result = unsafe { W::get(hwnd).as_ref() } + .and_then(|data| W::message(data, hwnd, umsg, wparam, lparam)); + if umsg == win::WM_DESTROY { + drop(Box::from_raw( + win::GetWindowLongPtrW(hwnd, 0) as *mut RefCell<W> + )); + } + result.unwrap_or_else(|| win::DefWindowProcW(hwnd, umsg, wparam, lparam)) + } + + let class_name = Self::class_name(); + let window_class = win::WNDCLASSW { + lpfnWndProc: Some(wnd_proc::<Self>), + hInstance: module, + lpszClassName: class_name.pcwstr(), + hbrBackground: Self::background(), + hIcon: Self::icon(), + hCursor: Self::cursor(), + cbWndExtra: std::mem::size_of::<isize>() as i32, + ..unsafe { std::mem::zeroed() } + }; + + if unsafe { win::RegisterClassW(&window_class) } == 0 { + anyhow::bail!("RegisterClassW failed") + } + Ok(()) + } + + /// Get the window data from a window created with this class. + /// + /// # Safety + /// This must only be called on window handles which were created with this class. + unsafe fn get(hwnd: HWND) -> *const RefCell<Self> { + win::GetWindowLongPtrW(hwnd, 0) as *const RefCell<Self> + } +} + +/// Types that can be stored as associated window data. +pub trait WindowData: Sized { + fn to_ptr(self) -> *mut RefCell<Self> { + std::ptr::null_mut() + } +} + +impl<T: CustomWindowClass> WindowData for T { + fn to_ptr(self) -> *mut RefCell<Self> { + Box::into_raw(Box::new(RefCell::new(self))) + } +} + +macro_rules! basic_window_classes { + () => {}; + ( $(#[$attr:meta])* struct $name:ident => $class:expr; $($rest:tt)* ) => { + #[derive(Default)] + $(#[$attr])* + struct $name; + + impl $crate::ui::ui_impl::window::WindowClass for $name { + fn class_name() -> $crate::ui::ui_impl::WideString { + $crate::ui::ui_impl::WideString::new($class) + } + } + + impl $crate::ui::ui_impl::window::WindowData for $name {} + + $crate::ui::ui_impl::window::basic_window_classes!($($rest)*); + } +} + +pub(crate) use basic_window_classes; + +pub struct WindowBuilder<'a, W> { + name: Option<&'a WideString>, + style: Option<u32>, + x: i32, + y: i32, + width: i32, + height: i32, + parent: Option<HWND>, + child_id: i32, + module: HINSTANCE, + data: W, +} + +impl<'a, W> WindowBuilder<'a, W> { + #[must_use] + pub fn name<'b>(self, s: &'b WideString) -> WindowBuilder<'b, W> { + WindowBuilder { + name: Some(s), + style: self.style, + x: self.x, + y: self.y, + width: self.width, + height: self.height, + parent: self.parent, + child_id: self.child_id, + module: self.module, + data: self.data, + } + } + + #[must_use] + pub fn style(mut self, d: u32) -> Self { + self.style = Some(d); + self + } + + #[must_use] + pub fn add_style(mut self, d: u32) -> Self { + *self.style.get_or_insert(0) |= d; + self + } + + #[must_use] + pub fn pos(mut self, x: i32, y: i32) -> Self { + self.x = x; + self.y = y; + self + } + + #[must_use] + pub fn size(mut self, width: i32, height: i32) -> Self { + self.width = width; + self.height = height; + self + } + + #[must_use] + pub fn parent(mut self, parent: HWND) -> Self { + self.parent = Some(parent); + self + } + + #[must_use] + pub fn child_id(mut self, id: i32) -> Self { + self.child_id = id; + self + } + + pub fn create(self) -> Window<W> + where + W: WindowClass + WindowData, + { + let class_name = W::class_name(); + let handle = unsafe { + win::CreateWindowExW( + 0, + class_name.pcwstr(), + self.name.map(|n| n.pcwstr()).unwrap_or(std::ptr::null()), + self.style.unwrap_or_default(), + self.x, + self.y, + self.width, + self.height, + self.parent.unwrap_or_default(), + self.child_id as _, + self.module, + self.data.to_ptr() as _, + ) + }; + assert!(handle != 0); + + Window { + handle, + child_id: self.child_id, + font_set: false, + _class: std::marker::PhantomData, + } + } +} + +/// A window handle with a known class type. +/// +/// Without a type parameter (defaulting to `()`), the window handle is generic (class type +/// unknown). +pub struct Window<W: 'static = ()> { + pub handle: HWND, + pub child_id: i32, + font_set: bool, + _class: std::marker::PhantomData<&'static RefCell<W>>, +} + +impl<W: CustomWindowClass> Window<W> { + /// Get the window data of the window. + #[allow(dead_code)] + pub fn data(&self) -> &RefCell<W> { + unsafe { W::get(self.handle).as_ref().unwrap() } + } +} + +impl<W> Window<W> { + /// Get a generic window handle. + pub fn generic(self) -> Window { + Window { + handle: self.handle, + child_id: self.child_id, + font_set: self.font_set, + _class: std::marker::PhantomData, + } + } + + /// Set a window's font. + pub fn set_font(&mut self, font: &Font) { + unsafe { win::SendMessageW(self.handle, win::WM_SETFONT, **font as _, 1 as _) }; + self.font_set = true; + } + + /// Set a window's font if not already set. + pub fn set_default_font(&mut self, font: &Font) { + if !self.font_set { + self.set_font(font); + } + } +} |