diff options
Diffstat (limited to '')
91 files changed, 11466 insertions, 5196 deletions
diff --git a/toolkit/crashreporter/client/Makefile.in b/toolkit/crashreporter/client/Makefile.in deleted file mode 100644 index 2d3ef1bee9..0000000000 --- a/toolkit/crashreporter/client/Makefile.in +++ /dev/null @@ -1,19 +0,0 @@ -# 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),WINNT) -MOZ_WINCONSOLE = 0 -endif - -include $(topsrcdir)/config/rules.mk - -ifeq ($(OS_ARCH),Darwin) -libs:: - $(NSINSTALL) -D $(DIST)/bin/crashreporter.app - rsync -a -C --exclude '*.in' $(srcdir)/macbuild/Contents $(DIST)/bin/crashreporter.app - $(call py_action,preprocessor crashreporter.app/Contents/Resources/English.lproj/InfoPlist.strings,-Fsubstitution --output-encoding utf-16 -DAPP_NAME='$(MOZ_APP_DISPLAYNAME)' $(srcdir)/macbuild/Contents/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/Throbber-small.avi b/toolkit/crashreporter/client/Throbber-small.avi Binary files differdeleted file mode 100644 index 640ea62c0e..0000000000 --- a/toolkit/crashreporter/client/Throbber-small.avi +++ /dev/null diff --git a/toolkit/crashreporter/client/Throbber-small.gif b/toolkit/crashreporter/client/Throbber-small.gif Binary files differdeleted file mode 100644 index cce32f20f4..0000000000 --- a/toolkit/crashreporter/client/Throbber-small.gif +++ /dev/null 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); + } + } +} diff --git a/toolkit/crashreporter/client/cocoabind/Cargo.toml b/toolkit/crashreporter/client/cocoabind/Cargo.toml new file mode 100644 index 0000000000..70a0f36582 --- /dev/null +++ b/toolkit/crashreporter/client/cocoabind/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cocoabind" +version = "0.1.0" +edition = "2021" + +[dependencies] +block = "0.1" +objc = "0.2" + +[build-dependencies] +bindgen = { version = "0.69", default-features = false, features = ["runtime"] } +mozbuild = "0.1.0" diff --git a/toolkit/crashreporter/client/cocoabind/build.rs b/toolkit/crashreporter/client/cocoabind/build.rs new file mode 100644 index 0000000000..ba91a87e97 --- /dev/null +++ b/toolkit/crashreporter/client/cocoabind/build.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 mozbuild::config::CC_BASE_FLAGS as CFLAGS; + +const TYPES: &[&str] = &[ + "ActionCell", + "Application", + "Array", + "AttributedString", + "Box", + "Button", + "ButtonCell", + "Cell", + "ClassDescription", + "Control", + "DefaultRunLoopMode", + "Dictionary", + "ForegroundColorAttributeName", + "LayoutDimension", + "LayoutGuide", + "LayoutXAxisAnchor", + "LayoutYAxisAnchor", + "MutableAttributedString", + "MutableParagraphStyle", + "MutableString", + "ModalPanelRunLoopMode", + "Panel", + "ProcessInfo", + "ProgressIndicator", + "Proxy", + "RunLoop", + "ScrollView", + "StackView", + "String", + "TextField", + "TextView", + "Value", + "View", + "Window", +]; + +fn main() { + let mut builder = bindgen::Builder::default() + .header_contents( + "cocoa_bindings.h", + "#define self self_ + #import <Cocoa/Cocoa.h> + ", + ) + .generate_block(true) + .prepend_enum_name(false) + .clang_args(CFLAGS) + .clang_args(["-x", "objective-c"]) + .clang_arg("-fblocks") + .derive_default(true) + .allowlist_item("TransformProcessType"); + for name in TYPES { + // (I|P) covers generated traits (interfaces and protocols). `(_.*)?` covers categories + // (which are generated as `CLASS_CATEGORY`). + builder = builder.allowlist_item(format!("(I|P)?NS{name}(_.*)?")); + } + let bindings = builder + .generate() + .expect("unable to generate cocoa bindings"); + let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); + bindings + .write_to_file(out_path.join("cocoa_bindings.rs")) + .expect("failed to write cocoa bindings"); + println!("cargo:rustc-link-lib=framework=AppKit"); + println!("cargo:rustc-link-lib=framework=Cocoa"); + println!("cargo:rustc-link-lib=framework=Foundation"); +} diff --git a/toolkit/crashreporter/client/cocoabind/src/lib.rs b/toolkit/crashreporter/client/cocoabind/src/lib.rs new file mode 100644 index 0000000000..b7271d9d92 --- /dev/null +++ b/toolkit/crashreporter/client/cocoabind/src/lib.rs @@ -0,0 +1,10 @@ +/* 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/. */ + +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(unused_imports)] + +include!(concat!(env!("OUT_DIR"), "/cocoa_bindings.rs")); diff --git a/toolkit/crashreporter/client/crashreporter.cpp b/toolkit/crashreporter/client/crashreporter.cpp deleted file mode 100644 index 2887b16170..0000000000 --- a/toolkit/crashreporter/client/crashreporter.cpp +++ /dev/null @@ -1,852 +0,0 @@ -/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ -/* 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/. */ - -#include "crashreporter.h" - -#ifdef _MSC_VER -// Disable exception handler warnings. -# pragma warning(disable : 4530) -#endif - -#include <fstream> -#include <iomanip> -#include <sstream> -#include <memory> -#include <ctime> -#include <cstdlib> -#include <cstring> -#include <string> -#include <utility> - -#ifdef XP_LINUX -# include <dlfcn.h> -#endif - -#include "json/json.h" -#include "nss.h" -#include "sechash.h" - -using std::ifstream; -using std::ios; -using std::istream; -using std::istringstream; -using std::ofstream; -using std::ostream; -using std::ostringstream; -using std::string; -using std::unique_ptr; -using std::vector; - -namespace CrashReporter { - -StringTable gStrings; -Json::Value gData; -string gSettingsPath; -string gEventsPath; -string gPingPath; -int gArgc; -char** gArgv; -bool gAutoSubmit; - -enum SubmissionResult { Succeeded, Failed }; - -static unique_ptr<ofstream> gLogStream(nullptr); -static string gReporterDumpFile; -static string gExtraFile; -static string gMemoryFile; - -static const char kExtraDataExtension[] = ".extra"; -static const char kMemoryReportExtension[] = ".memory.json.gz"; - -void UIError(const string& message) { - if (gAutoSubmit) { - return; - } - - string errorMessage; - if (!gStrings[ST_CRASHREPORTERERROR].empty()) { - char buf[2048]; - snprintf(buf, 2048, gStrings[ST_CRASHREPORTERERROR].c_str(), - message.c_str()); - errorMessage = buf; - } else { - errorMessage = message; - } - - UIError_impl(errorMessage); -} - -static string Unescape(const string& str) { - string ret; - for (string::const_iterator iter = str.begin(); iter != str.end(); iter++) { - if (*iter == '\\') { - iter++; - if (*iter == '\\') { - ret.push_back('\\'); - } else if (*iter == 'n') { - ret.push_back('\n'); - } else if (*iter == 't') { - ret.push_back('\t'); - } - } else { - ret.push_back(*iter); - } - } - - return ret; -} - -bool ReadStrings(istream& in, StringTable& strings, bool unescape) { - while (!in.eof()) { - string line; - std::getline(in, line); - int sep = line.find('='); - if (sep >= 0) { - string key, value; - key = line.substr(0, sep); - value = line.substr(sep + 1); - if (unescape) value = Unescape(value); - strings[key] = value; - } - } - - return true; -} - -bool ReadStringsFromFile(const string& path, StringTable& strings, - bool unescape) { - ifstream* f = UIOpenRead(path, ios::in); - bool success = false; - if (f->is_open()) { - success = ReadStrings(*f, strings, unescape); - f->close(); - } - - delete f; - return success; -} - -static bool ReadExtraFile(const string& aExtraDataPath, Json::Value& aExtra) { - bool success = false; - ifstream* f = UIOpenRead(aExtraDataPath, ios::in); - if (f->is_open()) { - Json::CharReaderBuilder builder; - success = parseFromStream(builder, *f, &aExtra, nullptr); - } - - delete f; - return success; -} - -static string Basename(const string& file) { - string::size_type slashIndex = file.rfind(UI_DIR_SEPARATOR); - if (slashIndex != string::npos) { - return file.substr(slashIndex + 1); - } - return file; -} - -static bool ReadEventFile(const string& aPath, string& aEventVersion, - string& aTime, string& aUuid, Json::Value& aData) { - bool res = false; - ifstream* f = UIOpenRead(aPath, ios::binary); - - if (f->is_open()) { - std::getline(*f, aEventVersion, '\n'); - res = f->good(); - std::getline(*f, aTime, '\n'); - res &= f->good(); - std::getline(*f, aUuid, '\n'); - res &= f->good(); - - if (res) { - Json::CharReaderBuilder builder; - res = parseFromStream(builder, *f, &aData, nullptr); - } - } - - delete f; - return res; -} - -static void OverwriteEventFile(const string& aPath, const string& aEventVersion, - const string& aTime, const string& aUuid, - const Json::Value& aData) { - ofstream* f = UIOpenWrite(aPath, ios::binary | ios::trunc); - if (f->is_open()) { - f->write(aEventVersion.c_str(), aEventVersion.length()) << '\n'; - f->write(aTime.c_str(), aTime.length()) << '\n'; - f->write(aUuid.c_str(), aUuid.length()) << '\n'; - - Json::StreamWriterBuilder builder; - builder["indentation"] = ""; - std::unique_ptr<Json::StreamWriter> writer(builder.newStreamWriter()); - writer->write(aData, f); - *f << "\n"; - } - - delete f; -} - -static void UpdateEventFile(const Json::Value& aExtraData, const string& aHash, - const string& aPingUuid) { - if (gEventsPath.empty()) { - // If there is no path for finding the crash event, skip this step. - return; - } - - string localId = CrashReporter::GetDumpLocalID(); - string path = gEventsPath + UI_DIR_SEPARATOR + localId; - string eventVersion; - string crashTime; - string crashUuid; - Json::Value eventData; - - if (!ReadEventFile(path, eventVersion, crashTime, crashUuid, eventData)) { - return; - } - - if (!aHash.empty()) { - eventData["MinidumpSha256Hash"] = aHash; - } - - if (!aPingUuid.empty()) { - eventData["CrashPingUUID"] = aPingUuid; - } - - if (aExtraData.isMember("StackTraces")) { - eventData["StackTraces"] = aExtraData["StackTraces"]; - } - - OverwriteEventFile(path, eventVersion, crashTime, crashUuid, eventData); -} - -static void WriteSubmissionEvent(SubmissionResult result, - const string& remoteId) { - if (gEventsPath.empty()) { - // If there is no path for writing the submission event, skip it. - return; - } - - string localId = CrashReporter::GetDumpLocalID(); - string fpath = gEventsPath + UI_DIR_SEPARATOR + localId + "-submission"; - ofstream* f = UIOpenWrite(fpath, ios::binary); - time_t tm; - time(&tm); - - if (f->is_open()) { - *f << "crash.submission.1\n"; - *f << tm << "\n"; - *f << localId << "\n"; - *f << (result == Succeeded ? "true" : "false") << "\n"; - *f << remoteId; - - f->close(); - } - - delete f; -} - -void LogMessage(const std::string& message) { - if (gLogStream.get()) { - char date[64]; - time_t tm; - time(&tm); - if (strftime(date, sizeof(date) - 1, "%c", localtime(&tm)) == 0) - date[0] = '\0'; - (*gLogStream) << "[" << date << "] " << message << '\n'; - } -} - -static void OpenLogFile() { - string logPath = gSettingsPath + UI_DIR_SEPARATOR + "submit.log"; - gLogStream.reset(UIOpenWrite(logPath, ios::app)); -} - -static bool ReadConfig() { - string iniPath; - if (!UIGetIniPath(iniPath)) { - return false; - } - - if (!ReadStringsFromFile(iniPath, gStrings, true)) return false; - - // See if we have a string override file, if so process it - char* overrideEnv = getenv("MOZ_CRASHREPORTER_STRINGS_OVERRIDE"); - if (overrideEnv && *overrideEnv && UIFileExists(overrideEnv)) - ReadStringsFromFile(overrideEnv, gStrings, true); - - return true; -} - -static string GetAdditionalFilename(const string& dumpfile, - const char* extension) { - string filename(dumpfile); - int dot = filename.rfind('.'); - if (dot < 0) return ""; - - filename.replace(dot, filename.length() - dot, extension); - return filename; -} - -static bool MoveCrashData(const string& toDir, string& dumpfile, - string& extrafile, string& memoryfile) { - if (!UIEnsurePathExists(toDir)) { - UIError(gStrings[ST_ERROR_CREATEDUMPDIR]); - return false; - } - - string newDump = toDir + UI_DIR_SEPARATOR + Basename(dumpfile); - string newExtra = toDir + UI_DIR_SEPARATOR + Basename(extrafile); - string newMemory = toDir + UI_DIR_SEPARATOR + Basename(memoryfile); - - if (!UIMoveFile(dumpfile, newDump)) { - UIError(gStrings[ST_ERROR_DUMPFILEMOVE]); - return false; - } - - if (!UIMoveFile(extrafile, newExtra)) { - UIError(gStrings[ST_ERROR_EXTRAFILEMOVE]); - return false; - } - - if (!memoryfile.empty()) { - // Ignore errors from moving the memory file - if (!UIMoveFile(memoryfile, newMemory)) { - UIDeleteFile(memoryfile); - newMemory.erase(); - } - memoryfile = newMemory; - } - - dumpfile = newDump; - extrafile = newExtra; - - return true; -} - -static bool AddSubmittedReport(const string& serverResponse) { - StringTable responseItems; - istringstream in(serverResponse); - ReadStrings(in, responseItems, false); - - if (responseItems.find("StopSendingReportsFor") != responseItems.end()) { - // server wants to tell us to stop sending reports for a certain version - string reportPath = gSettingsPath + UI_DIR_SEPARATOR + "EndOfLife" + - responseItems["StopSendingReportsFor"]; - - ofstream* reportFile = UIOpenWrite(reportPath, ios::trunc); - if (reportFile->is_open()) { - // don't really care about the contents - *reportFile << 1 << "\n"; - reportFile->close(); - } - delete reportFile; - } - - if (responseItems.find("Discarded") != responseItems.end()) { - // server discarded this report... save it so the user can resubmit it - // manually - return false; - } - - if (responseItems.find("CrashID") == responseItems.end()) return false; - - string submittedDir = gSettingsPath + UI_DIR_SEPARATOR + "submitted"; - if (!UIEnsurePathExists(submittedDir)) { - return false; - } - - string path = - submittedDir + UI_DIR_SEPARATOR + responseItems["CrashID"] + ".txt"; - - ofstream* file = UIOpenWrite(path, ios::trunc); - if (!file->is_open()) { - delete file; - return false; - } - - char buf[1024]; - snprintf(buf, 1024, gStrings["CrashID"].c_str(), - responseItems["CrashID"].c_str()); - *file << buf << "\n"; - - if (responseItems.find("ViewURL") != responseItems.end()) { - snprintf(buf, 1024, gStrings["CrashDetailsURL"].c_str(), - responseItems["ViewURL"].c_str()); - *file << buf << "\n"; - } - - file->close(); - delete file; - - WriteSubmissionEvent(Succeeded, responseItems["CrashID"]); - return true; -} - -void DeleteDump() { - const char* noDelete = getenv("MOZ_CRASHREPORTER_NO_DELETE_DUMP"); - if (!noDelete || *noDelete == '\0') { - if (!gReporterDumpFile.empty()) UIDeleteFile(gReporterDumpFile); - if (!gExtraFile.empty()) UIDeleteFile(gExtraFile); - if (!gMemoryFile.empty()) UIDeleteFile(gMemoryFile); - } -} - -void SendCompleted(bool success, const string& serverResponse) { - if (success) { - if (AddSubmittedReport(serverResponse)) { - DeleteDump(); - } else { - string directory = gReporterDumpFile; - int slashpos = directory.find_last_of("/\\"); - if (slashpos < 2) return; - directory.resize(slashpos); - UIPruneSavedDumps(directory); - WriteSubmissionEvent(Failed, ""); - } - } else { - WriteSubmissionEvent(Failed, ""); - } -} - -static string ComputeDumpHash() { -#ifdef XP_LINUX - // On Linux we rely on the system-provided libcurl which uses nss so we have - // to also use the system-provided nss instead of the ones we have bundled. - const char* libnssNames[] = { - "libnss3.so", -# ifndef HAVE_64BIT_BUILD - // 32-bit versions on 64-bit hosts - "/usr/lib32/libnss3.so", -# endif - }; - void* lib = nullptr; - - for (const char* libname : libnssNames) { - lib = dlopen(libname, RTLD_NOW); - - if (lib) { - break; - } - } - - if (!lib) { - return ""; - } - - SECStatus (*NSS_Initialize)(const char*, const char*, const char*, - const char*, PRUint32); - HASHContext* (*HASH_Create)(HASH_HashType); - void (*HASH_Destroy)(HASHContext*); - void (*HASH_Begin)(HASHContext*); - void (*HASH_Update)(HASHContext*, const unsigned char*, unsigned int); - void (*HASH_End)(HASHContext*, unsigned char*, unsigned int*, unsigned int); - - *(void**)(&NSS_Initialize) = dlsym(lib, "NSS_Initialize"); - *(void**)(&HASH_Create) = dlsym(lib, "HASH_Create"); - *(void**)(&HASH_Destroy) = dlsym(lib, "HASH_Destroy"); - *(void**)(&HASH_Begin) = dlsym(lib, "HASH_Begin"); - *(void**)(&HASH_Update) = dlsym(lib, "HASH_Update"); - *(void**)(&HASH_End) = dlsym(lib, "HASH_End"); - - if (!HASH_Create || !HASH_Destroy || !HASH_Begin || !HASH_Update || - !HASH_End) { - return ""; - } -#endif - // Minimal NSS initialization so we can use the hash functions - const PRUint32 kNssFlags = NSS_INIT_READONLY | NSS_INIT_NOROOTINIT | - NSS_INIT_NOMODDB | NSS_INIT_NOCERTDB; - if (NSS_Initialize(nullptr, "", "", "", kNssFlags) != SECSuccess) { - return ""; - } - - HASHContext* hashContext = HASH_Create(HASH_AlgSHA256); - - if (!hashContext) { - return ""; - } - - HASH_Begin(hashContext); - - ifstream* f = UIOpenRead(gReporterDumpFile, ios::binary); - bool error = false; - - // Read the minidump contents - if (f->is_open()) { - uint8_t buff[4096]; - - do { - f->read((char*)buff, sizeof(buff)); - - if (f->bad()) { - error = true; - break; - } - - HASH_Update(hashContext, buff, f->gcount()); - } while (!f->eof()); - - f->close(); - } else { - error = true; - } - - delete f; - - // Finalize the hash computation - uint8_t result[SHA256_LENGTH]; - uint32_t resultLen = 0; - - HASH_End(hashContext, result, &resultLen, SHA256_LENGTH); - - if (resultLen != SHA256_LENGTH) { - error = true; - } - - HASH_Destroy(hashContext); - - if (!error) { - ostringstream hash; - - for (size_t i = 0; i < SHA256_LENGTH; i++) { - hash << std::setw(2) << std::setfill('0') << std::hex - << static_cast<unsigned int>(result[i]); - } - - return hash.str(); - } - return ""; // If we encountered an error, return an empty hash -} - -string GetDumpLocalID() { - string localId = Basename(gReporterDumpFile); - string::size_type dot = localId.rfind('.'); - - if (dot == string::npos) return ""; - - return localId.substr(0, dot); -} - -string GetProgramPath(const string& exename) { - string path = gArgv[0]; - size_t pos = path.rfind(UI_CRASH_REPORTER_FILENAME BIN_SUFFIX); - path.erase(pos); -#ifdef XP_MACOSX - // On macOS the crash reporter client is shipped as an application bundle - // contained within Firefox' 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. - path.erase(pos - 1); - for (size_t i = 0; i < 3; i++) { - pos = path.rfind(UI_DIR_SEPARATOR, pos - 1); - } - - path.erase(pos + 1); -#endif // XP_MACOSX - path.append(exename + BIN_SUFFIX); - - return path; -} - -} // namespace CrashReporter - -using namespace CrashReporter; - -Json::Value kEmptyJsonString(""); - -void RewriteStrings(Json::Value& aExtraData) { - // rewrite some UI strings with the values from the query parameters - string product = aExtraData.get("ProductName", kEmptyJsonString).asString(); - Json::Value mozilla("Mozilla"); - string vendor = aExtraData.get("Vendor", mozilla).asString(); - - char buf[4096]; - snprintf(buf, sizeof(buf), gStrings[ST_CRASHREPORTERVENDORTITLE].c_str(), - vendor.c_str()); - gStrings[ST_CRASHREPORTERTITLE] = buf; - - string str = gStrings[ST_CRASHREPORTERPRODUCTERROR]; - // Only do the replacement here if the string has two - // format specifiers to start. Otherwise - // we assume it has the product name hardcoded. - string::size_type pos = str.find("%s"); - if (pos != string::npos) pos = str.find("%s", pos + 2); - if (pos != string::npos) { - // Leave a format specifier for UIError to fill in - snprintf(buf, sizeof(buf), gStrings[ST_CRASHREPORTERPRODUCTERROR].c_str(), - product.c_str(), "%s"); - gStrings[ST_CRASHREPORTERERROR] = buf; - } else { - // product name is hardcoded - gStrings[ST_CRASHREPORTERERROR] = str; - } - - snprintf(buf, sizeof(buf), gStrings[ST_CRASHREPORTERDESCRIPTION].c_str(), - product.c_str()); - gStrings[ST_CRASHREPORTERDESCRIPTION] = buf; - - snprintf(buf, sizeof(buf), gStrings[ST_CHECKSUBMIT].c_str(), vendor.c_str()); - gStrings[ST_CHECKSUBMIT] = buf; - - snprintf(buf, sizeof(buf), gStrings[ST_RESTART].c_str(), product.c_str()); - gStrings[ST_RESTART] = buf; - - snprintf(buf, sizeof(buf), gStrings[ST_QUIT].c_str(), product.c_str()); - gStrings[ST_QUIT] = buf; - - snprintf(buf, sizeof(buf), gStrings[ST_ERROR_ENDOFLIFE].c_str(), - product.c_str()); - gStrings[ST_ERROR_ENDOFLIFE] = buf; -} - -bool CheckEndOfLifed(const Json::Value& aVersion) { - if (!aVersion.isString()) { - return false; - } - - string reportPath = - gSettingsPath + UI_DIR_SEPARATOR + "EndOfLife" + aVersion.asString(); - return UIFileExists(reportPath); -} - -int main(int argc, char** argv) { - gArgc = argc; - gArgv = argv; - - string autoSubmitEnv = UIGetEnv("MOZ_CRASHREPORTER_AUTO_SUBMIT"); - gAutoSubmit = !autoSubmitEnv.empty(); - - if (!ReadConfig()) { - UIError("Couldn't read configuration."); - return 0; - } - - if (!UIInit()) { - return 0; - } - - if (argc > 1) { - gReporterDumpFile = argv[1]; - } - - if (gReporterDumpFile.empty()) { - // no dump file specified, run the default UI - if (!gAutoSubmit) { - UIShowDefaultUI(); - } - } else { - // Start by running minidump analyzer to gather stack traces. - string reporterDumpFile = gReporterDumpFile; - vector<string> args = {reporterDumpFile}; - string dumpAllThreadsEnv = UIGetEnv("MOZ_CRASHREPORTER_DUMP_ALL_THREADS"); - if (!dumpAllThreadsEnv.empty()) { - args.insert(args.begin(), "--full"); - } - UIRunProgram(CrashReporter::GetProgramPath(UI_MINIDUMP_ANALYZER_FILENAME), - args, - /* wait */ true); - - // go ahead with the crash reporter - gExtraFile = GetAdditionalFilename(gReporterDumpFile, kExtraDataExtension); - if (gExtraFile.empty()) { - UIError(gStrings[ST_ERROR_BADARGUMENTS]); - return 0; - } - - if (!UIFileExists(gExtraFile)) { - UIError(gStrings[ST_ERROR_EXTRAFILEEXISTS]); - return 0; - } - - gMemoryFile = - GetAdditionalFilename(gReporterDumpFile, kMemoryReportExtension); - if (!UIFileExists(gMemoryFile)) { - gMemoryFile.erase(); - } - - Json::Value extraData; - if (!ReadExtraFile(gExtraFile, extraData)) { - UIError(gStrings[ST_ERROR_EXTRAFILEREAD]); - return 0; - } - - if (!extraData.isMember("ProductName")) { - UIError(gStrings[ST_ERROR_NOPRODUCTNAME]); - return 0; - } - - // There is enough information in the extra file to rewrite strings - // to be product specific - RewriteStrings(extraData); - - if (!extraData.isMember("ServerURL")) { - UIError(gStrings[ST_ERROR_NOSERVERURL]); - return 0; - } - - // Hopefully the settings path exists in the environment. Try that before - // asking the platform-specific code to guess. - gSettingsPath = UIGetEnv("MOZ_CRASHREPORTER_DATA_DIRECTORY"); - if (gSettingsPath.empty()) { - string product = - extraData.get("ProductName", kEmptyJsonString).asString(); - string vendor = extraData.get("Vendor", kEmptyJsonString).asString(); - - if (!UIGetSettingsPath(vendor, product, gSettingsPath)) { - gSettingsPath.clear(); - } - } - - if (gSettingsPath.empty() || !UIEnsurePathExists(gSettingsPath)) { - UIError(gStrings[ST_ERROR_NOSETTINGSPATH]); - return 0; - } - - OpenLogFile(); - - gEventsPath = UIGetEnv("MOZ_CRASHREPORTER_EVENTS_DIRECTORY"); - gPingPath = UIGetEnv("MOZ_CRASHREPORTER_PING_DIRECTORY"); - - // Assemble and send the crash ping - string hash = ComputeDumpHash(); - - string pingUuid; - SendCrashPing(extraData, hash, pingUuid, gPingPath); - UpdateEventFile(extraData, hash, pingUuid); - - if (!UIFileExists(gReporterDumpFile)) { - UIError(gStrings[ST_ERROR_DUMPFILEEXISTS]); - return 0; - } - - string pendingDir = gSettingsPath + UI_DIR_SEPARATOR + "pending"; - if (!MoveCrashData(pendingDir, gReporterDumpFile, gExtraFile, - gMemoryFile)) { - return 0; - } - - string sendURL = extraData.get("ServerURL", kEmptyJsonString).asString(); - // we don't need to actually send these - extraData.removeMember("ServerURL"); - extraData.removeMember("StackTraces"); - - extraData["SubmittedFrom"] = "Client"; - extraData["Throttleable"] = "1"; - - // re-set XUL_APP_FILE for xulrunner wrapped apps - const char* appfile = getenv("MOZ_CRASHREPORTER_RESTART_XUL_APP_FILE"); - if (appfile && *appfile) { - const char prefix[] = "XUL_APP_FILE="; - char* env = (char*)malloc(strlen(appfile) + strlen(prefix) + 1); - if (!env) { - UIError("Out of memory"); - return 0; - } - strcpy(env, prefix); - strcat(env, appfile); - putenv(env); - free(env); - } - - vector<string> restartArgs; - - if (!extraData.isMember("WindowsErrorReporting")) { - // We relaunch the application associated with the client, but only when - // we encountered a crash caught by the exception handler. Crashes handled - // by WER are prevented from directly restarting the application. - string programPath = GetProgramPath(MOZ_APP_NAME); -#ifndef XP_WIN - const char* moz_app_launcher = getenv("MOZ_APP_LAUNCHER"); - if (moz_app_launcher) { - programPath = moz_app_launcher; - } -#endif // XP_WIN - - restartArgs.push_back(programPath); - - ostringstream paramName; - int i = 1; - paramName << "MOZ_CRASHREPORTER_RESTART_ARG_" << i++; - const char* param = getenv(paramName.str().c_str()); - while (param && *param) { - restartArgs.push_back(param); - - paramName.str(""); - paramName << "MOZ_CRASHREPORTER_RESTART_ARG_" << i++; - param = getenv(paramName.str().c_str()); - } - } - - // allow override of the server url via environment variable - // XXX: remove this in the far future when our robot - // masters force everyone to use XULRunner - char* urlEnv = getenv("MOZ_CRASHREPORTER_URL"); - if (urlEnv && *urlEnv) { - sendURL = urlEnv; - } - - // see if this version has been end-of-lifed - - if (extraData.isMember("Version") && - CheckEndOfLifed(extraData["Version"])) { - UIError(gStrings[ST_ERROR_ENDOFLIFE]); - DeleteDump(); - return 0; - } - - StringTable files; - files["upload_file_minidump"] = gReporterDumpFile; - if (!gMemoryFile.empty()) { - files["memory_report"] = gMemoryFile; - } - - if (!UIShowCrashUI(files, extraData, sendURL, restartArgs)) { - DeleteDump(); - } - } - - UIShutdown(); - - return 0; -} - -#if defined(XP_WIN) && !defined(__GNUC__) -# include <windows.h> - -// We need WinMain in order to not be a console app. This function is unused -// if we are a console application. -int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR args, int) { - // Remove everything except close window from the context menu - { - HKEY hkApp; - RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\Classes\\Applications", 0, - nullptr, REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, - &hkApp, nullptr); - RegCloseKey(hkApp); - if (RegCreateKeyExW(HKEY_CURRENT_USER, - L"Software\\Classes\\Applications\\crashreporter.exe", - 0, nullptr, REG_OPTION_VOLATILE, KEY_SET_VALUE, nullptr, - &hkApp, nullptr) == ERROR_SUCCESS) { - RegSetValueExW(hkApp, L"IsHostApp", 0, REG_NONE, 0, 0); - RegSetValueExW(hkApp, L"NoOpenWith", 0, REG_NONE, 0, 0); - RegSetValueExW(hkApp, L"NoStartPage", 0, REG_NONE, 0, 0); - RegCloseKey(hkApp); - } - } - - char** argv = static_cast<char**>(malloc(__argc * sizeof(char*))); - for (int i = 0; i < __argc; i++) { - argv[i] = strdup(WideToUTF8(__wargv[i]).c_str()); - } - - // Do the real work. - return main(__argc, argv); -} -#endif diff --git a/toolkit/crashreporter/client/crashreporter.exe.manifest b/toolkit/crashreporter/client/crashreporter.exe.manifest deleted file mode 100644 index 81aa1465c6..0000000000 --- a/toolkit/crashreporter/client/crashreporter.exe.manifest +++ /dev/null @@ -1,42 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="yes"?> -<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> -<assemblyIdentity - version="1.0.0.0" - processorArchitecture="*" - name="CrashReporter" - type="win32" -/> -<description>Crash Reporter</description> -<dependency> - <dependentAssembly> - <assemblyIdentity - type="win32" - name="Microsoft.Windows.Common-Controls" - version="6.0.0.0" - processorArchitecture="*" - publicKeyToken="6595b64144ccf1df" - language="*" - /> - </dependentAssembly> -</dependency> -<ms_asmv3:trustInfo xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3"> - <ms_asmv3:security> - <ms_asmv3:requestedPrivileges> - <ms_asmv3:requestedExecutionLevel level="asInvoker" uiAccess="false" /> - </ms_asmv3:requestedPrivileges> - </ms_asmv3:security> -</ms_asmv3:trustInfo> - <ms_asmv3:application xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3"> - <ms_asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings"> - <gdiScaling xmlns="http://schemas.microsoft.com/SMI/2017/WindowsSettings">true</gdiScaling> - </ms_asmv3:windowsSettings> - </ms_asmv3:application> - <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> - <application> - <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/> - <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/> - <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/> - <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/> - </application> - </compatibility> -</assembly> diff --git a/toolkit/crashreporter/client/crashreporter.h b/toolkit/crashreporter/client/crashreporter.h deleted file mode 100644 index fa7085da18..0000000000 --- a/toolkit/crashreporter/client/crashreporter.h +++ /dev/null @@ -1,158 +0,0 @@ -/* 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/. */ - -#ifndef CRASHREPORTER_H__ -#define CRASHREPORTER_H__ - -#ifdef _MSC_VER -# pragma warning(push) -// Disable exception handler warnings. -# pragma warning(disable : 4530) -#endif - -#include <string> -#include <map> -#include <vector> -#include <stdlib.h> -#include <stdio.h> -#include <fstream> - -#define MAX_COMMENT_LENGTH 10000 - -#if defined(XP_WIN) - -# include <windows.h> - -# define UI_DIR_SEPARATOR "\\" - -std::string WideToUTF8(const std::wstring& wide, bool* success = 0); - -#else - -# define UI_DIR_SEPARATOR "/" - -#endif - -#include "json/json.h" - -#define UI_CRASH_REPORTER_FILENAME "crashreporter" -#define UI_MINIDUMP_ANALYZER_FILENAME "minidump-analyzer" -#define UI_PING_SENDER_FILENAME "pingsender" - -typedef std::map<std::string, std::string> StringTable; - -#define ST_CRASHREPORTERTITLE "CrashReporterTitle" -#define ST_CRASHREPORTERVENDORTITLE "CrashReporterVendorTitle" -#define ST_CRASHREPORTERERROR "CrashReporterErrorText" -#define ST_CRASHREPORTERPRODUCTERROR "CrashReporterProductErrorText2" -#define ST_CRASHREPORTERHEADER "CrashReporterSorry" -#define ST_CRASHREPORTERDESCRIPTION "CrashReporterDescriptionText2" -#define ST_CRASHREPORTERDEFAULT "CrashReporterDefault" -#define ST_VIEWREPORT "Details" -#define ST_VIEWREPORTTITLE "ViewReportTitle" -#define ST_COMMENTGRAYTEXT "CommentGrayText" -#define ST_EXTRAREPORTINFO "ExtraReportInfo" -#define ST_CHECKSUBMIT "CheckSendReport" -#define ST_CHECKURL "CheckIncludeURL" -#define ST_REPORTPRESUBMIT "ReportPreSubmit2" -#define ST_REPORTDURINGSUBMIT "ReportDuringSubmit2" -#define ST_REPORTSUBMITSUCCESS "ReportSubmitSuccess" -#define ST_SUBMITFAILED "ReportSubmitFailed" -#define ST_QUIT "Quit2" -#define ST_RESTART "Restart" -#define ST_OK "Ok" -#define ST_CLOSE "Close" - -#define ST_ERROR_BADARGUMENTS "ErrorBadArguments" -#define ST_ERROR_EXTRAFILEEXISTS "ErrorExtraFileExists" -#define ST_ERROR_EXTRAFILEREAD "ErrorExtraFileRead" -#define ST_ERROR_EXTRAFILEMOVE "ErrorExtraFileMove" -#define ST_ERROR_DUMPFILEEXISTS "ErrorDumpFileExists" -#define ST_ERROR_DUMPFILEMOVE "ErrorDumpFileMove" -#define ST_ERROR_NOPRODUCTNAME "ErrorNoProductName" -#define ST_ERROR_NOSERVERURL "ErrorNoServerURL" -#define ST_ERROR_NOSETTINGSPATH "ErrorNoSettingsPath" -#define ST_ERROR_CREATEDUMPDIR "ErrorCreateDumpDir" -#define ST_ERROR_ENDOFLIFE "ErrorEndOfLife" - -//============================================================================= -// implemented in crashreporter.cpp and ping.cpp -//============================================================================= - -namespace CrashReporter { -extern StringTable gStrings; -extern std::string gSettingsPath; -extern std::string gEventsPath; -extern int gArgc; -extern char** gArgv; -extern bool gAutoSubmit; - -void UIError(const std::string& message); - -// The UI finished sending the report -void SendCompleted(bool success, const std::string& serverResponse); - -bool ReadStrings(std::istream& in, StringTable& strings, bool unescape); -bool ReadStringsFromFile(const std::string& path, StringTable& strings, - bool unescape); -void LogMessage(const std::string& message); -void DeleteDump(); - -std::string GetDumpLocalID(); -std::string GetProgramPath(const std::string& exename); - -// Telemetry ping -bool SendCrashPing(Json::Value& extra, const std::string& hash, - std::string& pingUuid, const std::string& pingDir); - -static const unsigned int kSaveCount = 10; -} // namespace CrashReporter - -//============================================================================= -// implemented in the platform-specific files -//============================================================================= - -bool UIInit(); -void UIShutdown(); - -// Run the UI for when the app was launched without a dump file -void UIShowDefaultUI(); - -// Run the UI for when the app was launched with a dump file -// Return true if the user sent (or tried to send) the crash report, -// false if they chose not to, and it should be deleted. -bool UIShowCrashUI(const StringTable& files, const Json::Value& queryParameters, - const std::string& sendURL, - const std::vector<std::string>& restartArgs); - -void UIError_impl(const std::string& message); - -bool UIGetIniPath(std::string& path); -bool UIGetSettingsPath(const std::string& vendor, const std::string& product, - std::string& settingsPath); -bool UIEnsurePathExists(const std::string& path); -bool UIFileExists(const std::string& path); -bool UIMoveFile(const std::string& oldfile, const std::string& newfile); -bool UIDeleteFile(const std::string& oldfile); -std::ifstream* UIOpenRead(const std::string& filename, - std::ios_base::openmode mode); -std::ofstream* UIOpenWrite(const std::string& filename, - std::ios_base::openmode mode); -void UIPruneSavedDumps(const std::string& directory); - -// Run the program specified by exename, passing it the parameters in arg. -// If wait is true, wait for the program to terminate execution before -// returning. Returns true if the program was launched correctly, false -// otherwise. -bool UIRunProgram(const std::string& exename, - const std::vector<std::string>& args, bool wait = false); - -// Read the environment variable specified by name -std::string UIGetEnv(const std::string& name); - -#ifdef _MSC_VER -# pragma warning(pop) -#endif - -#endif diff --git a/toolkit/crashreporter/client/crashreporter.ico b/toolkit/crashreporter/client/crashreporter.ico Binary files differdeleted file mode 100644 index 29ac3c6189..0000000000 --- a/toolkit/crashreporter/client/crashreporter.ico +++ /dev/null diff --git a/toolkit/crashreporter/client/crashreporter.rc b/toolkit/crashreporter/client/crashreporter.rc deleted file mode 100755 index f6042bf2e5..0000000000 --- a/toolkit/crashreporter/client/crashreporter.rc +++ /dev/null @@ -1,143 +0,0 @@ -/* 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/. */ - -// Microsoft Visual C++ generated resource script. -// -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winresrc.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (U.S.) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -#ifdef _WIN32 -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US -#pragma code_page(1252) -#endif //_WIN32 - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winresrc.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_MAINICON ICON "crashreporter.ico" - -///////////////////////////////////////////////////////////////////////////// -// -// AVI -// - -IDR_THROBBER AVI "Throbber-small.avi" - -///////////////////////////////////////////////////////////////////////////// -// -// Dialog -// - -IDD_SENDDIALOG DIALOGEX 0, 0, 241, 187 -STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | WS_POPUP | WS_VISIBLE | WS_CAPTION | WS_SYSMENU -EXSTYLE WS_EX_APPWINDOW -CAPTION "Sending Crash Report..." -FONT 8, "MS Shell Dlg", 400, 0, 0x1 -BEGIN - CONTROL "",IDC_DESCRIPTIONTEXT,"RICHEDIT50W",ES_MULTILINE | ES_READONLY,8,7,226,12,WS_EX_TRANSPARENT - CONTROL "tell mozilla about this crash so they can fix it",IDC_SUBMITREPORTCHECK, - "Button",BS_AUTOCHECKBOX | WS_TABSTOP,12,25,222,10 - CHECKBOX "details...",IDC_VIEWREPORTBUTTON,24,40,54,14,BS_PUSHLIKE - EDITTEXT IDC_COMMENTTEXT,24,59,210,43,ES_MULTILINE | ES_WANTRETURN | WS_VSCROLL - CONTROL "include the address of the page i was on",IDC_INCLUDEURLCHECK, - "Button",BS_AUTOCHECKBOX | WS_TABSTOP,24,107,210,10 - CONTROL "",IDC_THROBBER,"SysAnimate32",ACS_TRANSPARENT | NOT WS_VISIBLE | WS_TABSTOP,4,152,16,16 - LTEXT "your crash report will be submitted when you restart",IDC_PROGRESSTEXT,24,152,210,10,SS_NOPREFIX - DEFPUSHBUTTON "restart firefox",IDC_RESTARTBUTTON,84,166,68,14 - PUSHBUTTON "quit without sending",IDC_CLOSEBUTTON,157,166,77,14 -END - -IDD_VIEWREPORTDIALOG DIALOGEX 0, 0, 208, 126 -STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | WS_POPUP | WS_CAPTION -CAPTION "view report" -FONT 8, "MS Shell Dlg", 400, 0, 0x1 -BEGIN - CONTROL "",IDC_VIEWREPORTTEXT,"RICHEDIT50W",ES_MULTILINE | ES_READONLY | WS_BORDER | WS_VSCROLL | WS_TABSTOP,7,7,194,92 - DEFPUSHBUTTON "OK",IDOK,151,105,50,14 -END - - -///////////////////////////////////////////////////////////////////////////// -// -// DESIGNINFO -// - -#ifdef APSTUDIO_INVOKED -GUIDELINES DESIGNINFO -BEGIN - IDD_SENDDIALOG, DIALOG - BEGIN - LEFTMARGIN, 8 - RIGHTMARGIN, 234 - TOPMARGIN, 7 - BOTTOMMARGIN, 180 - END - - IDD_VIEWREPORTDIALOG, DIALOG - BEGIN - LEFTMARGIN, 7 - RIGHTMARGIN, 201 - TOPMARGIN, 7 - BOTTOMMARGIN, 119 - END -END -#endif // APSTUDIO_INVOKED - -#endif // English (U.S.) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED - diff --git a/toolkit/crashreporter/client/crashreporter_gtk_common.cpp b/toolkit/crashreporter/client/crashreporter_gtk_common.cpp deleted file mode 100644 index b9a957cfd9..0000000000 --- a/toolkit/crashreporter/client/crashreporter_gtk_common.cpp +++ /dev/null @@ -1,361 +0,0 @@ -/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ -/* 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/. */ - -#include <unistd.h> -#include <dlfcn.h> -#include <errno.h> -#include <glib.h> -#include <gtk/gtk.h> -#include <signal.h> -#include <stdio.h> -#include <stdlib.h> -#include <string.h> -#include <sys/stat.h> -#include <sys/types.h> -#include <sys/wait.h> -#include <gdk/gdkkeysyms.h> - -#include <algorithm> -#include <string> -#include <vector> - -#include "common/linux/http_upload.h" -#include "crashreporter.h" -#include "crashreporter_gtk_common.h" - -#ifndef GDK_KEY_Escape -# define GDK_KEY_Escape GDK_Escape -#endif - -using std::string; -using std::vector; - -using namespace CrashReporter; - -GtkWidget* gWindow = 0; -GtkWidget* gSubmitReportCheck = 0; -GtkWidget* gIncludeURLCheck = 0; -GtkWidget* gThrobber = 0; -GtkWidget* gProgressLabel = 0; -GtkWidget* gCloseButton = 0; -GtkWidget* gRestartButton = 0; - -bool gInitialized = false; -bool gDidTrySend = false; -StringTable gFiles; -Json::Value gQueryParameters; -string gHttpProxy; -string gAuth; -string gCACertificateFile; -string gSendURL; -string gURLParameter; -vector<string> gRestartArgs; -GThread* gSendThreadID; - -// From crashreporter_linux.cpp -void SendReport(); -void DisableGUIAndSendReport(); -void TryInitGnome(); -void UpdateSubmit(); - -static bool RestartApplication() { - char** argv = reinterpret_cast<char**>( - malloc(sizeof(char*) * (gRestartArgs.size() + 1))); - - if (!argv) return false; - - unsigned int i; - for (i = 0; i < gRestartArgs.size(); i++) { - argv[i] = (char*)gRestartArgs[i].c_str(); - } - argv[i] = 0; - - pid_t pid = fork(); - if (pid == -1) { - free(argv); - return false; - } - - if (pid == 0) { - (void)execv(argv[0], argv); - _exit(1); - } - - free(argv); - - return true; -} - -// Quit the app, used as a timeout callback -gboolean CloseApp(gpointer data) { - if (!gAutoSubmit) { - gtk_main_quit(); - } - g_thread_join(gSendThreadID); - return FALSE; -} - -static gboolean ReportCompleted(gpointer success) { - gtk_widget_hide(gThrobber); - string str = - success ? gStrings[ST_REPORTSUBMITSUCCESS] : gStrings[ST_SUBMITFAILED]; - gtk_label_set_text(GTK_LABEL(gProgressLabel), str.c_str()); - g_timeout_add(5000, CloseApp, 0); - return FALSE; -} - -#define HTTP_PROXY_DIR "/system/http_proxy" - -void LoadProxyinfo() { - class GConfClient; - typedef GConfClient* (*_gconf_default_fn)(); - typedef gboolean (*_gconf_bool_fn)(GConfClient*, const gchar*, GError**); - typedef gint (*_gconf_int_fn)(GConfClient*, const gchar*, GError**); - typedef gchar* (*_gconf_string_fn)(GConfClient*, const gchar*, GError**); - - if (getenv("http_proxy")) - return; // libcurl can use the value from the environment - - static void* gconfLib = dlopen("libgconf-2.so.4", RTLD_LAZY); - if (!gconfLib) return; - - _gconf_default_fn gconf_client_get_default = - (_gconf_default_fn)dlsym(gconfLib, "gconf_client_get_default"); - _gconf_bool_fn gconf_client_get_bool = - (_gconf_bool_fn)dlsym(gconfLib, "gconf_client_get_bool"); - _gconf_int_fn gconf_client_get_int = - (_gconf_int_fn)dlsym(gconfLib, "gconf_client_get_int"); - _gconf_string_fn gconf_client_get_string = - (_gconf_string_fn)dlsym(gconfLib, "gconf_client_get_string"); - - if (!(gconf_client_get_default && gconf_client_get_bool && - gconf_client_get_int && gconf_client_get_string)) { - dlclose(gconfLib); - return; - } - - GConfClient* conf = gconf_client_get_default(); - - if (gconf_client_get_bool(conf, HTTP_PROXY_DIR "/use_http_proxy", nullptr)) { - gint port; - gchar *host = nullptr, *httpproxy = nullptr; - - host = gconf_client_get_string(conf, HTTP_PROXY_DIR "/host", nullptr); - port = gconf_client_get_int(conf, HTTP_PROXY_DIR "/port", nullptr); - - if (port && host && *host != '\0') { - httpproxy = g_strdup_printf("http://%s:%d/", host, port); - gHttpProxy = httpproxy; - } - - g_free(host); - g_free(httpproxy); - - if (gconf_client_get_bool(conf, HTTP_PROXY_DIR "/use_authentication", - nullptr)) { - gchar *user, *password, *auth = nullptr; - - user = gconf_client_get_string( - conf, HTTP_PROXY_DIR "/authentication_user", nullptr); - password = gconf_client_get_string( - conf, HTTP_PROXY_DIR "/authentication_password", nullptr); - - if (user && password) { - auth = g_strdup_printf("%s:%s", user, password); - gAuth = auth; - } - - g_free(user); - g_free(password); - g_free(auth); - } - } - - g_object_unref(conf); - - // Don't dlclose gconfLib as libORBit-2 uses atexit(). -} - -gpointer SendThread(gpointer args) { - Json::StreamWriterBuilder builder; - builder["indentation"] = ""; - string parameters(writeString(builder, gQueryParameters)); - - string response, error; - long response_code; - - bool success = google_breakpad::HTTPUpload::SendRequest( - gSendURL, parameters, gFiles, gHttpProxy, gAuth, gCACertificateFile, - &response, &response_code, &error); - if (success) { - LogMessage("Crash report submitted successfully"); - } else { - LogMessage("Crash report submission failed: " + error); - } - - SendCompleted(success, response); - - if (!gAutoSubmit) { - // Apparently glib is threadsafe, and will schedule this - // on the main thread, see: - // http://library.gnome.org/devel/gtk-faq/stable/x499.html - g_idle_add(ReportCompleted, (gpointer)success); - } - - return nullptr; -} - -gboolean WindowDeleted(GtkWidget* window, GdkEvent* event, gpointer userData) { - SaveSettings(); - gtk_main_quit(); - return TRUE; -} - -gboolean check_escape(GtkWidget* window, GdkEventKey* event, - gpointer userData) { - if (event->keyval == GDK_KEY_Escape) { - gtk_main_quit(); - return TRUE; - } - return FALSE; -} - -static void MaybeSubmitReport() { - if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gSubmitReportCheck))) { - gDidTrySend = true; - DisableGUIAndSendReport(); - } else { - gtk_main_quit(); - } -} - -void CloseClicked(GtkButton* button, gpointer userData) { - SaveSettings(); - MaybeSubmitReport(); -} - -void RestartClicked(GtkButton* button, gpointer userData) { - SaveSettings(); - RestartApplication(); - MaybeSubmitReport(); -} - -static void UpdateURL() { - if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gIncludeURLCheck))) { - gQueryParameters["URL"] = gURLParameter; - } else { - gQueryParameters.removeMember("URL"); - } -} - -void SubmitReportChecked(GtkButton* sender, gpointer userData) { - UpdateSubmit(); -} - -void IncludeURLClicked(GtkButton* sender, gpointer userData) { UpdateURL(); } - -/* === Crashreporter UI Functions === */ - -bool UIInit() { - // breakpad probably left us with blocked signals, unblock them here - sigset_t signals, old; - sigfillset(&signals); - sigprocmask(SIG_UNBLOCK, &signals, &old); - - // tell glib we're going to use threads - g_thread_init(nullptr); - - if (gtk_init_check(&gArgc, &gArgv)) { - gInitialized = true; - - if (gStrings.find("isRTL") != gStrings.end() && gStrings["isRTL"] == "yes") - gtk_widget_set_default_direction(GTK_TEXT_DIR_RTL); - - return true; - } - - return false; -} - -void UIShowDefaultUI() { - GtkWidget* errorDialog = gtk_message_dialog_new( - nullptr, GTK_DIALOG_MODAL, GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "%s", - gStrings[ST_CRASHREPORTERDEFAULT].c_str()); - - gtk_window_set_title(GTK_WINDOW(errorDialog), - gStrings[ST_CRASHREPORTERTITLE].c_str()); - gtk_dialog_run(GTK_DIALOG(errorDialog)); -} - -void UIError_impl(const string& message) { - if (!gInitialized) { - // Didn't initialize, this is the best we can do - printf("Error: %s\n", message.c_str()); - return; - } - - GtkWidget* errorDialog = - gtk_message_dialog_new(nullptr, GTK_DIALOG_MODAL, GTK_MESSAGE_ERROR, - GTK_BUTTONS_CLOSE, "%s", message.c_str()); - - gtk_window_set_title(GTK_WINDOW(errorDialog), - gStrings[ST_CRASHREPORTERTITLE].c_str()); - gtk_dialog_run(GTK_DIALOG(errorDialog)); -} - -bool UIGetIniPath(string& path) { - path = gArgv[0]; - path.append(".ini"); - - return true; -} - -/* - * Settings are stored in ~/.vendor/product, or - * ~/.product if vendor is empty. - */ -bool UIGetSettingsPath(const string& vendor, const string& product, - string& settingsPath) { - char* home = getenv("HOME"); - - if (!home) return false; - - settingsPath = home; - settingsPath += "/."; - if (!vendor.empty()) { - string lc_vendor; - std::transform(vendor.begin(), vendor.end(), back_inserter(lc_vendor), - (int (*)(int))std::tolower); - settingsPath += lc_vendor + "/"; - } - string lc_product; - std::transform(product.begin(), product.end(), back_inserter(lc_product), - (int (*)(int))std::tolower); - settingsPath += lc_product + "/Crash Reports"; - return true; -} - -bool UIMoveFile(const string& file, const string& newfile) { - if (!rename(file.c_str(), newfile.c_str())) return true; - if (errno != EXDEV) return false; - - // use system /bin/mv instead, time to fork - pid_t pID = vfork(); - if (pID < 0) { - // Failed to fork - return false; - } - if (pID == 0) { - char* const args[4] = {const_cast<char*>("mv"), strdup(file.c_str()), - strdup(newfile.c_str()), 0}; - if (args[1] && args[2]) execve("/bin/mv", args, 0); - free(args[1]); - free(args[2]); - exit(-1); - } - int status; - waitpid(pID, &status, 0); - return UIFileExists(newfile); -} diff --git a/toolkit/crashreporter/client/crashreporter_gtk_common.h b/toolkit/crashreporter/client/crashreporter_gtk_common.h deleted file mode 100644 index 208c7ba6b0..0000000000 --- a/toolkit/crashreporter/client/crashreporter_gtk_common.h +++ /dev/null @@ -1,50 +0,0 @@ -/* 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/. */ - -#ifndef CRASHREPORTER_GTK_COMMON_H__ -#define CRASHREPORTER_GTK_COMMON_H__ - -#include <glib.h> -#include <gtk/gtk.h> - -#include <string> -#include <vector> - -#include "json/json.h" - -const char kIniFile[] = "crashreporter.ini"; - -extern GtkWidget* gWindow; -extern GtkWidget* gSubmitReportCheck; -extern GtkWidget* gIncludeURLCheck; -extern GtkWidget* gThrobber; -extern GtkWidget* gProgressLabel; -extern GtkWidget* gCloseButton; -extern GtkWidget* gRestartButton; - -extern std::vector<std::string> gRestartArgs; -extern GThread* gSendThreadID; - -extern bool gInitialized; -extern bool gDidTrySend; -extern StringTable gFiles; -extern Json::Value gQueryParameters; -extern std::string gHttpProxy; -extern std::string gAuth; -extern std::string gCACertificateFile; -extern std::string gSendURL; -extern std::string gURLParameter; - -void LoadProxyinfo(); -gboolean CloseApp(gpointer data); -gpointer SendThread(gpointer args); -gboolean WindowDeleted(GtkWidget* window, GdkEvent* event, gpointer userData); -gboolean check_escape(GtkWidget* window, GdkEventKey* event, gpointer data); -void SubmitReportChecked(GtkButton* sender, gpointer userData); -void IncludeURLClicked(GtkButton* sender, gpointer userData); -void CloseClicked(GtkButton* button, gpointer userData); -void RestartClicked(GtkButton* button, gpointer userData); -void SaveSettings(void); - -#endif // CRASHREPORTER_GTK_COMMON_H__ diff --git a/toolkit/crashreporter/client/crashreporter_linux.cpp b/toolkit/crashreporter/client/crashreporter_linux.cpp deleted file mode 100644 index 644b34654e..0000000000 --- a/toolkit/crashreporter/client/crashreporter_linux.cpp +++ /dev/null @@ -1,525 +0,0 @@ -/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ -/* 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/. */ - -#include <dlfcn.h> -#include <fcntl.h> -#include <glib.h> -#include <gtk/gtk.h> -#include <string.h> - -#include <cctype> - -#include "crashreporter.h" -#include "crashreporter_gtk_common.h" - -#define LABEL_MAX_CHAR_WIDTH 48 - -using std::ios; -using std::string; -using std::vector; - -using namespace CrashReporter; - -static GtkWidget* gViewReportButton = 0; -static GtkWidget* gCommentTextLabel = 0; -static GtkWidget* gCommentText = 0; - -static bool gCommentFieldHint = true; - -// handle from dlopen'ing libgnome -static void* gnomeLib = nullptr; -// handle from dlopen'ing libgnomeui -static void* gnomeuiLib = nullptr; - -static void LoadSettings() { - /* - * NOTE! This code needs to stay in sync with the preference checking - * code in in nsExceptionHandler.cpp. - */ - - bool includeURL = true; - bool submitReport = true; - StringTable settings; - if (ReadStringsFromFile(gSettingsPath + "/" + kIniFile, settings, true)) { - if (settings.find("IncludeURL") != settings.end()) { - includeURL = settings["IncludeURL"][0] != '0'; - } - if (settings.find("SubmitReport") != settings.end()) { - submitReport = settings["SubmitReport"][0] != '0'; - } - } - - if (gIncludeURLCheck) { - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(gIncludeURLCheck), - includeURL); - } - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(gSubmitReportCheck), - submitReport); -} - -static string Escape(const string& str) { - string ret; - for (auto c : str) { - if (c == '\\') { - ret += "\\\\"; - } else if (c == '\n') { - ret += "\\n"; - } else if (c == '\t') { - ret += "\\t"; - } else { - ret.push_back(c); - } - } - - return ret; -} - -static bool WriteStrings(std::ostream& out, const string& header, - StringTable& strings, bool escape) { - out << "[" << header << "]\n"; - for (const auto& iter : strings) { - out << iter.first << "="; - if (escape) { - out << Escape(iter.second); - } else { - out << iter.second; - } - - out << '\n'; - } - - return true; -} - -static bool WriteStringsToFile(const string& path, const string& header, - StringTable& strings, bool escape) { - std::ofstream* f = UIOpenWrite(path, ios::trunc); - bool success = false; - if (f->is_open()) { - success = WriteStrings(*f, header, strings, escape); - f->close(); - } - - delete f; - return success; -} - -void SaveSettings() { - /* - * NOTE! This code needs to stay in sync with the preference setting - * code in in nsExceptionHandler.cpp. - */ - - StringTable settings; - - ReadStringsFromFile(gSettingsPath + "/" + kIniFile, settings, true); - if (gIncludeURLCheck != 0) - settings["IncludeURL"] = - gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gIncludeURLCheck)) ? "1" - : "0"; - settings["SubmitReport"] = - gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gSubmitReportCheck)) ? "1" - : "0"; - - WriteStringsToFile(gSettingsPath + "/" + kIniFile, "Crash Reporter", settings, - true); -} - -void SendReport() { - LoadProxyinfo(); - - // spawn a thread to do the sending - gSendThreadID = g_thread_create(SendThread, nullptr, TRUE, nullptr); -} - -void DisableGUIAndSendReport() { - // disable all our gui controls, show the throbber + change the progress text - gtk_widget_set_sensitive(gSubmitReportCheck, FALSE); - gtk_widget_set_sensitive(gViewReportButton, FALSE); - gtk_widget_set_sensitive(gCommentText, FALSE); - if (gIncludeURLCheck) gtk_widget_set_sensitive(gIncludeURLCheck, FALSE); - gtk_widget_set_sensitive(gCloseButton, FALSE); - if (gRestartButton) gtk_widget_set_sensitive(gRestartButton, FALSE); - gtk_widget_show_all(gThrobber); - gtk_label_set_text(GTK_LABEL(gProgressLabel), - gStrings[ST_REPORTDURINGSUBMIT].c_str()); - - SendReport(); -} - -static void ShowReportInfo(GtkTextView* viewReportTextView) { - GtkTextBuffer* buffer = gtk_text_view_get_buffer(viewReportTextView); - - GtkTextIter start, end; - gtk_text_buffer_get_start_iter(buffer, &start); - gtk_text_buffer_get_end_iter(buffer, &end); - - gtk_text_buffer_delete(buffer, &start, &end); - - for (Json::ValueConstIterator iter = gQueryParameters.begin(); - iter != gQueryParameters.end(); ++iter) { - gtk_text_buffer_insert(buffer, &end, iter.name().c_str(), - iter.name().length()); - gtk_text_buffer_insert(buffer, &end, ": ", -1); - string value; - if (iter->isString()) { - value = iter->asString(); - } else { - Json::StreamWriterBuilder builder; - builder["indentation"] = ""; - value = writeString(builder, *iter); - } - gtk_text_buffer_insert(buffer, &end, value.c_str(), value.length()); - gtk_text_buffer_insert(buffer, &end, "\n", -1); - } - - gtk_text_buffer_insert(buffer, &end, "\n", -1); - gtk_text_buffer_insert(buffer, &end, gStrings[ST_EXTRAREPORTINFO].c_str(), - -1); -} - -void UpdateSubmit() { - if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(gSubmitReportCheck))) { - gtk_widget_set_sensitive(gViewReportButton, TRUE); - gtk_widget_set_sensitive(gCommentText, TRUE); - if (gIncludeURLCheck) gtk_widget_set_sensitive(gIncludeURLCheck, TRUE); - gtk_label_set_text(GTK_LABEL(gProgressLabel), - gStrings[ST_REPORTPRESUBMIT].c_str()); - } else { - gtk_widget_set_sensitive(gViewReportButton, FALSE); - gtk_widget_set_sensitive(gCommentText, FALSE); - if (gIncludeURLCheck) gtk_widget_set_sensitive(gIncludeURLCheck, FALSE); - gtk_label_set_text(GTK_LABEL(gProgressLabel), ""); - } -} - -static void ViewReportClicked(GtkButton* button, gpointer userData) { - GtkDialog* dialog = GTK_DIALOG(gtk_dialog_new_with_buttons( - gStrings[ST_VIEWREPORTTITLE].c_str(), GTK_WINDOW(gWindow), - GTK_DIALOG_MODAL, GTK_STOCK_OK, GTK_RESPONSE_OK, nullptr)); - - GtkWidget* scrolled = gtk_scrolled_window_new(0, 0); - gtk_container_add(GTK_CONTAINER(gtk_dialog_get_content_area(dialog)), - scrolled); - gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), - GTK_POLICY_AUTOMATIC, GTK_POLICY_ALWAYS); - gtk_scrolled_window_set_shadow_type(GTK_SCROLLED_WINDOW(scrolled), - GTK_SHADOW_IN); - gtk_widget_set_vexpand(scrolled, TRUE); - - GtkWidget* viewReportTextView = gtk_text_view_new(); - gtk_container_add(GTK_CONTAINER(scrolled), viewReportTextView); - gtk_text_view_set_editable(GTK_TEXT_VIEW(viewReportTextView), FALSE); - gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(viewReportTextView), GTK_WRAP_WORD); - gtk_widget_set_size_request(GTK_WIDGET(viewReportTextView), -1, 100); - - ShowReportInfo(GTK_TEXT_VIEW(viewReportTextView)); - - gtk_dialog_set_default_response(dialog, GTK_RESPONSE_OK); - gtk_widget_set_size_request(GTK_WIDGET(dialog), 400, 200); - gtk_widget_show_all(GTK_WIDGET(dialog)); - gtk_dialog_run(dialog); - gtk_widget_destroy(GTK_WIDGET(dialog)); -} - -static void CommentChanged(GtkTextBuffer* buffer, gpointer userData) { - GtkTextIter start, end; - gtk_text_buffer_get_start_iter(buffer, &start); - gtk_text_buffer_get_end_iter(buffer, &end); - const char* comment = gtk_text_buffer_get_text(buffer, &start, &end, TRUE); - if (comment[0] == '\0' || gCommentFieldHint) { - gQueryParameters.removeMember("Comments"); - } else { - gQueryParameters["Comments"] = comment; - } -} - -static void CommentInsert(GtkTextBuffer* buffer, GtkTextIter* location, - gchar* text, gint len, gpointer userData) { - GtkTextIter start, end; - gtk_text_buffer_get_start_iter(buffer, &start); - gtk_text_buffer_get_end_iter(buffer, &end); - const char* comment = gtk_text_buffer_get_text(buffer, &start, &end, TRUE); - - // limit to 500 bytes in utf-8 - if (strlen(comment) + len > MAX_COMMENT_LENGTH) { - g_signal_stop_emission_by_name(buffer, "insert-text"); - } -} - -static void UpdateHintText(GtkWidget* widget, gboolean gainedFocus, - bool* hintShowing, const char* hintText) { - GtkTextBuffer* buffer = nullptr; - if (GTK_IS_TEXT_VIEW(widget)) - buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(widget)); - - if (gainedFocus) { - if (*hintShowing) { - if (buffer == nullptr) { // sort of cheating - gtk_entry_set_text(GTK_ENTRY(widget), ""); - } else { // GtkTextView - gtk_text_buffer_set_text(buffer, "", 0); - } - gtk_widget_modify_text(widget, GTK_STATE_NORMAL, nullptr); - *hintShowing = false; - } - } else { - // lost focus - const char* text = nullptr; - if (buffer == nullptr) { - text = gtk_entry_get_text(GTK_ENTRY(widget)); - } else { - GtkTextIter start, end; - gtk_text_buffer_get_start_iter(buffer, &start); - gtk_text_buffer_get_end_iter(buffer, &end); - text = gtk_text_buffer_get_text(buffer, &start, &end, TRUE); - } - - if (text == nullptr || text[0] == '\0') { - *hintShowing = true; - - if (buffer == nullptr) { - gtk_entry_set_text(GTK_ENTRY(widget), hintText); - } else { - gtk_text_buffer_set_text(buffer, hintText, -1); - } - - gtk_widget_modify_text( - widget, GTK_STATE_NORMAL, - >k_widget_get_style(widget)->text[GTK_STATE_INSENSITIVE]); - } - } -} - -static gboolean CommentFocusChange(GtkWidget* widget, GdkEventFocus* event, - gpointer userData) { - UpdateHintText(widget, event->in, &gCommentFieldHint, - gStrings[ST_COMMENTGRAYTEXT].c_str()); - - return FALSE; -} - -typedef struct _GnomeProgram GnomeProgram; -typedef struct _GnomeModuleInfo GnomeModuleInfo; -typedef GnomeProgram* (*_gnome_program_init_fn)(const char*, const char*, - const GnomeModuleInfo*, int, - char**, const char*, ...); -typedef const GnomeModuleInfo* (*_libgnomeui_module_info_get_fn)(); - -void TryInitGnome() { - gnomeLib = dlopen("libgnome-2.so.0", RTLD_LAZY); - if (!gnomeLib) return; - - gnomeuiLib = dlopen("libgnomeui-2.so.0", RTLD_LAZY); - if (!gnomeuiLib) return; - - _gnome_program_init_fn gnome_program_init = - (_gnome_program_init_fn)(dlsym(gnomeLib, "gnome_program_init")); - _libgnomeui_module_info_get_fn libgnomeui_module_info_get = - (_libgnomeui_module_info_get_fn)(dlsym(gnomeuiLib, - "libgnomeui_module_info_get")); - - if (gnome_program_init && libgnomeui_module_info_get) { - gnome_program_init("crashreporter", "1.0", libgnomeui_module_info_get(), - gArgc, gArgv, nullptr); - } -} - -/* === Crashreporter UI Functions === */ - -/* - * Anything not listed here is in crashreporter_gtk_common.cpp: - * UIInit - * UIShowDefaultUI - * UIError_impl - * UIGetIniPath - * UIGetSettingsPath - * UIEnsurePathExists - * UIFileExists - * UIMoveFile - * UIDeleteFile - * UIOpenRead - * UIOpenWrite - */ - -void UIShutdown() { - if (gnomeuiLib) dlclose(gnomeuiLib); - // Don't dlclose gnomeLib as libgnomevfs and libORBit-2 use atexit(). -} - -bool UIShowCrashUI(const StringTable& files, const Json::Value& queryParameters, - const string& sendURL, const vector<string>& restartArgs) { - gFiles = files; - gQueryParameters = queryParameters; - gSendURL = sendURL; - gRestartArgs = restartArgs; - if (gQueryParameters.isMember("URL")) { - gURLParameter = gQueryParameters["URL"].asString(); - } - - if (gAutoSubmit) { - SendReport(); - CloseApp(nullptr); - return true; - } - - gWindow = gtk_window_new(GTK_WINDOW_TOPLEVEL); - gtk_window_set_title(GTK_WINDOW(gWindow), - gStrings[ST_CRASHREPORTERTITLE].c_str()); - gtk_window_set_resizable(GTK_WINDOW(gWindow), FALSE); - gtk_window_set_position(GTK_WINDOW(gWindow), GTK_WIN_POS_CENTER); - gtk_container_set_border_width(GTK_CONTAINER(gWindow), 12); - g_signal_connect(gWindow, "delete-event", G_CALLBACK(WindowDeleted), 0); - g_signal_connect(gWindow, "key_press_event", G_CALLBACK(check_escape), - nullptr); - - GtkWidget* vbox = gtk_vbox_new(FALSE, 6); - gtk_container_add(GTK_CONTAINER(gWindow), vbox); - - GtkWidget* titleLabel = gtk_label_new(""); - gtk_box_pack_start(GTK_BOX(vbox), titleLabel, FALSE, FALSE, 0); - gtk_misc_set_alignment(GTK_MISC(titleLabel), 0, 0.5); - char* markup = - g_strdup_printf("<b>%s</b>", gStrings[ST_CRASHREPORTERHEADER].c_str()); - gtk_label_set_markup(GTK_LABEL(titleLabel), markup); - g_free(markup); - - GtkWidget* descriptionLabel = - gtk_label_new(gStrings[ST_CRASHREPORTERDESCRIPTION].c_str()); - gtk_box_pack_start(GTK_BOX(vbox), descriptionLabel, TRUE, TRUE, 0); - // force the label to line wrap - gtk_label_set_max_width_chars(GTK_LABEL(descriptionLabel), - LABEL_MAX_CHAR_WIDTH); - gtk_label_set_line_wrap(GTK_LABEL(descriptionLabel), TRUE); - gtk_label_set_selectable(GTK_LABEL(descriptionLabel), TRUE); - gtk_misc_set_alignment(GTK_MISC(descriptionLabel), 0, 0.5); - - // this is honestly how they suggest you indent a section - GtkWidget* indentBox = gtk_hbox_new(FALSE, 0); - gtk_box_pack_start(GTK_BOX(vbox), indentBox, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(indentBox), gtk_label_new(""), FALSE, FALSE, 6); - - GtkWidget* innerVBox1 = gtk_vbox_new(FALSE, 0); - gtk_box_pack_start(GTK_BOX(indentBox), innerVBox1, TRUE, TRUE, 0); - - gSubmitReportCheck = - gtk_check_button_new_with_label(gStrings[ST_CHECKSUBMIT].c_str()); - gtk_box_pack_start(GTK_BOX(innerVBox1), gSubmitReportCheck, FALSE, FALSE, 0); - g_signal_connect(gSubmitReportCheck, "clicked", - G_CALLBACK(SubmitReportChecked), 0); - - // indent again, below the "submit report" checkbox - GtkWidget* indentBox2 = gtk_hbox_new(FALSE, 0); - gtk_box_pack_start(GTK_BOX(innerVBox1), indentBox2, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(indentBox2), gtk_label_new(""), FALSE, FALSE, 6); - - GtkWidget* innerVBox = gtk_vbox_new(FALSE, 0); - gtk_box_pack_start(GTK_BOX(indentBox2), innerVBox, TRUE, TRUE, 0); - gtk_box_set_spacing(GTK_BOX(innerVBox), 6); - - GtkWidget* viewReportButtonBox = gtk_hbutton_box_new(); - gtk_box_pack_start(GTK_BOX(innerVBox), viewReportButtonBox, FALSE, FALSE, 0); - gtk_box_set_spacing(GTK_BOX(viewReportButtonBox), 6); - gtk_button_box_set_layout(GTK_BUTTON_BOX(viewReportButtonBox), - GTK_BUTTONBOX_START); - - gViewReportButton = - gtk_button_new_with_label(gStrings[ST_VIEWREPORT].c_str()); - gtk_box_pack_start(GTK_BOX(viewReportButtonBox), gViewReportButton, FALSE, - FALSE, 0); - g_signal_connect(gViewReportButton, "clicked", G_CALLBACK(ViewReportClicked), - 0); - - GtkWidget* scrolled = gtk_scrolled_window_new(0, 0); - gtk_container_add(GTK_CONTAINER(innerVBox), scrolled); - gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), - GTK_POLICY_NEVER, GTK_POLICY_ALWAYS); - gtk_scrolled_window_set_shadow_type(GTK_SCROLLED_WINDOW(scrolled), - GTK_SHADOW_IN); - gtk_scrolled_window_set_min_content_height(GTK_SCROLLED_WINDOW(scrolled), - 100); - - gCommentTextLabel = gtk_label_new(gStrings[ST_COMMENTGRAYTEXT].c_str()); - gCommentText = gtk_text_view_new(); - gtk_label_set_mnemonic_widget(GTK_LABEL(gCommentTextLabel), gCommentText); - gtk_text_view_set_accepts_tab(GTK_TEXT_VIEW(gCommentText), FALSE); - g_signal_connect(gCommentText, "focus-in-event", - G_CALLBACK(CommentFocusChange), 0); - g_signal_connect(gCommentText, "focus-out-event", - G_CALLBACK(CommentFocusChange), 0); - - GtkTextBuffer* commentBuffer = - gtk_text_view_get_buffer(GTK_TEXT_VIEW(gCommentText)); - g_signal_connect(commentBuffer, "changed", G_CALLBACK(CommentChanged), 0); - g_signal_connect(commentBuffer, "insert-text", G_CALLBACK(CommentInsert), 0); - - gtk_container_add(GTK_CONTAINER(scrolled), gCommentText); - gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(gCommentText), GTK_WRAP_WORD_CHAR); - gtk_widget_set_size_request(GTK_WIDGET(gCommentText), -1, 100); - - if (gQueryParameters.isMember("URL")) { - gIncludeURLCheck = - gtk_check_button_new_with_label(gStrings[ST_CHECKURL].c_str()); - gtk_box_pack_start(GTK_BOX(innerVBox), gIncludeURLCheck, FALSE, FALSE, 0); - g_signal_connect(gIncludeURLCheck, "clicked", G_CALLBACK(IncludeURLClicked), - 0); - // on by default - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(gIncludeURLCheck), TRUE); - } - - GtkWidget* progressBox = gtk_hbox_new(FALSE, 6); - gtk_box_pack_start(GTK_BOX(vbox), progressBox, TRUE, TRUE, 0); - - // Get the throbber image from alongside the executable - char* dir = g_path_get_dirname(gArgv[0]); - char* path = g_build_filename(dir, "Throbber-small.gif", nullptr); - g_free(dir); - gThrobber = gtk_image_new_from_file(path); - gtk_box_pack_start(GTK_BOX(progressBox), gThrobber, FALSE, FALSE, 0); - - gProgressLabel = gtk_label_new(gStrings[ST_REPORTPRESUBMIT].c_str()); - gtk_box_pack_start(GTK_BOX(progressBox), gProgressLabel, TRUE, TRUE, 0); - // force the label to line wrap - gtk_label_set_max_width_chars(GTK_LABEL(gProgressLabel), - LABEL_MAX_CHAR_WIDTH); - gtk_label_set_line_wrap(GTK_LABEL(gProgressLabel), TRUE); - - GtkWidget* buttonBox = gtk_hbutton_box_new(); - gtk_box_pack_end(GTK_BOX(vbox), buttonBox, FALSE, FALSE, 0); - gtk_box_set_spacing(GTK_BOX(buttonBox), 6); - gtk_button_box_set_layout(GTK_BUTTON_BOX(buttonBox), GTK_BUTTONBOX_END); - - gCloseButton = gtk_button_new_with_label(gStrings[ST_QUIT].c_str()); - gtk_box_pack_start(GTK_BOX(buttonBox), gCloseButton, FALSE, FALSE, 0); - gtk_widget_set_can_default(gCloseButton, TRUE); - g_signal_connect(gCloseButton, "clicked", G_CALLBACK(CloseClicked), 0); - - gRestartButton = 0; - if (!restartArgs.empty()) { - gRestartButton = gtk_button_new_with_label(gStrings[ST_RESTART].c_str()); - gtk_box_pack_start(GTK_BOX(buttonBox), gRestartButton, FALSE, FALSE, 0); - gtk_widget_set_can_default(gRestartButton, TRUE); - g_signal_connect(gRestartButton, "clicked", G_CALLBACK(RestartClicked), 0); - } - - gtk_widget_grab_focus(gSubmitReportCheck); - - gtk_widget_grab_default(gRestartButton ? gRestartButton : gCloseButton); - - LoadSettings(); - - UpdateSubmit(); - - UpdateHintText(gCommentText, FALSE, &gCommentFieldHint, - gStrings[ST_COMMENTGRAYTEXT].c_str()); - - gtk_widget_show_all(gWindow); - // stick this here to avoid the show_all above... - gtk_widget_hide(gThrobber); - - gtk_main(); - - return gDidTrySend; -} diff --git a/toolkit/crashreporter/client/crashreporter_osx.h b/toolkit/crashreporter/client/crashreporter_osx.h deleted file mode 100644 index d9aeddb4b0..0000000000 --- a/toolkit/crashreporter/client/crashreporter_osx.h +++ /dev/null @@ -1,108 +0,0 @@ -/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ -/* 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/. */ - -#ifndef CRASHREPORTER_OSX_H__ -#define CRASHREPORTER_OSX_H__ - -#include <Cocoa/Cocoa.h> -#include "HTTPMultipartUpload.h" -#include "crashreporter.h" -#include "json/json.h" - -// Defined below -@class TextViewWithPlaceHolder; - -@interface CrashReporterUI : NSObject { - IBOutlet NSWindow* mWindow; - - /* Crash reporter view */ - IBOutlet NSTextField* mHeaderLabel; - IBOutlet NSTextField* mDescriptionLabel; - IBOutlet NSButton* mViewReportButton; - IBOutlet NSScrollView* mCommentScrollView; - IBOutlet TextViewWithPlaceHolder* mCommentText; - IBOutlet NSButton* mSubmitReportButton; - IBOutlet NSButton* mIncludeURLButton; - IBOutlet NSButton* mEmailMeButton; - IBOutlet NSTextField* mEmailText; - IBOutlet NSButton* mCloseButton; - IBOutlet NSButton* mRestartButton; - IBOutlet NSProgressIndicator* mProgressIndicator; - IBOutlet NSTextField* mProgressText; - - /* Error view */ - IBOutlet NSView* mErrorView; - IBOutlet NSTextField* mErrorHeaderLabel; - IBOutlet NSTextField* mErrorLabel; - IBOutlet NSButton* mErrorCloseButton; - - /* For "show info" alert */ - IBOutlet NSWindow* mViewReportWindow; - IBOutlet NSTextView* mViewReportTextView; - IBOutlet NSButton* mViewReportOkButton; - - HTTPMultipartUpload* mPost; -} - -- (void)showCrashUI:(const StringTable&)files - queryParameters:(const Json::Value&)queryParameters - sendURL:(const std::string&)sendURL; -- (void)showErrorUI:(const std::string&)message; -- (void)showReportInfo; -- (void)maybeSubmitReport; -- (void)closeMeDown:(id)unused; - -- (IBAction)submitReportClicked:(id)sender; -- (IBAction)viewReportClicked:(id)sender; -- (IBAction)viewReportOkClicked:(id)sender; -- (IBAction)closeClicked:(id)sender; -- (IBAction)restartClicked:(id)sender; -- (IBAction)includeURLClicked:(id)sender; - -- (void)textDidChange:(NSNotification*)aNotification; -- (BOOL)textView:(NSTextView*)aTextView - shouldChangeTextInRange:(NSRange)affectedCharRange - replacementString:(NSString*)replacementString; - -- (void)doInitialResizing; -- (float)setStringFitVertically:(NSControl*)control - string:(NSString*)str - resizeWindow:(BOOL)resizeWindow; -- (void)setView:(NSView*)v animate:(BOOL)animate; -- (void)enableControls:(BOOL)enabled; -- (void)updateSubmit; -- (void)updateURL; -- (void)updateEmail; -- (void)sendReport; -- (bool)setupPost; -- (void)uploadThread:(HTTPMultipartUpload*)post; -- (void)uploadComplete:(NSData*)data; - -- (BOOL)applicationShouldTerminateAfterLastWindowClosed: - (NSApplication*)theApplication; -- (void)applicationWillTerminate:(NSNotification*)aNotification; - -@end - -/* - * Subclass NSTextView to provide a text view with placeholder text. - * Also provide a setEnabled implementation. - */ -@interface TextViewWithPlaceHolder : NSTextView { - NSMutableAttributedString* mPlaceHolderString; -} - -- (BOOL)becomeFirstResponder; -- (void)drawRect:(NSRect)rect; -- (BOOL)resignFirstResponder; -- (void)setPlaceholder:(NSString*)placeholder; -- (void)insertTab:(id)sender; -- (void)insertBacktab:(id)sender; -- (void)setEnabled:(BOOL)enabled; -- (void)dealloc; - -@end - -#endif diff --git a/toolkit/crashreporter/client/crashreporter_osx.mm b/toolkit/crashreporter/client/crashreporter_osx.mm deleted file mode 100644 index b6d5d8ac6f..0000000000 --- a/toolkit/crashreporter/client/crashreporter_osx.mm +++ /dev/null @@ -1,805 +0,0 @@ -/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ -/* 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/. */ - -#import <Cocoa/Cocoa.h> -#import <CoreFoundation/CoreFoundation.h> -#include "crashreporter.h" -#include "crashreporter_osx.h" -#include <crt_externs.h> -#include <spawn.h> -#include <sys/stat.h> -#include <sys/types.h> -#include <fcntl.h> -#include <sstream> - -using std::ostringstream; -using std::string; -using std::vector; - -using namespace CrashReporter; - -static NSAutoreleasePool* gMainPool; -static CrashReporterUI* gUI = 0; -static StringTable gFiles; -static Json::Value gQueryParameters; -static string gURLParameter; -static string gSendURL; -static vector<string> gRestartArgs; -static bool gDidTrySend = false; -static bool gRTLlayout = false; - -static cpu_type_t pref_cpu_types[2] = { -#if defined(__i386__) - CPU_TYPE_X86, -#elif defined(__x86_64__) - CPU_TYPE_X86_64, -#elif defined(__ppc__) - CPU_TYPE_POWERPC, -#elif defined(__aarch64__) - CPU_TYPE_ARM64, -#endif - CPU_TYPE_ANY}; - -#define NSSTR(s) [NSString stringWithUTF8String:(s).c_str()] - -static NSString* Str(const char* aName) { - string str = gStrings[aName]; - if (str.empty()) str = "?"; - return NSSTR(str); -} - -static bool RestartApplication() { - vector<char*> argv(gRestartArgs.size() + 1); - - posix_spawnattr_t spawnattr; - if (posix_spawnattr_init(&spawnattr) != 0) { - return false; - } - - // Set spawn attributes. - size_t attr_count = sizeof(pref_cpu_types) / sizeof(pref_cpu_types[0]); - size_t attr_ocount = 0; - if (posix_spawnattr_setbinpref_np(&spawnattr, attr_count, pref_cpu_types, - &attr_ocount) != 0 || - attr_ocount != attr_count) { - posix_spawnattr_destroy(&spawnattr); - return false; - } - - unsigned int i; - for (i = 0; i < gRestartArgs.size(); i++) { - argv[i] = (char*)gRestartArgs[i].c_str(); - } - argv[i] = 0; - - char** env = NULL; - char*** nsEnv = _NSGetEnviron(); - if (nsEnv) env = *nsEnv; - int result = posix_spawnp(NULL, argv[0], NULL, &spawnattr, &argv[0], env); - - posix_spawnattr_destroy(&spawnattr); - - return result == 0; -} - -@implementation CrashReporterUI - -- (void)awakeFromNib { - gUI = self; - [mWindow center]; - - [mWindow setTitle:[[NSBundle mainBundle] - objectForInfoDictionaryKey:@"CFBundleName"]]; - [NSApp activateIgnoringOtherApps:YES]; -} - -- (void)showCrashUI:(const StringTable&)files - queryParameters:(const Json::Value&)queryParameters - sendURL:(const string&)sendURL { - gFiles = files; - gQueryParameters = queryParameters; - gSendURL = sendURL; - - if (gAutoSubmit) { - gDidTrySend = true; - [self sendReport]; - return; - } - - [mWindow setTitle:Str(ST_CRASHREPORTERTITLE)]; - [mHeaderLabel setStringValue:Str(ST_CRASHREPORTERHEADER)]; - - NSRect viewReportFrame = [mViewReportButton frame]; - [mViewReportButton setTitle:Str(ST_VIEWREPORT)]; - [mViewReportButton sizeToFit]; - if (gRTLlayout) { - // sizeToFit will keep the left side fixed, so realign - float oldWidth = viewReportFrame.size.width; - viewReportFrame = [mViewReportButton frame]; - viewReportFrame.origin.x += oldWidth - viewReportFrame.size.width; - [mViewReportButton setFrame:viewReportFrame]; - } - - [mSubmitReportButton setTitle:Str(ST_CHECKSUBMIT)]; - [mIncludeURLButton setTitle:Str(ST_CHECKURL)]; - [mViewReportOkButton setTitle:Str(ST_OK)]; - - [mCommentText setPlaceholder:Str(ST_COMMENTGRAYTEXT)]; - if (gRTLlayout) [mCommentText toggleBaseWritingDirection:self]; - - if (gQueryParameters.isMember("URL")) { - // save the URL value in case the checkbox gets unchecked - gURLParameter = gQueryParameters["URL"].asString(); - } else { - // no URL specified, hide checkbox - [mIncludeURLButton removeFromSuperview]; - // shrink window to fit - NSRect frame = [mWindow frame]; - NSRect includeURLFrame = [mIncludeURLButton frame]; - NSRect emailFrame = [mEmailMeButton frame]; - int buttonMask = [mViewReportButton autoresizingMask]; - int checkMask = [mSubmitReportButton autoresizingMask]; - int commentScrollMask = [mCommentScrollView autoresizingMask]; - - [mViewReportButton setAutoresizingMask:NSViewMinYMargin]; - [mSubmitReportButton setAutoresizingMask:NSViewMinYMargin]; - [mCommentScrollView setAutoresizingMask:NSViewMinYMargin]; - - // remove all the space in between - frame.size.height -= includeURLFrame.origin.y - emailFrame.origin.y; - [mWindow setFrame:frame display:true animate:NO]; - - [mViewReportButton setAutoresizingMask:buttonMask]; - [mSubmitReportButton setAutoresizingMask:checkMask]; - [mCommentScrollView setAutoresizingMask:commentScrollMask]; - } - - // resize some buttons horizontally and possibly some controls vertically - [self doInitialResizing]; - - // load default state of submit checkbox - // we don't just do this via IB because we want the default to be - // off a certain percentage of the time - BOOL submitChecked = YES; - NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults]; - if (nil != [userDefaults objectForKey:@"submitReport"]) { - submitChecked = [userDefaults boolForKey:@"submitReport"]; - } else { - [userDefaults setBool:submitChecked forKey:@"submitReport"]; - } - [mSubmitReportButton setState:(submitChecked ? NSOnState : NSOffState)]; - - // load default state of include URL checkbox - BOOL includeChecked = YES; - if (nil != [userDefaults objectForKey:@"IncludeURL"]) { - includeChecked = [userDefaults boolForKey:@"IncludeURL"]; - } else { - [userDefaults setBool:includeChecked forKey:@"IncludeURL"]; - } - [mIncludeURLButton setState:(includeChecked ? NSOnState : NSOffState)]; - - [self updateSubmit]; - [self updateURL]; - [self updateEmail]; - - [mWindow makeKeyAndOrderFront:nil]; -} - -- (void)showErrorUI:(const string&)message { - [self setView:mErrorView animate:NO]; - - [mErrorHeaderLabel setStringValue:Str(ST_CRASHREPORTERHEADER)]; - [self setStringFitVertically:mErrorLabel - string:NSSTR(message) - resizeWindow:YES]; - [mErrorCloseButton setTitle:Str(ST_OK)]; - - [mErrorCloseButton setKeyEquivalent:@"\r"]; - [mWindow makeFirstResponder:mErrorCloseButton]; - [mWindow makeKeyAndOrderFront:nil]; -} - -- (void)showReportInfo { - NSDictionary* boldAttr = @{ - NSFontAttributeName : - [NSFont boldSystemFontOfSize:[NSFont smallSystemFontSize]], - NSForegroundColorAttributeName : NSColor.textColor, - }; - NSDictionary* normalAttr = @{ - NSFontAttributeName : - [NSFont systemFontOfSize:[NSFont smallSystemFontSize]], - NSForegroundColorAttributeName : NSColor.textColor, - }; - - [mViewReportTextView setString:@""]; - for (Json::ValueConstIterator iter = gQueryParameters.begin(); - iter != gQueryParameters.end(); ++iter) { - NSAttributedString* key = - [[NSAttributedString alloc] initWithString:NSSTR(iter.name() + ": ") - attributes:boldAttr]; - string str; - if (iter->isString()) { - str = iter->asString(); - } else { - Json::StreamWriterBuilder builder; - builder["indentation"] = ""; - str = writeString(builder, *iter); - } - NSAttributedString* value = - [[NSAttributedString alloc] initWithString:NSSTR(str + "\n") - attributes:normalAttr]; - [[mViewReportTextView textStorage] appendAttributedString:key]; - [[mViewReportTextView textStorage] appendAttributedString:value]; - [key release]; - [value release]; - } - - NSAttributedString* extra = [[NSAttributedString alloc] - initWithString:NSSTR("\n" + gStrings[ST_EXTRAREPORTINFO]) - attributes:normalAttr]; - [[mViewReportTextView textStorage] appendAttributedString:extra]; - [extra release]; -} - -- (void)maybeSubmitReport { - if ([mSubmitReportButton state] == NSOnState) { - [self setStringFitVertically:mProgressText - string:Str(ST_REPORTDURINGSUBMIT) - resizeWindow:YES]; - // disable all the controls - [self enableControls:NO]; - [mSubmitReportButton setEnabled:NO]; - [mRestartButton setEnabled:NO]; - [mCloseButton setEnabled:NO]; - [mProgressIndicator startAnimation:self]; - gDidTrySend = true; - [self sendReport]; - } else { - [NSApp terminate:self]; - } -} - -- (void)closeMeDown:(id)unused { - [NSApp terminate:self]; -} - -- (IBAction)submitReportClicked:(id)sender { - [self updateSubmit]; - NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults]; - [userDefaults setBool:([mSubmitReportButton state] == NSOnState) - forKey:@"submitReport"]; - [userDefaults synchronize]; -} - -- (IBAction)viewReportClicked:(id)sender { - [self showReportInfo]; - [NSApp beginSheet:mViewReportWindow - modalForWindow:mWindow - modalDelegate:nil - didEndSelector:nil - contextInfo:nil]; -} - -- (IBAction)viewReportOkClicked:(id)sender { - [mViewReportWindow orderOut:nil]; - [NSApp endSheet:mViewReportWindow]; -} - -- (IBAction)closeClicked:(id)sender { - [self maybeSubmitReport]; -} - -- (IBAction)restartClicked:(id)sender { - RestartApplication(); - [self maybeSubmitReport]; -} - -- (IBAction)includeURLClicked:(id)sender { - [self updateURL]; - NSUserDefaults* userDefaults = [NSUserDefaults standardUserDefaults]; - [userDefaults setBool:([mIncludeURLButton state] == NSOnState) - forKey:@"IncludeURL"]; - [userDefaults synchronize]; -} - -- (void)textDidChange:(NSNotification*)aNotification { - // update comment parameter - if ([[[mCommentText textStorage] mutableString] length] > 0) - gQueryParameters["Comments"] = - [[[mCommentText textStorage] mutableString] UTF8String]; - else - gQueryParameters.removeMember("Comments"); -} - -// Limit the comment field to 500 bytes in UTF-8 -- (BOOL)textView:(NSTextView*)aTextView - shouldChangeTextInRange:(NSRange)affectedCharRange - replacementString:(NSString*)replacementString { - // current string length + replacement text length - replaced range length - if (([[aTextView string] lengthOfBytesUsingEncoding:NSUTF8StringEncoding] + - [replacementString lengthOfBytesUsingEncoding:NSUTF8StringEncoding] - - [[[aTextView string] substringWithRange:affectedCharRange] - lengthOfBytesUsingEncoding:NSUTF8StringEncoding]) > - MAX_COMMENT_LENGTH) { - return NO; - } - return YES; -} - -- (void)doInitialResizing { - NSRect windowFrame = [mWindow frame]; - NSRect restartFrame = [mRestartButton frame]; - NSRect closeFrame = [mCloseButton frame]; - // resize close button to fit text - float oldCloseWidth = closeFrame.size.width; - [mCloseButton setTitle:Str(ST_QUIT)]; - [mCloseButton sizeToFit]; - closeFrame = [mCloseButton frame]; - // move close button left if it grew - if (!gRTLlayout) { - closeFrame.origin.x -= closeFrame.size.width - oldCloseWidth; - } - - if (gRestartArgs.size() == 0) { - [mRestartButton removeFromSuperview]; - if (!gRTLlayout) { - closeFrame.origin.x = restartFrame.origin.x + - (restartFrame.size.width - closeFrame.size.width); - } else { - closeFrame.origin.x = restartFrame.origin.x; - } - [mCloseButton setFrame:closeFrame]; - [mCloseButton setKeyEquivalent:@"\r"]; - } else { - [mRestartButton setTitle:Str(ST_RESTART)]; - // resize "restart" button - float oldRestartWidth = restartFrame.size.width; - [mRestartButton sizeToFit]; - restartFrame = [mRestartButton frame]; - if (!gRTLlayout) { - // move left by the amount that the button grew - restartFrame.origin.x -= restartFrame.size.width - oldRestartWidth; - closeFrame.origin.x -= restartFrame.size.width - oldRestartWidth; - } else { - // shift the close button right in RTL - closeFrame.origin.x += restartFrame.size.width - oldRestartWidth; - } - [mRestartButton setFrame:restartFrame]; - [mCloseButton setFrame:closeFrame]; - // possibly resize window if both buttons no longer fit - // leave 20 px from either side of the window, and 12 px - // between the buttons - float neededWidth = - closeFrame.size.width + restartFrame.size.width + 2 * 20 + 12; - - if (neededWidth > windowFrame.size.width) { - windowFrame.size.width = neededWidth; - [mWindow setFrame:windowFrame display:true animate:NO]; - } - [mRestartButton setKeyEquivalent:@"\r"]; - } - - NSButton* checkboxes[] = {mSubmitReportButton, mIncludeURLButton}; - - for (auto checkbox : checkboxes) { - NSRect frame = [checkbox frame]; - [checkbox sizeToFit]; - if (gRTLlayout) { - // sizeToFit will keep the left side fixed, so realign - float oldWidth = frame.size.width; - frame = [checkbox frame]; - frame.origin.x += oldWidth - frame.size.width; - [checkbox setFrame:frame]; - } - // keep existing spacing on left side, + 20 px spare on right - float neededWidth = - frame.origin.x + checkbox.intrinsicContentSize.width + 20; - if (neededWidth > windowFrame.size.width) { - windowFrame.size.width = neededWidth; - [mWindow setFrame:windowFrame display:true animate:NO]; - } - } - - // do this down here because we may have made the window wider - // up above - [self setStringFitVertically:mDescriptionLabel - string:Str(ST_CRASHREPORTERDESCRIPTION) - resizeWindow:YES]; - - // now pin all the controls (except quit/submit) in place, - // if we lengthen the window after this, it's just to lengthen - // the progress text, so nothing above that text should move. - NSView* views[] = {mSubmitReportButton, mViewReportButton, - mCommentScrollView, mIncludeURLButton, - mProgressIndicator, mProgressText}; - for (auto view : views) { - [view setAutoresizingMask:NSViewMinYMargin]; - } -} - -- (float)setStringFitVertically:(NSControl*)control - string:(NSString*)str - resizeWindow:(BOOL)resizeWindow { - // hack to make the text field grow vertically - NSRect frame = [control frame]; - float oldHeight = frame.size.height; - - frame.size.height = 10000; - NSSize oldCellSize = [[control cell] cellSizeForBounds:frame]; - [control setStringValue:str]; - NSSize newCellSize = [[control cell] cellSizeForBounds:frame]; - - float delta = newCellSize.height - oldCellSize.height; - frame.origin.y -= delta; - frame.size.height = oldHeight + delta; - [control setFrame:frame]; - - if (resizeWindow) { - NSRect frame = [mWindow frame]; - frame.origin.y -= delta; - frame.size.height += delta; - [mWindow setFrame:frame display:true animate:NO]; - } - - return delta; -} - -- (void)setView:(NSView*)v animate:(BOOL)animate { - NSRect frame = [mWindow frame]; - - NSRect oldViewFrame = [[mWindow contentView] frame]; - NSRect newViewFrame = [v frame]; - - frame.origin.y += oldViewFrame.size.height - newViewFrame.size.height; - frame.size.height -= oldViewFrame.size.height - newViewFrame.size.height; - - frame.origin.x += oldViewFrame.size.width - newViewFrame.size.width; - frame.size.width -= oldViewFrame.size.width - newViewFrame.size.width; - - [mWindow setContentView:v]; - [mWindow setFrame:frame display:true animate:animate]; -} - -- (void)enableControls:(BOOL)enabled { - [mViewReportButton setEnabled:enabled]; - [mIncludeURLButton setEnabled:enabled]; - [mCommentText setEnabled:enabled]; - [mCommentScrollView setHasVerticalScroller:enabled]; -} - -- (void)updateSubmit { - if ([mSubmitReportButton state] == NSOnState) { - [self setStringFitVertically:mProgressText - string:Str(ST_REPORTPRESUBMIT) - resizeWindow:YES]; - [mProgressText setHidden:NO]; - // enable all the controls - [self enableControls:YES]; - } else { - // not submitting, disable all the controls under - // the submit checkbox, and hide the status text - [mProgressText setHidden:YES]; - [self enableControls:NO]; - } -} - -- (void)updateURL { - if ([mIncludeURLButton state] == NSOnState && !gURLParameter.empty()) { - gQueryParameters["URL"] = gURLParameter; - } else { - gQueryParameters.removeMember("URL"); - } -} - -- (void)updateEmail { - // In order to remove the email fields, we have to edit the .nib files which - // we can't do with current xcode so we make them hidden; updating the - // crashreporter interface for mac is covered in bug #1696164 - [mEmailMeButton setHidden:YES]; - [mEmailText setHidden:YES]; -} - -- (void)sendReport { - if (![self setupPost]) { - LogMessage("Crash report submission failed: could not set up POST data"); - - if (gAutoSubmit) { - [NSApp terminate:self]; - } - - [self setStringFitVertically:mProgressText - string:Str(ST_SUBMITFAILED) - resizeWindow:YES]; - // quit after 5 seconds - [self performSelector:@selector(closeMeDown:) - withObject:nil - afterDelay:5.0]; - } - - [NSThread detachNewThreadSelector:@selector(uploadThread:) - toTarget:self - withObject:mPost]; -} - -- (bool)setupPost { - NSURL* url = [NSURL - URLWithString:[NSSTR(gSendURL) stringByAddingPercentEscapesUsingEncoding: - NSUTF8StringEncoding]]; - if (!url) return false; - - mPost = [[HTTPMultipartUpload alloc] initWithURL:url]; - if (!mPost) return false; - - for (StringTable::const_iterator i = gFiles.begin(); i != gFiles.end(); i++) { - [mPost addFileAtPath:NSSTR(i->second) name:NSSTR(i->first)]; - } - - Json::StreamWriterBuilder builder; - builder["indentation"] = ""; - string output = writeString(builder, gQueryParameters).append("\r\n"); - NSMutableString* parameters = - [[NSMutableString alloc] initWithUTF8String:output.c_str()]; - - [mPost setParameters:parameters]; - [parameters release]; - - return true; -} - -- (void)uploadComplete:(NSData*)data { - NSHTTPURLResponse* response = [mPost response]; - [mPost release]; - - bool success; - string reply; - if (!data || !response || [response statusCode] != 200) { - success = false; - reply = ""; - - // if data is nil, we probably logged an error in uploadThread - if (data != nil && response != nil) { - ostringstream message; - message << "Crash report submission failed: server returned status " - << [response statusCode]; - LogMessage(message.str()); - } - } else { - success = true; - LogMessage("Crash report submitted successfully"); - - NSString* encodingName = [response textEncodingName]; - NSStringEncoding encoding; - if (encodingName) { - encoding = CFStringConvertEncodingToNSStringEncoding( - CFStringConvertIANACharSetNameToEncoding((CFStringRef)encodingName)); - } else { - encoding = NSISOLatin1StringEncoding; - } - NSString* r = [[NSString alloc] initWithData:data encoding:encoding]; - reply = [r UTF8String]; - [r release]; - } - - SendCompleted(success, reply); - - if (gAutoSubmit) { - [NSApp terminate:self]; - } - - [mProgressIndicator stopAnimation:self]; - if (success) { - [self setStringFitVertically:mProgressText - string:Str(ST_REPORTSUBMITSUCCESS) - resizeWindow:YES]; - } else { - [self setStringFitVertically:mProgressText - string:Str(ST_SUBMITFAILED) - resizeWindow:YES]; - } - // quit after 5 seconds - [self performSelector:@selector(closeMeDown:) withObject:nil afterDelay:5.0]; -} - -- (void)uploadThread:(HTTPMultipartUpload*)post { - NSAutoreleasePool* autoreleasepool = [[NSAutoreleasePool alloc] init]; - NSError* error = nil; - NSData* data = [post send:&error]; - if (error) { - data = nil; - NSString* errorDesc = [error localizedDescription]; - string message = [errorDesc UTF8String]; - LogMessage("Crash report submission failed: " + message); - } - - [self performSelectorOnMainThread:@selector(uploadComplete:) - withObject:data - waitUntilDone:YES]; - - [autoreleasepool release]; -} - -// to get auto-quit when we close the window -- (BOOL)applicationShouldTerminateAfterLastWindowClosed: - (NSApplication*)theApplication { - return YES; -} - -- (void)applicationWillTerminate:(NSNotification*)aNotification { - // since we use [NSApp terminate:] we never return to main, - // so do our cleanup here - if (!gDidTrySend) DeleteDump(); -} - -@end - -@implementation TextViewWithPlaceHolder - -- (BOOL)becomeFirstResponder { - [self setNeedsDisplay:YES]; - return [super becomeFirstResponder]; -} - -- (void)drawRect:(NSRect)rect { - [super drawRect:rect]; - if (mPlaceHolderString && [[self string] isEqualToString:@""] && - self != [[self window] firstResponder]) - [mPlaceHolderString drawInRect:[self frame]]; -} - -- (BOOL)resignFirstResponder { - [self setNeedsDisplay:YES]; - return [super resignFirstResponder]; -} - -- (void)setPlaceholder:(NSString*)placeholder { - NSColor* txtColor = [NSColor disabledControlTextColor]; - NSDictionary* txtDict = [NSDictionary - dictionaryWithObjectsAndKeys:txtColor, NSForegroundColorAttributeName, - nil]; - mPlaceHolderString = - [[NSMutableAttributedString alloc] initWithString:placeholder - attributes:txtDict]; - if (gRTLlayout) - [mPlaceHolderString setAlignment:NSTextAlignmentRight - range:NSMakeRange(0, [placeholder length])]; -} - -- (void)insertTab:(id)sender { - // don't actually want to insert tabs, just tab to next control - [[self window] selectNextKeyView:sender]; -} - -- (void)insertBacktab:(id)sender { - [[self window] selectPreviousKeyView:sender]; -} - -- (void)setEnabled:(BOOL)enabled { - [self setSelectable:enabled]; - [self setEditable:enabled]; - if (![[self string] isEqualToString:@""]) { - NSAttributedString* colorString; - NSColor* txtColor; - if (enabled) - txtColor = [NSColor textColor]; - else - txtColor = [NSColor disabledControlTextColor]; - NSDictionary* txtDict = [NSDictionary - dictionaryWithObjectsAndKeys:txtColor, NSForegroundColorAttributeName, - nil]; - colorString = [[NSAttributedString alloc] initWithString:[self string] - attributes:txtDict]; - [[self textStorage] setAttributedString:colorString]; - [self setInsertionPointColor:txtColor]; - [colorString release]; - } -} - -- (void)dealloc { - [mPlaceHolderString release]; - [super dealloc]; -} - -@end - -/* === Crashreporter UI Functions === */ - -bool UIInit() { - gMainPool = [[NSAutoreleasePool alloc] init]; - [NSApplication sharedApplication]; - - if (gStrings.find("isRTL") != gStrings.end() && gStrings["isRTL"] == "yes") - gRTLlayout = true; - - if (gAutoSubmit) { - gUI = [[CrashReporterUI alloc] init]; - } else { - [[NSBundle mainBundle] - loadNibNamed:(gRTLlayout ? @"MainMenuRTL" : @"MainMenu") - owner:NSApp - topLevelObjects:nil]; - } - - return true; -} - -void UIShutdown() { [gMainPool release]; } - -void UIShowDefaultUI() { - [gUI showErrorUI:gStrings[ST_CRASHREPORTERDEFAULT]]; - [NSApp run]; -} - -bool UIShowCrashUI(const StringTable& files, const Json::Value& queryParameters, - const string& sendURL, const vector<string>& restartArgs) { - gRestartArgs = restartArgs; - - [gUI showCrashUI:files queryParameters:queryParameters sendURL:sendURL]; - [NSApp run]; - - return gDidTrySend; -} - -void UIError_impl(const string& message) { - if (!gUI) { - // UI failed to initialize, printing is the best we can do - printf("Error: %s\n", message.c_str()); - return; - } - - [gUI showErrorUI:message]; - [NSApp run]; -} - -bool UIGetIniPath(string& path) { - NSString* tmpPath = [NSString stringWithUTF8String:gArgv[0]]; - NSString* iniName = [tmpPath lastPathComponent]; - iniName = [iniName stringByAppendingPathExtension:@"ini"]; - tmpPath = [tmpPath stringByDeletingLastPathComponent]; - tmpPath = [tmpPath stringByDeletingLastPathComponent]; - tmpPath = [tmpPath stringByAppendingPathComponent:@"Resources"]; - tmpPath = [tmpPath stringByAppendingPathComponent:iniName]; - path = [tmpPath UTF8String]; - return true; -} - -bool UIGetSettingsPath(const string& vendor, const string& product, - string& settingsPath) { - NSArray* paths = NSSearchPathForDirectoriesInDomains( - NSApplicationSupportDirectory, NSUserDomainMask, YES); - NSString* destPath = [paths firstObject]; - - // Note that MacOS ignores the vendor when creating the profile hierarchy - - // all application preferences directories live alongside one another in - // ~/Library/Application Support/ - destPath = [destPath stringByAppendingPathComponent:NSSTR(product)]; - // Thunderbird stores its profile in ~/Library/Thunderbird, - // but we're going to put stuff in ~/Library/Application Support/Thunderbird - // anyway, so we have to ensure that path exists. - string tempPath = [destPath UTF8String]; - if (!UIEnsurePathExists(tempPath)) return false; - - destPath = [destPath stringByAppendingPathComponent:@"Crash Reports"]; - - settingsPath = [destPath UTF8String]; - - return true; -} - -bool UIMoveFile(const string& file, const string& newfile) { - if (!rename(file.c_str(), newfile.c_str())) return true; - if (errno != EXDEV) return false; - - NSFileManager* fileManager = [NSFileManager defaultManager]; - NSString* source = - [fileManager stringWithFileSystemRepresentation:file.c_str() - length:file.length()]; - NSString* dest = - [fileManager stringWithFileSystemRepresentation:newfile.c_str() - length:newfile.length()]; - if (!source || !dest) return false; - - [fileManager moveItemAtPath:source toPath:dest error:NULL]; - return UIFileExists(newfile); -} diff --git a/toolkit/crashreporter/client/crashreporter_unix_common.cpp b/toolkit/crashreporter/client/crashreporter_unix_common.cpp deleted file mode 100644 index e6514d4423..0000000000 --- a/toolkit/crashreporter/client/crashreporter_unix_common.cpp +++ /dev/null @@ -1,139 +0,0 @@ -/* 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/. */ - -#include "crashreporter.h" - -#include <algorithm> -#include <sys/wait.h> - -#include <dirent.h> -#include <errno.h> -#include <sys/stat.h> -#include <unistd.h> - -using namespace CrashReporter; -using std::ios_base; -using std::sort; -using std::string; -using std::vector; - -struct FileData { - time_t timestamp; - string path; -}; - -static bool CompareFDTime(const FileData& fd1, const FileData& fd2) { - return fd1.timestamp > fd2.timestamp; -} - -void UIPruneSavedDumps(const string& directory) { - DIR* dirfd = opendir(directory.c_str()); - if (!dirfd) return; - - vector<FileData> dumpfiles; - - while (dirent* dir = readdir(dirfd)) { - FileData fd; - fd.path = directory + '/' + dir->d_name; - if (fd.path.size() < 5) continue; - - if (fd.path.compare(fd.path.size() - 4, 4, ".dmp") != 0) continue; - - struct stat st; - if (stat(fd.path.c_str(), &st)) { - closedir(dirfd); - return; - } - - fd.timestamp = st.st_mtime; - - dumpfiles.push_back(fd); - } - - closedir(dirfd); - - sort(dumpfiles.begin(), dumpfiles.end(), CompareFDTime); - - while (dumpfiles.size() > kSaveCount) { - // get the path of the oldest file - string path = dumpfiles[dumpfiles.size() - 1].path; - UIDeleteFile(path); - - // s/.dmp/.extra/ - path.replace(path.size() - 4, 4, ".extra"); - UIDeleteFile(path); - - dumpfiles.pop_back(); - } -} - -bool UIRunProgram(const string& exename, const vector<string>& args, - bool wait) { - pid_t pid = fork(); - - if (pid == -1) { - return false; - } else if (pid == 0) { - // Child - size_t argvLen = args.size() + 2; - vector<char*> argv(argvLen); - - argv[0] = const_cast<char*>(exename.c_str()); - - for (size_t i = 0; i < args.size(); i++) { - argv[i + 1] = const_cast<char*>(args[i].c_str()); - } - - argv[argvLen - 1] = nullptr; - - // Run the program - int rv = execv(exename.c_str(), argv.data()); - - if (rv == -1) { - exit(EXIT_FAILURE); - } - } else { - // Parent - if (wait) { - waitpid(pid, nullptr, 0); - } - } - - return true; -} - -bool UIEnsurePathExists(const string& path) { - int ret = mkdir(path.c_str(), S_IRWXU); - int e = errno; - if (ret == -1 && e != EEXIST) return false; - - return true; -} - -bool UIFileExists(const string& path) { - struct stat sb; - int ret = stat(path.c_str(), &sb); - if (ret == -1 || !(sb.st_mode & S_IFREG)) return false; - - return true; -} - -bool UIDeleteFile(const string& file) { return (unlink(file.c_str()) != -1); } - -std::ifstream* UIOpenRead(const string& filename, ios_base::openmode mode) { - return new std::ifstream(filename.c_str(), mode); -} - -std::ofstream* UIOpenWrite(const string& filename, ios_base::openmode mode) { - return new std::ofstream(filename.c_str(), mode); -} - -string UIGetEnv(const string& name) { - const char* var = getenv(name.c_str()); - if (var && *var) { - return var; - } - - return ""; -} diff --git a/toolkit/crashreporter/client/crashreporter_win.cpp b/toolkit/crashreporter/client/crashreporter_win.cpp deleted file mode 100644 index 35018bda4a..0000000000 --- a/toolkit/crashreporter/client/crashreporter_win.cpp +++ /dev/null @@ -1,1266 +0,0 @@ -/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ -/* 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/. */ - -#ifdef WIN32_LEAN_AND_MEAN -# undef WIN32_LEAN_AND_MEAN -#endif - -#include "crashreporter.h" - -#include <windows.h> -#include <commctrl.h> -#include <richedit.h> -#include <shellapi.h> -#include <shlobj.h> -#include <shlwapi.h> -#include <math.h> -#include <set> -#include <algorithm> -#include "resource.h" -#include "windows/sender/crash_report_sender.h" -#include "common/windows/string_utils-inl.h" - -#define SUBMIT_REPORT_VALUE L"SubmitCrashReport" -#define INCLUDE_URL_VALUE L"IncludeURL" - -#define WM_UPLOADCOMPLETE WM_APP - -// Thanks, Windows.h :( -#undef min -#undef max - -using std::ifstream; -using std::ios; -using std::ios_base; -using std::map; -using std::ofstream; -using std::set; -using std::string; -using std::vector; -using std::wstring; - -using namespace CrashReporter; - -typedef struct { - HWND hDlg; - Json::Value queryParameters; - map<wstring, wstring> files; - wstring sendURL; - - wstring serverResponse; -} SendThreadData; - -/* - * Per http://msdn2.microsoft.com/en-us/library/ms645398(VS.85).aspx - * "The DLGTEMPLATEEX structure is not defined in any standard header file. - * The structure definition is provided here to explain the format of an - * extended template for a dialog box. - */ -typedef struct { - WORD dlgVer; - WORD signature; - DWORD helpID; - DWORD exStyle; - // There's more to this struct, but it has weird variable-length - // members, and I only actually need to touch exStyle on an existing - // instance, so I've omitted the rest. -} DLGTEMPLATEEX; - -static HANDLE gThreadHandle; -static SendThreadData gSendData = { - 0, -}; -static vector<string> gRestartArgs; -static Json::Value gQueryParameters; -static wstring gCrashReporterKey(L"Software\\Mozilla\\Crash Reporter"); -static string gURLParameter; -static int gCheckboxPadding = 6; -static bool gRTLlayout = false; - -// When vertically resizing the dialog, these items should move down -static set<UINT> gAttachedBottom; - -// Default set of items for gAttachedBottom -static const UINT kDefaultAttachedBottom[] = { - IDC_SUBMITREPORTCHECK, IDC_VIEWREPORTBUTTON, IDC_COMMENTTEXT, - IDC_INCLUDEURLCHECK, IDC_PROGRESSTEXT, IDC_THROBBER, - IDC_CLOSEBUTTON, IDC_RESTARTBUTTON, -}; - -static wstring UTF8ToWide(const string& utf8, bool* success = 0); -static DWORD WINAPI SendThreadProc(LPVOID param); - -static wstring Str(const char* key) { return UTF8ToWide(gStrings[key]); } - -/* === win32 helper functions === */ - -static void DoInitCommonControls() { - INITCOMMONCONTROLSEX ic; - ic.dwSize = sizeof(INITCOMMONCONTROLSEX); - ic.dwICC = ICC_PROGRESS_CLASS; - InitCommonControlsEx(&ic); - // also get the rich edit control - LoadLibrary(L"Msftedit.dll"); -} - -static bool GetBoolValue(HKEY hRegKey, LPCTSTR valueName, DWORD* value) { - DWORD type, dataSize; - dataSize = sizeof(DWORD); - if (RegQueryValueEx(hRegKey, valueName, nullptr, &type, (LPBYTE)value, - &dataSize) == ERROR_SUCCESS && - type == REG_DWORD) - return true; - - return false; -} - -static bool CheckBoolKey(const wchar_t* key, const wchar_t* valueName, - bool* enabled) { - /* - * NOTE! This code needs to stay in sync with the preference checking - * code in in nsExceptionHandler.cpp. - */ - *enabled = false; - bool found = false; - HKEY hRegKey; - DWORD val; - // see if our reg key is set globally - if (RegOpenKey(HKEY_LOCAL_MACHINE, key, &hRegKey) == ERROR_SUCCESS) { - if (GetBoolValue(hRegKey, valueName, &val)) { - *enabled = (val == 1); - found = true; - } - RegCloseKey(hRegKey); - } else { - // look for it in user settings - if (RegOpenKey(HKEY_CURRENT_USER, key, &hRegKey) == ERROR_SUCCESS) { - if (GetBoolValue(hRegKey, valueName, &val)) { - *enabled = (val == 1); - found = true; - } - RegCloseKey(hRegKey); - } - } - - return found; -} - -static void SetBoolKey(const wchar_t* key, const wchar_t* value, bool enabled) { - /* - * NOTE! This code needs to stay in sync with the preference setting - * code in in nsExceptionHandler.cpp. - */ - HKEY hRegKey; - - if (RegCreateKey(HKEY_CURRENT_USER, key, &hRegKey) == ERROR_SUCCESS) { - DWORD data = (enabled ? 1 : 0); - RegSetValueEx(hRegKey, value, 0, REG_DWORD, (LPBYTE)&data, sizeof(data)); - RegCloseKey(hRegKey); - } -} - -static string FormatLastError() { - DWORD err = GetLastError(); - LPWSTR s; - string message = "Crash report submission failed: "; - // odds are it's a WinInet error - HANDLE hInetModule = GetModuleHandle(L"WinInet.dll"); - if (FormatMessage(FORMAT_MESSAGE_ALLOCATE_BUFFER | - FORMAT_MESSAGE_FROM_SYSTEM | - FORMAT_MESSAGE_FROM_HMODULE, - hInetModule, err, 0, (LPWSTR)&s, 0, nullptr) != 0) { - message += WideToUTF8(s, nullptr); - LocalFree(s); - // strip off any trailing newlines - string::size_type n = message.find_last_not_of("\r\n"); - if (n < message.size() - 1) { - message.erase(n + 1); - } - } else { - char buf[64]; - sprintf(buf, "Unknown error, error code: 0x%08x", - static_cast<unsigned int>(err)); - message += buf; - } - return message; -} - -#define TS_DRAW 2 -#define BP_CHECKBOX 3 - -typedef HANDLE(WINAPI* OpenThemeDataPtr)(HWND hwnd, LPCWSTR pszClassList); -typedef HRESULT(WINAPI* CloseThemeDataPtr)(HANDLE hTheme); -typedef HRESULT(WINAPI* GetThemePartSizePtr)(HANDLE hTheme, HDC hdc, - int iPartId, int iStateId, - RECT* prc, int ts, SIZE* psz); -typedef HRESULT(WINAPI* GetThemeContentRectPtr)(HANDLE hTheme, HDC hdc, - int iPartId, int iStateId, - const RECT* pRect, - RECT* pContentRect); - -static void GetThemeSizes(HWND hwnd) { - HMODULE themeDLL = LoadLibrary(L"uxtheme.dll"); - - if (!themeDLL) return; - - OpenThemeDataPtr openTheme = - (OpenThemeDataPtr)GetProcAddress(themeDLL, "OpenThemeData"); - CloseThemeDataPtr closeTheme = - (CloseThemeDataPtr)GetProcAddress(themeDLL, "CloseThemeData"); - GetThemePartSizePtr getThemePartSize = - (GetThemePartSizePtr)GetProcAddress(themeDLL, "GetThemePartSize"); - - if (!openTheme || !closeTheme || !getThemePartSize) { - FreeLibrary(themeDLL); - return; - } - - HANDLE buttonTheme = openTheme(hwnd, L"Button"); - if (!buttonTheme) { - FreeLibrary(themeDLL); - return; - } - HDC hdc = GetDC(hwnd); - SIZE s; - getThemePartSize(buttonTheme, hdc, BP_CHECKBOX, 0, nullptr, TS_DRAW, &s); - gCheckboxPadding = s.cx; - closeTheme(buttonTheme); - FreeLibrary(themeDLL); -} - -// Gets the position of a window relative to another window's client area -static void GetRelativeRect(HWND hwnd, HWND hwndParent, RECT* r) { - GetWindowRect(hwnd, r); - MapWindowPoints(nullptr, hwndParent, (POINT*)r, 2); -} - -static void SetDlgItemVisible(HWND hwndDlg, UINT item, bool visible) { - HWND hwnd = GetDlgItem(hwndDlg, item); - - ShowWindow(hwnd, visible ? SW_SHOW : SW_HIDE); -} - -/* === Crash Reporting Dialog === */ - -static void StretchDialog(HWND hwndDlg, int ydiff) { - RECT r; - GetWindowRect(hwndDlg, &r); - r.bottom += ydiff; - MoveWindow(hwndDlg, r.left, r.top, r.right - r.left, r.bottom - r.top, TRUE); -} - -static void ReflowDialog(HWND hwndDlg, int ydiff) { - // Move items attached to the bottom down/up by as much as - // the window resize - for (set<UINT>::const_iterator item = gAttachedBottom.begin(); - item != gAttachedBottom.end(); item++) { - RECT r; - HWND hwnd = GetDlgItem(hwndDlg, *item); - GetRelativeRect(hwnd, hwndDlg, &r); - r.top += ydiff; - r.bottom += ydiff; - MoveWindow(hwnd, r.left, r.top, r.right - r.left, r.bottom - r.top, TRUE); - } -} - -static DWORD WINAPI SendThreadProc(LPVOID param) { - bool finishedOk; - SendThreadData* td = (SendThreadData*)param; - - if (td->sendURL.empty()) { - finishedOk = false; - LogMessage("No server URL, not sending report"); - } else { - Json::StreamWriterBuilder builder; - builder["indentation"] = ""; - string parameters(Json::writeString(builder, td->queryParameters)); - google_breakpad::CrashReportSender sender(L""); - finishedOk = (sender.SendCrashReport(td->sendURL, parameters, td->files, - &td->serverResponse) == - google_breakpad::RESULT_SUCCEEDED); - if (finishedOk) { - LogMessage("Crash report submitted successfully"); - } else { - // get an error string and print it to the log - // XXX: would be nice to get the HTTP status code here, filed: - // http://code.google.com/p/google-breakpad/issues/detail?id=220 - LogMessage(FormatLastError()); - } - } - - if (gAutoSubmit) { - // Ordinarily this is done on the main thread in CrashReporterDialogProc, - // for auto submit we don't run that and it should be safe to finish up - // here as is done on other platforms. - SendCompleted(finishedOk, WideToUTF8(gSendData.serverResponse)); - } else { - PostMessage(td->hDlg, WM_UPLOADCOMPLETE, finishedOk ? 1 : 0, 0); - } - - return 0; -} - -static void EndCrashReporterDialog(HWND hwndDlg, int code) { - // Save the current values to the registry - SetBoolKey(gCrashReporterKey.c_str(), INCLUDE_URL_VALUE, - IsDlgButtonChecked(hwndDlg, IDC_INCLUDEURLCHECK) != 0); - SetBoolKey(gCrashReporterKey.c_str(), SUBMIT_REPORT_VALUE, - IsDlgButtonChecked(hwndDlg, IDC_SUBMITREPORTCHECK) != 0); - - EndDialog(hwndDlg, code); -} - -static void MaybeResizeProgressText(HWND hwndDlg) { - HWND hwndProgress = GetDlgItem(hwndDlg, IDC_PROGRESSTEXT); - HDC hdc = GetDC(hwndProgress); - HFONT hfont = (HFONT)SendMessage(hwndProgress, WM_GETFONT, 0, 0); - if (hfont) SelectObject(hdc, hfont); - SIZE size; - RECT rect; - GetRelativeRect(hwndProgress, hwndDlg, &rect); - - wchar_t text[1024]; - GetWindowText(hwndProgress, text, 1024); - - if (!GetTextExtentPoint32(hdc, text, wcslen(text), &size)) return; - - if (size.cx < (rect.right - rect.left)) return; - - // Figure out how much we need to resize things vertically - // This is sort of a fudge, but it should be good enough. - int wantedHeight = - size.cy * (int)ceil((float)size.cx / (float)(rect.right - rect.left)); - int diff = wantedHeight - (rect.bottom - rect.top); - if (diff <= 0) return; - - MoveWindow(hwndProgress, rect.left, rect.top, rect.right - rect.left, - wantedHeight, TRUE); - - gAttachedBottom.clear(); - gAttachedBottom.insert(IDC_CLOSEBUTTON); - gAttachedBottom.insert(IDC_RESTARTBUTTON); - - StretchDialog(hwndDlg, diff); - - for (size_t i = 0; i < sizeof(kDefaultAttachedBottom) / sizeof(UINT); i++) { - gAttachedBottom.insert(kDefaultAttachedBottom[i]); - } -} - -static void MaybeSendReport(HWND hwndDlg) { - if (!IsDlgButtonChecked(hwndDlg, IDC_SUBMITREPORTCHECK)) { - EndCrashReporterDialog(hwndDlg, 0); - return; - } - - // disable all the form controls - EnableWindow(GetDlgItem(hwndDlg, IDC_SUBMITREPORTCHECK), false); - EnableWindow(GetDlgItem(hwndDlg, IDC_VIEWREPORTBUTTON), false); - EnableWindow(GetDlgItem(hwndDlg, IDC_COMMENTTEXT), false); - EnableWindow(GetDlgItem(hwndDlg, IDC_INCLUDEURLCHECK), false); - EnableWindow(GetDlgItem(hwndDlg, IDC_CLOSEBUTTON), false); - EnableWindow(GetDlgItem(hwndDlg, IDC_RESTARTBUTTON), false); - - SetDlgItemText(hwndDlg, IDC_PROGRESSTEXT, Str(ST_REPORTDURINGSUBMIT).c_str()); - MaybeResizeProgressText(hwndDlg); - // start throbber - // play entire AVI, and loop - Animate_Play(GetDlgItem(hwndDlg, IDC_THROBBER), 0, -1, -1); - SetDlgItemVisible(hwndDlg, IDC_THROBBER, true); - gThreadHandle = nullptr; - gSendData.hDlg = hwndDlg; - gSendData.queryParameters = gQueryParameters; - - gThreadHandle = - CreateThread(nullptr, 0, SendThreadProc, &gSendData, 0, nullptr); -} - -static void RestartApplication() { - wstring cmdLine; - - for (unsigned int i = 0; i < gRestartArgs.size(); i++) { - cmdLine += L"\"" + UTF8ToWide(gRestartArgs[i]) + L"\" "; - } - - STARTUPINFO si; - PROCESS_INFORMATION pi; - - ZeroMemory(&si, sizeof(si)); - si.cb = sizeof(si); - si.dwFlags = STARTF_USESHOWWINDOW; - si.wShowWindow = SW_SHOWNORMAL; - ZeroMemory(&pi, sizeof(pi)); - - if (CreateProcess(nullptr, (LPWSTR)cmdLine.c_str(), nullptr, nullptr, FALSE, - 0, nullptr, nullptr, &si, &pi)) { - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - } -} - -static void ShowReportInfo(HWND hwndDlg) { - wstring description; - - for (Json::ValueConstIterator iter = gQueryParameters.begin(); - iter != gQueryParameters.end(); ++iter) { - description += UTF8ToWide(iter.name()); - description += L": "; - string value; - if (iter->isString()) { - value = iter->asString(); - } else { - Json::StreamWriterBuilder builder; - builder["indentation"] = ""; - value = Json::writeString(builder, *iter); - } - description += UTF8ToWide(value); - description += L"\n"; - } - - description += L"\n"; - description += Str(ST_EXTRAREPORTINFO); - - SetDlgItemText(hwndDlg, IDC_VIEWREPORTTEXT, description.c_str()); -} - -static void UpdateURL(HWND hwndDlg) { - if (IsDlgButtonChecked(hwndDlg, IDC_INCLUDEURLCHECK)) { - gQueryParameters["URL"] = gURLParameter; - } else { - gQueryParameters.removeMember("URL"); - } -} - -static void UpdateComment(HWND hwndDlg) { - wchar_t comment[MAX_COMMENT_LENGTH + 1]; - GetDlgItemTextW(hwndDlg, IDC_COMMENTTEXT, comment, - sizeof(comment) / sizeof(comment[0])); - if (wcslen(comment) > 0) - gQueryParameters["Comments"] = WideToUTF8(comment); - else - gQueryParameters.removeMember("Comments"); -} - -/* - * Dialog procedure for the "view report" dialog. - */ -static BOOL CALLBACK ViewReportDialogProc(HWND hwndDlg, UINT message, - WPARAM wParam, LPARAM lParam) { - switch (message) { - case WM_INITDIALOG: { - SetWindowText(hwndDlg, Str(ST_VIEWREPORTTITLE).c_str()); - SetDlgItemText(hwndDlg, IDOK, Str(ST_OK).c_str()); - SendDlgItemMessage(hwndDlg, IDC_VIEWREPORTTEXT, EM_SETTARGETDEVICE, - (WPARAM) nullptr, 0); - ShowReportInfo(hwndDlg); - SetFocus(GetDlgItem(hwndDlg, IDOK)); - return FALSE; - } - - case WM_COMMAND: { - if (HIWORD(wParam) == BN_CLICKED && LOWORD(wParam) == IDOK) - EndDialog(hwndDlg, 0); - return FALSE; - } - } - return FALSE; -} - -// Return the number of bytes this string will take encoded -// in UTF-8 -static inline int BytesInUTF8(wchar_t* str) { - // Just count size of buffer for UTF-8, minus one - // (we don't need to count the null terminator) - return WideCharToMultiByte(CP_UTF8, 0, str, -1, nullptr, 0, nullptr, - nullptr) - - 1; -} - -// Calculate the length of the text in this edit control (in bytes, -// in the UTF-8 encoding) after replacing the current selection -// with |insert|. -static int NewTextLength(HWND hwndEdit, wchar_t* insert) { - wchar_t current[MAX_COMMENT_LENGTH + 1]; - - GetWindowText(hwndEdit, current, MAX_COMMENT_LENGTH + 1); - DWORD selStart, selEnd; - SendMessage(hwndEdit, EM_GETSEL, (WPARAM)&selStart, (LPARAM)&selEnd); - - int selectionLength = 0; - if (selEnd - selStart > 0) { - wchar_t selection[MAX_COMMENT_LENGTH + 1]; - google_breakpad::WindowsStringUtils::safe_wcsncpy( - selection, MAX_COMMENT_LENGTH + 1, current + selStart, - selEnd - selStart); - selection[selEnd - selStart] = '\0'; - selectionLength = BytesInUTF8(selection); - } - - // current string length + replacement text length - // - replaced selection length - return BytesInUTF8(current) + BytesInUTF8(insert) - selectionLength; -} - -// Window procedure for subclassing edit controls -static LRESULT CALLBACK EditSubclassProc(HWND hwnd, UINT uMsg, WPARAM wParam, - LPARAM lParam) { - static WNDPROC super = nullptr; - - if (super == nullptr) super = (WNDPROC)GetWindowLongPtr(hwnd, GWLP_USERDATA); - - switch (uMsg) { - case WM_PAINT: { - HDC hdc; - PAINTSTRUCT ps; - RECT r; - wchar_t windowText[1024]; - - GetWindowText(hwnd, windowText, 1024); - // if the control contains text or is focused, draw it normally - if (GetFocus() == hwnd || windowText[0] != '\0') - return CallWindowProc(super, hwnd, uMsg, wParam, lParam); - - GetClientRect(hwnd, &r); - hdc = BeginPaint(hwnd, &ps); - FillRect(hdc, &r, - GetSysColorBrush(IsWindowEnabled(hwnd) ? COLOR_WINDOW - : COLOR_BTNFACE)); - SetTextColor(hdc, GetSysColor(COLOR_GRAYTEXT)); - SelectObject(hdc, (HFONT)GetStockObject(DEFAULT_GUI_FONT)); - SetBkMode(hdc, TRANSPARENT); - wchar_t* txt = (wchar_t*)GetProp(hwnd, L"PROP_GRAYTEXT"); - // Get the actual edit control rect - CallWindowProc(super, hwnd, EM_GETRECT, 0, (LPARAM)&r); - UINT format = DT_EDITCONTROL | DT_NOPREFIX | DT_WORDBREAK | DT_INTERNAL; - if (gRTLlayout) format |= DT_RIGHT; - if (txt) DrawText(hdc, txt, wcslen(txt), &r, format); - EndPaint(hwnd, &ps); - return 0; - } - - // We handle WM_CHAR and WM_PASTE to limit the comment box to 500 - // bytes in UTF-8. - case WM_CHAR: { - // Leave accelerator keys and non-printing chars (except LF) alone - if (wParam & (1 << 24) || wParam & (1 << 29) || - (wParam < ' ' && wParam != '\n')) - break; - - wchar_t ch[2] = {(wchar_t)wParam, 0}; - if (NewTextLength(hwnd, ch) > MAX_COMMENT_LENGTH) return 0; - - break; - } - - case WM_PASTE: { - if (IsClipboardFormatAvailable(CF_UNICODETEXT) && OpenClipboard(hwnd)) { - HGLOBAL hg = GetClipboardData(CF_UNICODETEXT); - wchar_t* pastedText = (wchar_t*)GlobalLock(hg); - int newSize = 0; - - if (pastedText) newSize = NewTextLength(hwnd, pastedText); - - GlobalUnlock(hg); - CloseClipboard(); - - if (newSize > MAX_COMMENT_LENGTH) return 0; - } - break; - } - - case WM_SETFOCUS: - case WM_KILLFOCUS: { - RECT r; - GetClientRect(hwnd, &r); - InvalidateRect(hwnd, &r, TRUE); - break; - } - - case WM_DESTROY: { - // cleanup our property - HGLOBAL hData = RemoveProp(hwnd, L"PROP_GRAYTEXT"); - if (hData) GlobalFree(hData); - } - } - - return CallWindowProc(super, hwnd, uMsg, wParam, lParam); -} - -// Resize a control to fit this text -static int ResizeControl(HWND hwndButton, RECT& rect, wstring text, - bool shiftLeft, int userDefinedPadding) { - HDC hdc = GetDC(hwndButton); - HFONT hfont = (HFONT)SendMessage(hwndButton, WM_GETFONT, 0, 0); - if (hfont) SelectObject(hdc, hfont); - SIZE size, oldSize; - int sizeDiff = 0; - - wchar_t oldText[1024]; - GetWindowText(hwndButton, oldText, 1024); - - if (GetTextExtentPoint32(hdc, text.c_str(), text.length(), &size) - // default text on the button - && GetTextExtentPoint32(hdc, oldText, wcslen(oldText), &oldSize)) { - /* - Expand control widths to accomidate wider text strings. For most - controls (including buttons) the text padding is defined by the - dialog's rc file. Some controls (such as checkboxes) have padding - that extends to the end of the dialog, in which case we ignore the - rc padding and rely on a user defined value passed in through - userDefinedPadding. - */ - int textIncrease = size.cx - oldSize.cx; - if (textIncrease < 0) return 0; - int existingTextPadding; - if (userDefinedPadding == 0) - existingTextPadding = (rect.right - rect.left) - oldSize.cx; - else - existingTextPadding = userDefinedPadding; - sizeDiff = textIncrease + existingTextPadding; - - if (shiftLeft) { - // shift left by the amount the button should grow - rect.left -= sizeDiff; - } else { - // grow right instead - rect.right += sizeDiff; - } - MoveWindow(hwndButton, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return sizeDiff; -} - -// The window was resized horizontally, so widen some of our -// controls to make use of the space -static void StretchControlsToFit(HWND hwndDlg) { - int controls[] = {IDC_DESCRIPTIONTEXT, IDC_SUBMITREPORTCHECK, IDC_COMMENTTEXT, - IDC_INCLUDEURLCHECK, IDC_PROGRESSTEXT}; - - RECT dlgRect; - GetClientRect(hwndDlg, &dlgRect); - - for (size_t i = 0; i < sizeof(controls) / sizeof(controls[0]); i++) { - RECT r; - HWND hwndControl = GetDlgItem(hwndDlg, controls[i]); - GetRelativeRect(hwndControl, hwndDlg, &r); - // 6 pixel spacing on the right - if (r.right + 6 != dlgRect.right) { - r.right = dlgRect.right - 6; - MoveWindow(hwndControl, r.left, r.top, r.right - r.left, r.bottom - r.top, - TRUE); - } - } -} - -static void SubmitReportChecked(HWND hwndDlg) { - bool enabled = (IsDlgButtonChecked(hwndDlg, IDC_SUBMITREPORTCHECK) != 0); - EnableWindow(GetDlgItem(hwndDlg, IDC_VIEWREPORTBUTTON), enabled); - EnableWindow(GetDlgItem(hwndDlg, IDC_COMMENTTEXT), enabled); - EnableWindow(GetDlgItem(hwndDlg, IDC_INCLUDEURLCHECK), enabled); - SetDlgItemVisible(hwndDlg, IDC_PROGRESSTEXT, enabled); -} - -static INT_PTR DialogBoxParamMaybeRTL(UINT idd, HWND hwndParent, - DLGPROC dlgProc, LPARAM param) { - INT_PTR rv = 0; - if (gRTLlayout) { - // We need to toggle the WS_EX_LAYOUTRTL style flag on the dialog - // template. - HRSRC hDialogRC = FindResource(nullptr, MAKEINTRESOURCE(idd), RT_DIALOG); - HGLOBAL hDlgTemplate = LoadResource(nullptr, hDialogRC); - DLGTEMPLATEEX* pDlgTemplate = (DLGTEMPLATEEX*)LockResource(hDlgTemplate); - unsigned long sizeDlg = SizeofResource(nullptr, hDialogRC); - HGLOBAL hMyDlgTemplate = GlobalAlloc(GPTR, sizeDlg); - DLGTEMPLATEEX* pMyDlgTemplate = (DLGTEMPLATEEX*)GlobalLock(hMyDlgTemplate); - memcpy(pMyDlgTemplate, pDlgTemplate, sizeDlg); - - pMyDlgTemplate->exStyle |= WS_EX_LAYOUTRTL; - - rv = DialogBoxIndirectParam(nullptr, (LPCDLGTEMPLATE)pMyDlgTemplate, - hwndParent, dlgProc, param); - GlobalUnlock(hMyDlgTemplate); - GlobalFree(hMyDlgTemplate); - } else { - rv = DialogBoxParam(nullptr, MAKEINTRESOURCE(idd), hwndParent, dlgProc, - param); - } - - return rv; -} - -static BOOL CALLBACK CrashReporterDialogProc(HWND hwndDlg, UINT message, - WPARAM wParam, LPARAM lParam) { - static int sHeight = 0; - - bool success; - bool enabled; - - switch (message) { - case WM_INITDIALOG: { - GetThemeSizes(hwndDlg); - RECT r; - GetClientRect(hwndDlg, &r); - sHeight = r.bottom - r.top; - - SetWindowText(hwndDlg, Str(ST_CRASHREPORTERTITLE).c_str()); - HICON hIcon = - LoadIcon(GetModuleHandle(nullptr), MAKEINTRESOURCE(IDI_MAINICON)); - SendMessage(hwndDlg, WM_SETICON, ICON_SMALL, (LPARAM)hIcon); - SendMessage(hwndDlg, WM_SETICON, ICON_BIG, (LPARAM)hIcon); - - // resize the "View Report" button based on the string length - RECT rect; - HWND hwnd = GetDlgItem(hwndDlg, IDC_VIEWREPORTBUTTON); - GetRelativeRect(hwnd, hwndDlg, &rect); - ResizeControl(hwnd, rect, Str(ST_VIEWREPORT), false, 0); - SetDlgItemText(hwndDlg, IDC_VIEWREPORTBUTTON, Str(ST_VIEWREPORT).c_str()); - - hwnd = GetDlgItem(hwndDlg, IDC_SUBMITREPORTCHECK); - GetRelativeRect(hwnd, hwndDlg, &rect); - long maxdiff = ResizeControl(hwnd, rect, Str(ST_CHECKSUBMIT), false, - gCheckboxPadding); - SetDlgItemText(hwndDlg, IDC_SUBMITREPORTCHECK, - Str(ST_CHECKSUBMIT).c_str()); - - if (!CheckBoolKey(gCrashReporterKey.c_str(), SUBMIT_REPORT_VALUE, - &enabled)) - enabled = true; - - CheckDlgButton(hwndDlg, IDC_SUBMITREPORTCHECK, - enabled ? BST_CHECKED : BST_UNCHECKED); - SubmitReportChecked(hwndDlg); - - HWND hwndComment = GetDlgItem(hwndDlg, IDC_COMMENTTEXT); - WNDPROC OldWndProc = (WNDPROC)SetWindowLongPtr( - hwndComment, GWLP_WNDPROC, (LONG_PTR)EditSubclassProc); - - // Subclass comment edit control to get placeholder text - SetWindowLongPtr(hwndComment, GWLP_USERDATA, (LONG_PTR)OldWndProc); - wstring commentGrayText = Str(ST_COMMENTGRAYTEXT); - wchar_t* hMem = (wchar_t*)GlobalAlloc( - GPTR, (commentGrayText.length() + 1) * sizeof(wchar_t)); - wcscpy(hMem, commentGrayText.c_str()); - SetProp(hwndComment, L"PROP_GRAYTEXT", hMem); - - hwnd = GetDlgItem(hwndDlg, IDC_INCLUDEURLCHECK); - GetRelativeRect(hwnd, hwndDlg, &rect); - long diff = - ResizeControl(hwnd, rect, Str(ST_CHECKURL), false, gCheckboxPadding); - maxdiff = std::max(diff, maxdiff); - SetDlgItemText(hwndDlg, IDC_INCLUDEURLCHECK, Str(ST_CHECKURL).c_str()); - - // want this on by default - if (CheckBoolKey(gCrashReporterKey.c_str(), INCLUDE_URL_VALUE, - &enabled) && - !enabled) { - CheckDlgButton(hwndDlg, IDC_INCLUDEURLCHECK, BST_UNCHECKED); - } else { - CheckDlgButton(hwndDlg, IDC_INCLUDEURLCHECK, BST_CHECKED); - } - - SetDlgItemText(hwndDlg, IDC_PROGRESSTEXT, - Str(ST_REPORTPRESUBMIT).c_str()); - - RECT closeRect; - HWND hwndClose = GetDlgItem(hwndDlg, IDC_CLOSEBUTTON); - GetRelativeRect(hwndClose, hwndDlg, &closeRect); - - RECT restartRect; - HWND hwndRestart = GetDlgItem(hwndDlg, IDC_RESTARTBUTTON); - GetRelativeRect(hwndRestart, hwndDlg, &restartRect); - - // set the close button text and shift the buttons around - // since the size may need to change - int sizeDiff = ResizeControl(hwndClose, closeRect, Str(ST_QUIT), true, 0); - restartRect.left -= sizeDiff; - restartRect.right -= sizeDiff; - SetDlgItemText(hwndDlg, IDC_CLOSEBUTTON, Str(ST_QUIT).c_str()); - - if (gRestartArgs.size() > 0) { - // Resize restart button to fit text - ResizeControl(hwndRestart, restartRect, Str(ST_RESTART), true, 0); - SetDlgItemText(hwndDlg, IDC_RESTARTBUTTON, Str(ST_RESTART).c_str()); - } else { - // No restart arguments, so just hide the restart button - SetDlgItemVisible(hwndDlg, IDC_RESTARTBUTTON, false); - } - // See if we need to widen the window - // Leave 6 pixels on either side + 6 pixels between the buttons - int neededSize = closeRect.right - closeRect.left + restartRect.right - - restartRect.left + 6 * 3; - GetClientRect(hwndDlg, &r); - // We may already have resized one of the checkboxes above - maxdiff = std::max(maxdiff, neededSize - (r.right - r.left)); - - if (maxdiff > 0) { - // widen window - GetWindowRect(hwndDlg, &r); - r.right += maxdiff; - MoveWindow(hwndDlg, r.left, r.top, r.right - r.left, r.bottom - r.top, - TRUE); - // shift both buttons right - if (restartRect.left + maxdiff < 6) maxdiff += 6; - closeRect.left += maxdiff; - closeRect.right += maxdiff; - restartRect.left += maxdiff; - restartRect.right += maxdiff; - MoveWindow(hwndClose, closeRect.left, closeRect.top, - closeRect.right - closeRect.left, - closeRect.bottom - closeRect.top, TRUE); - StretchControlsToFit(hwndDlg); - } - // need to move the restart button regardless - MoveWindow(hwndRestart, restartRect.left, restartRect.top, - restartRect.right - restartRect.left, - restartRect.bottom - restartRect.top, TRUE); - - // Resize the description text last, in case the window was resized - // before this. - SendDlgItemMessage(hwndDlg, IDC_DESCRIPTIONTEXT, EM_SETEVENTMASK, - (WPARAM) nullptr, ENM_REQUESTRESIZE); - - wstring description = Str(ST_CRASHREPORTERHEADER); - description += L"\n\n"; - description += Str(ST_CRASHREPORTERDESCRIPTION); - SetDlgItemText(hwndDlg, IDC_DESCRIPTIONTEXT, description.c_str()); - - // Make the title bold. - CHARFORMAT fmt = { - 0, - }; - fmt.cbSize = sizeof(fmt); - fmt.dwMask = CFM_BOLD; - fmt.dwEffects = CFE_BOLD; - SendDlgItemMessage(hwndDlg, IDC_DESCRIPTIONTEXT, EM_SETSEL, 0, - Str(ST_CRASHREPORTERHEADER).length()); - SendDlgItemMessage(hwndDlg, IDC_DESCRIPTIONTEXT, EM_SETCHARFORMAT, - SCF_SELECTION, (LPARAM)&fmt); - SendDlgItemMessage(hwndDlg, IDC_DESCRIPTIONTEXT, EM_SETSEL, 0, 0); - // Force redraw. - SendDlgItemMessage(hwndDlg, IDC_DESCRIPTIONTEXT, EM_SETTARGETDEVICE, - (WPARAM) nullptr, 0); - // Force resize. - SendDlgItemMessage(hwndDlg, IDC_DESCRIPTIONTEXT, EM_REQUESTRESIZE, 0, 0); - - // if no URL was given, hide the URL checkbox - if (!gQueryParameters.isMember("URL")) { - RECT urlCheckRect; - GetWindowRect(GetDlgItem(hwndDlg, IDC_INCLUDEURLCHECK), &urlCheckRect); - - SetDlgItemVisible(hwndDlg, IDC_INCLUDEURLCHECK, false); - - gAttachedBottom.erase(IDC_VIEWREPORTBUTTON); - gAttachedBottom.erase(IDC_SUBMITREPORTCHECK); - gAttachedBottom.erase(IDC_COMMENTTEXT); - - StretchDialog(hwndDlg, urlCheckRect.top - urlCheckRect.bottom); - - gAttachedBottom.insert(IDC_VIEWREPORTBUTTON); - gAttachedBottom.insert(IDC_SUBMITREPORTCHECK); - gAttachedBottom.insert(IDC_COMMENTTEXT); - } - - MaybeResizeProgressText(hwndDlg); - - // Open the AVI resource for the throbber - Animate_Open(GetDlgItem(hwndDlg, IDC_THROBBER), - MAKEINTRESOURCE(IDR_THROBBER)); - - UpdateURL(hwndDlg); - - SetFocus(GetDlgItem(hwndDlg, IDC_SUBMITREPORTCHECK)); - return FALSE; - } - case WM_SIZE: { - ReflowDialog(hwndDlg, HIWORD(lParam) - sHeight); - sHeight = HIWORD(lParam); - InvalidateRect(hwndDlg, nullptr, TRUE); - return FALSE; - } - case WM_NOTIFY: { - NMHDR* notification = reinterpret_cast<NMHDR*>(lParam); - if (notification->code == EN_REQUESTRESIZE) { - // Resizing the rich edit control to fit the description text. - REQRESIZE* reqresize = reinterpret_cast<REQRESIZE*>(lParam); - RECT newSize = reqresize->rc; - RECT oldSize; - GetRelativeRect(notification->hwndFrom, hwndDlg, &oldSize); - - // resize the text box as requested - MoveWindow(notification->hwndFrom, newSize.left, newSize.top, - newSize.right - newSize.left, newSize.bottom - newSize.top, - TRUE); - - // Resize the dialog to fit (the WM_SIZE handler will move the controls) - StretchDialog(hwndDlg, newSize.bottom - oldSize.bottom); - } - return FALSE; - } - case WM_COMMAND: { - if (HIWORD(wParam) == BN_CLICKED) { - switch (LOWORD(wParam)) { - case IDC_VIEWREPORTBUTTON: - DialogBoxParamMaybeRTL(IDD_VIEWREPORTDIALOG, hwndDlg, - (DLGPROC)ViewReportDialogProc, 0); - break; - case IDC_SUBMITREPORTCHECK: - SubmitReportChecked(hwndDlg); - break; - case IDC_INCLUDEURLCHECK: - UpdateURL(hwndDlg); - break; - case IDC_CLOSEBUTTON: - MaybeSendReport(hwndDlg); - break; - case IDC_RESTARTBUTTON: - RestartApplication(); - MaybeSendReport(hwndDlg); - break; - } - } else if (HIWORD(wParam) == EN_CHANGE) { - switch (LOWORD(wParam)) { - case IDC_COMMENTTEXT: - UpdateComment(hwndDlg); - } - } - - return FALSE; - } - case WM_UPLOADCOMPLETE: { - WaitForSingleObject(gThreadHandle, INFINITE); - success = (wParam == 1); - SendCompleted(success, WideToUTF8(gSendData.serverResponse)); - // hide throbber - Animate_Stop(GetDlgItem(hwndDlg, IDC_THROBBER)); - SetDlgItemVisible(hwndDlg, IDC_THROBBER, false); - - SetDlgItemText(hwndDlg, IDC_PROGRESSTEXT, - success ? Str(ST_REPORTSUBMITSUCCESS).c_str() - : Str(ST_SUBMITFAILED).c_str()); - MaybeResizeProgressText(hwndDlg); - // close dialog after 5 seconds - SetTimer(hwndDlg, 0, 5000, nullptr); - // - return TRUE; - } - - case WM_TIMER: { - // The "1" gets used down in UIShowCrashUI to indicate that we at least - // tried to send the report. - EndCrashReporterDialog(hwndDlg, 1); - return FALSE; - } - - case WM_CLOSE: { - EndCrashReporterDialog(hwndDlg, 0); - return FALSE; - } - } - return FALSE; -} - -static wstring UTF8ToWide(const string& utf8, bool* success) { - wchar_t* buffer = nullptr; - int buffer_size = - MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, nullptr, 0); - if (buffer_size == 0) { - if (success) *success = false; - return L""; - } - - buffer = new wchar_t[buffer_size]; - if (buffer == nullptr) { - if (success) *success = false; - return L""; - } - - MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, buffer, buffer_size); - wstring str = buffer; - delete[] buffer; - - if (success) *success = true; - - return str; -} - -static string WideToMBCP(const wstring& wide, unsigned int cp, - bool* success = nullptr) { - char* buffer = nullptr; - int buffer_size = WideCharToMultiByte(cp, 0, wide.c_str(), -1, nullptr, 0, - nullptr, nullptr); - if (buffer_size == 0) { - if (success) *success = false; - return ""; - } - - buffer = new char[buffer_size]; - if (buffer == nullptr) { - if (success) *success = false; - return ""; - } - - WideCharToMultiByte(cp, 0, wide.c_str(), -1, buffer, buffer_size, nullptr, - nullptr); - string mb = buffer; - delete[] buffer; - - if (success) *success = true; - - return mb; -} - -string WideToUTF8(const wstring& wide, bool* success) { - return WideToMBCP(wide, CP_UTF8, success); -} - -/* === Crashreporter UI Functions === */ - -bool UIInit() { - for (size_t i = 0; i < sizeof(kDefaultAttachedBottom) / sizeof(UINT); i++) { - gAttachedBottom.insert(kDefaultAttachedBottom[i]); - } - - DoInitCommonControls(); - - return true; -} - -void UIShutdown() {} - -void UIShowDefaultUI() { - MessageBox(nullptr, Str(ST_CRASHREPORTERDEFAULT).c_str(), L"Crash Reporter", - MB_OK | MB_ICONSTOP); -} - -bool UIShowCrashUI(const StringTable& files, const Json::Value& queryParameters, - const string& sendURL, const vector<string>& restartArgs) { - gSendData.hDlg = nullptr; - gSendData.sendURL = UTF8ToWide(sendURL); - - for (StringTable::const_iterator i = files.begin(); i != files.end(); i++) { - gSendData.files[UTF8ToWide(i->first)] = UTF8ToWide(i->second); - } - - gQueryParameters = queryParameters; - - if (gQueryParameters.isMember("Vendor")) { - gCrashReporterKey = L"Software\\"; - string vendor = gQueryParameters["Vendor"].asString(); - if (!vendor.empty()) { - gCrashReporterKey += UTF8ToWide(vendor) + L"\\"; - } - string productName = gQueryParameters["ProductName"].asString(); - gCrashReporterKey += UTF8ToWide(productName) + L"\\Crash Reporter"; - } - - if (gQueryParameters.isMember("URL")) { - gURLParameter = gQueryParameters["URL"].asString(); - } - - gRestartArgs = restartArgs; - - if (gStrings.find("isRTL") != gStrings.end() && gStrings["isRTL"] == "yes") - gRTLlayout = true; - - if (gAutoSubmit) { - gSendData.queryParameters = gQueryParameters; - - gThreadHandle = - CreateThread(nullptr, 0, SendThreadProc, &gSendData, 0, nullptr); - WaitForSingleObject(gThreadHandle, INFINITE); - // SendCompleted was called from SendThreadProc - return true; - } - - return 1 == DialogBoxParamMaybeRTL(IDD_SENDDIALOG, nullptr, - (DLGPROC)CrashReporterDialogProc, 0); -} - -void UIError_impl(const string& message) { - wstring title = Str(ST_CRASHREPORTERTITLE); - if (title.empty()) title = L"Crash Reporter Error"; - - MessageBox(nullptr, UTF8ToWide(message).c_str(), title.c_str(), - MB_OK | MB_ICONSTOP); -} - -bool UIGetIniPath(string& path) { - wchar_t fileName[MAX_PATH]; - if (GetModuleFileName(nullptr, fileName, MAX_PATH)) { - // get crashreporter ini - wchar_t* s = wcsrchr(fileName, '.'); - if (s) { - wcscpy(s, L".ini"); - path = WideToUTF8(fileName); - return true; - } - } - - return false; -} - -bool UIGetSettingsPath(const string& vendor, const string& product, - string& settings_path) { - wchar_t path[MAX_PATH] = {}; - HRESULT hRes = SHGetFolderPath(nullptr, CSIDL_APPDATA, nullptr, 0, path); - if (FAILED(hRes)) { - // This provides a fallback for getting the path to APPDATA by querying the - // registry when the call to SHGetFolderPath is unable to provide this path - // (Bug 513958). - HKEY key; - DWORD type, dwRes; - DWORD size = sizeof(path) - 1; - dwRes = ::RegOpenKeyExW(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Exp" - L"lorer\\Shell Folders", - 0, KEY_READ, &key); - if (dwRes != ERROR_SUCCESS) return false; - - dwRes = - RegQueryValueExW(key, L"AppData", nullptr, &type, (LPBYTE)&path, &size); - ::RegCloseKey(key); - // The call to RegQueryValueExW must succeed, the type must be REG_SZ, the - // buffer size must not equal 0, and the buffer size be a multiple of 2. - if (dwRes != ERROR_SUCCESS || type != REG_SZ || size == 0 || size % 2 != 0) - return false; - } - - if (!vendor.empty()) { - PathAppend(path, UTF8ToWide(vendor).c_str()); - } - PathAppend(path, UTF8ToWide(product).c_str()); - PathAppend(path, L"Crash Reports"); - settings_path = WideToUTF8(path); - return true; -} - -bool UIEnsurePathExists(const string& path) { - if (CreateDirectory(UTF8ToWide(path).c_str(), nullptr) == 0) { - if (GetLastError() != ERROR_ALREADY_EXISTS) return false; - } - - return true; -} - -bool UIFileExists(const string& path) { - DWORD attrs = GetFileAttributes(UTF8ToWide(path).c_str()); - return (attrs != INVALID_FILE_ATTRIBUTES); -} - -bool UIMoveFile(const string& oldfile, const string& newfile) { - if (oldfile == newfile) return true; - - return MoveFile(UTF8ToWide(oldfile).c_str(), UTF8ToWide(newfile).c_str()) == - TRUE; -} - -bool UIDeleteFile(const string& oldfile) { - return DeleteFile(UTF8ToWide(oldfile).c_str()) == TRUE; -} - -ifstream* UIOpenRead(const string& filename, ios_base::openmode mode) { -#if defined(_MSC_VER) - ifstream* file = new ifstream(); - file->open(UTF8ToWide(filename).c_str(), mode); -#else // GCC - ifstream* file = - new ifstream(WideToMBCP(UTF8ToWide(filename), CP_ACP).c_str(), mode); -#endif // _MSC_VER - - return file; -} - -ofstream* UIOpenWrite(const string& filename, ios_base::openmode mode) { -#if defined(_MSC_VER) - ofstream* file = new ofstream(); - file->open(UTF8ToWide(filename).c_str(), mode); -#else // GCC - ofstream* file = - new ofstream(WideToMBCP(UTF8ToWide(filename), CP_ACP).c_str(), mode); -#endif // _MSC_VER - - return file; -} - -struct FileData { - FILETIME timestamp; - wstring path; -}; - -static bool CompareFDTime(const FileData& fd1, const FileData& fd2) { - return CompareFileTime(&fd1.timestamp, &fd2.timestamp) > 0; -} - -void UIPruneSavedDumps(const std::string& directory) { - wstring wdirectory = UTF8ToWide(directory); - - WIN32_FIND_DATA fdata; - wstring findpath = wdirectory + L"\\*.dmp"; - HANDLE dirlist = FindFirstFile(findpath.c_str(), &fdata); - if (dirlist == INVALID_HANDLE_VALUE) return; - - vector<FileData> dumpfiles; - - for (BOOL ok = true; ok; ok = FindNextFile(dirlist, &fdata)) { - FileData fd = {fdata.ftLastWriteTime, wdirectory + L"\\" + fdata.cFileName}; - dumpfiles.push_back(fd); - } - - sort(dumpfiles.begin(), dumpfiles.end(), CompareFDTime); - - while (dumpfiles.size() > kSaveCount) { - // get the path of the oldest file - wstring path = (--dumpfiles.end())->path; - DeleteFile(path.c_str()); - - // s/.dmp/.extra/ - path.replace(path.size() - 4, 4, L".extra"); - DeleteFile(path.c_str()); - - dumpfiles.pop_back(); - } - FindClose(dirlist); -} - -bool UIRunProgram(const string& exename, const std::vector<std::string>& args, - bool wait) { - wstring cmdLine = L"\"" + UTF8ToWide(exename) + L"\" "; - - for (auto arg : args) { - cmdLine += L"\"" + UTF8ToWide(arg) + L"\" "; - } - - STARTUPINFO si = {}; - si.cb = sizeof(si); - PROCESS_INFORMATION pi = {}; - - if (!CreateProcess(/* lpApplicationName */ nullptr, (LPWSTR)cmdLine.c_str(), - /* lpProcessAttributes */ nullptr, - /* lpThreadAttributes */ nullptr, - /* bInheritHandles */ false, - NORMAL_PRIORITY_CLASS | CREATE_NO_WINDOW, - /* lpEnvironment */ nullptr, - /* lpCurrentDirectory */ nullptr, &si, &pi)) { - return false; - } - - if (wait) { - WaitForSingleObject(pi.hProcess, INFINITE); - } - - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - return true; -} - -string UIGetEnv(const string& name) { - const wchar_t* var = _wgetenv(UTF8ToWide(name).c_str()); - if (var && *var) { - return WideToUTF8(var); - } - - return ""; -} diff --git a/toolkit/crashreporter/client/gtkbind/Cargo.toml b/toolkit/crashreporter/client/gtkbind/Cargo.toml new file mode 100644 index 0000000000..813d43dbe3 --- /dev/null +++ b/toolkit/crashreporter/client/gtkbind/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "gtkbind" +version = "0.1.0" +edition = "2021" + +[build-dependencies] +bindgen = { version = "0.69.0", default-features = false, features = ["runtime"] } +mozbuild = "0.1.0" diff --git a/toolkit/crashreporter/client/gtkbind/build.rs b/toolkit/crashreporter/client/gtkbind/build.rs new file mode 100644 index 0000000000..fa3402fdf2 --- /dev/null +++ b/toolkit/crashreporter/client/gtkbind/build.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 https://mozilla.org/MPL/2.0/. */ + +use mozbuild::config::{ + CC_BASE_FLAGS as CFLAGS, MOZ_GTK3_CFLAGS as GTK_CFLAGS, MOZ_GTK3_LIBS as GTK_LIBS, +}; + +const HEADER: &str = r#" +#include "gtk/gtk.h" +#include "pango/pango.h" +#include "gdk-pixbuf/gdk-pixbuf.h" +"#; + +fn main() { + let bindings = bindgen::Builder::default() + .header_contents("gtk_bindings.h", HEADER) + .clang_args(CFLAGS) + .clang_args(GTK_CFLAGS) + .allowlist_function("gtk_.*") + .allowlist_function( + "g_(application|main_context|memory_input_stream|object|signal|timeout)_.*", + ) + .allowlist_function("gdk_pixbuf_new_from_stream") + .allowlist_function("pango_attr_.*") + // The gtk/glib valist functions generate FFI-unsafe signatures on aarch64 which cause + // compile errors. We don't use them anyway. + .blocklist_function(".*_valist") + .derive_default(true) + .generate() + .expect("unable to generate gtk bindings"); + for flag in GTK_LIBS { + if let Some(lib) = flag.strip_prefix("-l") { + println!("cargo:rustc-link-lib={lib}"); + } else if let Some(path) = flag.strip_prefix("-L") { + println!("cargo:rustc-link-search={path}"); + } + } + let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); + bindings + .write_to_file(out_path.join("gtk_bindings.rs")) + .expect("failed to write gtk bindings"); +} diff --git a/toolkit/crashreporter/client/gtkbind/src/lib.rs b/toolkit/crashreporter/client/gtkbind/src/lib.rs new file mode 100644 index 0000000000..714f3ed047 --- /dev/null +++ b/toolkit/crashreporter/client/gtkbind/src/lib.rs @@ -0,0 +1,9 @@ +/* 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/. */ + +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] + +include!(concat!(env!("OUT_DIR"), "/gtk_bindings.rs")); diff --git a/toolkit/crashreporter/client/macbuild/Contents/Info.plist b/toolkit/crashreporter/client/macbuild/Contents/Info.plist deleted file mode 100644 index 51d6c4de37..0000000000 --- a/toolkit/crashreporter/client/macbuild/Contents/Info.plist +++ /dev/null @@ -1,36 +0,0 @@ -<?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>NSMainNibFile</key> - <string>MainMenu</string> - <key>NSRequiresAquaSystemAppearance</key> - <false/> - <key>NSPrincipalClass</key> - <string>NSApplication</string> - <key>LSUIElement</key> - <true/> -</dict> -</plist> diff --git a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenu.nib/classes.nib b/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenu.nib/classes.nib deleted file mode 100644 index 254131e431..0000000000 --- a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenu.nib/classes.nib +++ /dev/null @@ -1,100 +0,0 @@ -<?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>IBClasses</key> - <array> - <dict> - <key>ACTIONS</key> - <dict> - <key>closeClicked</key> - <string>id</string> - <key>includeURLClicked</key> - <string>id</string> - <key>restartClicked</key> - <string>id</string> - <key>submitReportClicked</key> - <string>id</string> - <key>viewReportClicked</key> - <string>id</string> - <key>viewReportOkClicked</key> - <string>id</string> - </dict> - <key>CLASS</key> - <string>CrashReporterUI</string> - <key>LANGUAGE</key> - <string>ObjC</string> - <key>OUTLETS</key> - <dict> - <key>mCloseButton</key> - <string>NSButton</string> - <key>mCommentScrollView</key> - <string>NSScrollView</string> - <key>mCommentText</key> - <string>TextViewWithPlaceHolder</string> - <key>mDescriptionLabel</key> - <string>NSTextField</string> - <key>mEmailMeButton</key> - <string>NSButton</string> - <key>mEmailText</key> - <string>NSTextField</string> - <key>mErrorCloseButton</key> - <string>NSButton</string> - <key>mErrorHeaderLabel</key> - <string>NSTextField</string> - <key>mErrorLabel</key> - <string>NSTextField</string> - <key>mErrorView</key> - <string>NSView</string> - <key>mHeaderLabel</key> - <string>NSTextField</string> - <key>mIncludeURLButton</key> - <string>NSButton</string> - <key>mProgressIndicator</key> - <string>NSProgressIndicator</string> - <key>mProgressText</key> - <string>NSTextField</string> - <key>mRestartButton</key> - <string>NSButton</string> - <key>mSubmitReportButton</key> - <string>NSButton</string> - <key>mViewReportButton</key> - <string>NSButton</string> - <key>mViewReportOkButton</key> - <string>NSButton</string> - <key>mViewReportTextView</key> - <string>NSTextView</string> - <key>mViewReportWindow</key> - <string>NSWindow</string> - <key>mWindow</key> - <string>NSWindow</string> - </dict> - <key>SUPERCLASS</key> - <string>NSObject</string> - </dict> - <dict> - <key>ACTIONS</key> - <dict> - <key>insertTab</key> - <string>id</string> - </dict> - <key>CLASS</key> - <string>TextViewWithPlaceHolder</string> - <key>LANGUAGE</key> - <string>ObjC</string> - <key>SUPERCLASS</key> - <string>NSTextView</string> - </dict> - <dict> - <key>CLASS</key> - <string>FirstResponder</string> - <key>LANGUAGE</key> - <string>ObjC</string> - <key>SUPERCLASS</key> - <string>NSObject</string> - </dict> - </array> - <key>IBVersion</key> - <string>1</string> -</dict> -</plist> diff --git a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenu.nib/info.nib b/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenu.nib/info.nib deleted file mode 100644 index 517349ffce..0000000000 --- a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenu.nib/info.nib +++ /dev/null @@ -1,18 +0,0 @@ -<?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>IBFramework Version</key> - <string>629</string> - <key>IBOldestOS</key> - <integer>5</integer> - <key>IBOpenObjects</key> - <array> - <integer>2</integer> - </array> - <key>IBSystem Version</key> - <string>9C7010</string> - <key>targetFramework</key> - <string>IBCocoaFramework</string> -</dict> -</plist> diff --git a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenu.nib/keyedobjects.nib b/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenu.nib/keyedobjects.nib Binary files differdeleted file mode 100644 index bfdcccb74c..0000000000 --- a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenu.nib/keyedobjects.nib +++ /dev/null diff --git a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenuRTL.nib/classes.nib b/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenuRTL.nib/classes.nib deleted file mode 100644 index 254131e431..0000000000 --- a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenuRTL.nib/classes.nib +++ /dev/null @@ -1,100 +0,0 @@ -<?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>IBClasses</key> - <array> - <dict> - <key>ACTIONS</key> - <dict> - <key>closeClicked</key> - <string>id</string> - <key>includeURLClicked</key> - <string>id</string> - <key>restartClicked</key> - <string>id</string> - <key>submitReportClicked</key> - <string>id</string> - <key>viewReportClicked</key> - <string>id</string> - <key>viewReportOkClicked</key> - <string>id</string> - </dict> - <key>CLASS</key> - <string>CrashReporterUI</string> - <key>LANGUAGE</key> - <string>ObjC</string> - <key>OUTLETS</key> - <dict> - <key>mCloseButton</key> - <string>NSButton</string> - <key>mCommentScrollView</key> - <string>NSScrollView</string> - <key>mCommentText</key> - <string>TextViewWithPlaceHolder</string> - <key>mDescriptionLabel</key> - <string>NSTextField</string> - <key>mEmailMeButton</key> - <string>NSButton</string> - <key>mEmailText</key> - <string>NSTextField</string> - <key>mErrorCloseButton</key> - <string>NSButton</string> - <key>mErrorHeaderLabel</key> - <string>NSTextField</string> - <key>mErrorLabel</key> - <string>NSTextField</string> - <key>mErrorView</key> - <string>NSView</string> - <key>mHeaderLabel</key> - <string>NSTextField</string> - <key>mIncludeURLButton</key> - <string>NSButton</string> - <key>mProgressIndicator</key> - <string>NSProgressIndicator</string> - <key>mProgressText</key> - <string>NSTextField</string> - <key>mRestartButton</key> - <string>NSButton</string> - <key>mSubmitReportButton</key> - <string>NSButton</string> - <key>mViewReportButton</key> - <string>NSButton</string> - <key>mViewReportOkButton</key> - <string>NSButton</string> - <key>mViewReportTextView</key> - <string>NSTextView</string> - <key>mViewReportWindow</key> - <string>NSWindow</string> - <key>mWindow</key> - <string>NSWindow</string> - </dict> - <key>SUPERCLASS</key> - <string>NSObject</string> - </dict> - <dict> - <key>ACTIONS</key> - <dict> - <key>insertTab</key> - <string>id</string> - </dict> - <key>CLASS</key> - <string>TextViewWithPlaceHolder</string> - <key>LANGUAGE</key> - <string>ObjC</string> - <key>SUPERCLASS</key> - <string>NSTextView</string> - </dict> - <dict> - <key>CLASS</key> - <string>FirstResponder</string> - <key>LANGUAGE</key> - <string>ObjC</string> - <key>SUPERCLASS</key> - <string>NSObject</string> - </dict> - </array> - <key>IBVersion</key> - <string>1</string> -</dict> -</plist> diff --git a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenuRTL.nib/info.nib b/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenuRTL.nib/info.nib deleted file mode 100644 index 4a2251aaf5..0000000000 --- a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenuRTL.nib/info.nib +++ /dev/null @@ -1,18 +0,0 @@ -<?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>IBFramework Version</key> - <string>629</string> - <key>IBOldestOS</key> - <integer>5</integer> - <key>IBOpenObjects</key> - <array> - <integer>2</integer> - </array> - <key>IBSystem Version</key> - <string>9D34</string> - <key>targetFramework</key> - <string>IBCocoaFramework</string> -</dict> -</plist> diff --git a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenuRTL.nib/keyedobjects.nib b/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenuRTL.nib/keyedobjects.nib Binary files differdeleted file mode 100644 index 6c93849b94..0000000000 --- a/toolkit/crashreporter/client/macbuild/Contents/Resources/English.lproj/MainMenuRTL.nib/keyedobjects.nib +++ /dev/null diff --git a/toolkit/crashreporter/client/moz.build b/toolkit/crashreporter/client/moz.build deleted file mode 100644 index 82e19b8637..0000000000 --- a/toolkit/crashreporter/client/moz.build +++ /dev/null @@ -1,97 +0,0 @@ -# -*- 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/. - -if CONFIG["OS_TARGET"] != "Android": - Program("crashreporter") - - UNIFIED_SOURCES += [ - "../CrashAnnotations.cpp", - "crashreporter.cpp", - "ping.cpp", - ] - - LOCAL_INCLUDES += [ - "/toolkit/components/jsoncpp/include", - ] - - USE_LIBS += [ - "jsoncpp", - ] - -if CONFIG["OS_ARCH"] == "WINNT": - UNIFIED_SOURCES += [ - "crashreporter_win.cpp", - ] - include("/toolkit/crashreporter/breakpad-client/windows/sender/objs.mozbuild") - SOURCES += objs_sender - SOURCES += [ - "../google-breakpad/src/common/windows/http_upload.cc", - ] - DEFINES["UNICODE"] = True - DEFINES["_UNICODE"] = True - USE_LIBS += [ - "nss", - ] - OS_LIBS += [ - "advapi32", - "comctl32", - "gdi32", - "ole32", - "shell32", - "wininet", - "shlwapi", - "user32", - ] -elif CONFIG["OS_ARCH"] == "Darwin": - UNIFIED_SOURCES += [ - "../google-breakpad/src/common/mac/HTTPMultipartUpload.m", - "crashreporter_osx.mm", - "crashreporter_unix_common.cpp", - ] - LOCAL_INCLUDES += [ - "../google-breakpad/src/common/mac", - ] - OS_LIBS += ["-framework Cocoa"] - USE_LIBS += [ - "nss", - ] - LDFLAGS += ["-Wl,-rpath,@executable_path/../../../"] -elif CONFIG["OS_ARCH"] == "SunOS": - SOURCES += [ - "crashreporter_linux.cpp", - "crashreporter_unix.cpp", - ] - USE_LIBS += [ - "breakpad_solaris_common_s", - ] - -if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk": - UNIFIED_SOURCES += [ - "../google-breakpad/src/common/linux/http_upload.cc", - "crashreporter_gtk_common.cpp", - "crashreporter_linux.cpp", - "crashreporter_unix_common.cpp", - ] - OS_LIBS += CONFIG["MOZ_GTK3_LIBS"] - OS_LIBS += CONFIG["MOZ_GTHREAD_LIBS"] - CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"] - CXXFLAGS += CONFIG["MOZ_GTHREAD_CFLAGS"] - -if CONFIG["OS_ARCH"] == "Linux" or CONFIG["OS_ARCH"] == "SunOS": - FINAL_TARGET_FILES += [ - "Throbber-small.gif", - ] - -DEFINES["MOZ_APP_NAME"] = '"%s"' % CONFIG["MOZ_APP_NAME"] -DEFINES["BIN_SUFFIX"] = '"%s"' % CONFIG["BIN_SUFFIX"] - -RCINCLUDE = "crashreporter.rc" - -# Don't use the STL wrappers in the crashreporter clients; they don't -# link with -lmozalloc, and it really doesn't matter here anyway. -DisableStlWrapping() - -include("/toolkit/crashreporter/crashreporter.mozbuild") diff --git a/toolkit/crashreporter/client/ping.cpp b/toolkit/crashreporter/client/ping.cpp deleted file mode 100644 index b49211c9c1..0000000000 --- a/toolkit/crashreporter/client/ping.cpp +++ /dev/null @@ -1,324 +0,0 @@ -/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ -/* 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/. */ - -#include "crashreporter.h" - -#include <cstring> -#include <ctime> -#include <string> - -#if defined(XP_LINUX) -# include <fcntl.h> -# include <unistd.h> -# include <sys/stat.h> -#elif defined(XP_MACOSX) -# include <CoreFoundation/CoreFoundation.h> -#elif defined(XP_WIN) -# include <objbase.h> -#endif - -#include "json/json.h" - -#include "CrashAnnotations.h" - -using std::string; - -namespace CrashReporter { - -struct UUID { - uint32_t m0; - uint16_t m1; - uint16_t m2; - uint8_t m3[8]; -}; - -// Generates an UUID; the code here is mostly copied from nsUUIDGenerator.cpp -static string GenerateUUID() { - UUID id = {}; - -#if defined(XP_WIN) // Windows - HRESULT hr = CoCreateGuid((GUID*)&id); - if (FAILED(hr)) { - return ""; - } -#elif defined(XP_MACOSX) // MacOS X - CFUUIDRef uuid = CFUUIDCreate(kCFAllocatorDefault); - if (!uuid) { - return ""; - } - - CFUUIDBytes bytes = CFUUIDGetUUIDBytes(uuid); - memcpy(&id, &bytes, sizeof(UUID)); - - CFRelease(uuid); -#elif defined(HAVE_ARC4RANDOM_BUF) // Android, BSD, ... - arc4random_buf(&id, sizeof(UUID)); -#else // Linux - int fd = open("/dev/urandom", O_RDONLY); - - if (fd == -1) { - return ""; - } - - if (read(fd, &id, sizeof(UUID)) != sizeof(UUID)) { - close(fd); - return ""; - } - - close(fd); -#endif - - /* Put in the version */ - id.m2 &= 0x0fff; - id.m2 |= 0x4000; - - /* Put in the variant */ - id.m3[0] &= 0x3f; - id.m3[0] |= 0x80; - - const char* kUUIDFormatString = - "%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x"; - const size_t kUUIDFormatStringLength = 36; - char str[kUUIDFormatStringLength + 1] = {'\0'}; - - int num = snprintf(str, kUUIDFormatStringLength + 1, kUUIDFormatString, id.m0, - id.m1, id.m2, id.m3[0], id.m3[1], id.m3[2], id.m3[3], - id.m3[4], id.m3[5], id.m3[6], id.m3[7]); - - if (num != kUUIDFormatStringLength) { - return ""; - } - - return str; -} - -const char kISO8601Date[] = "%F"; -const char kISO8601DateHours[] = "%FT%H:00:00.000Z"; - -// Return the current date as a string in the specified format, the following -// constants are provided: -// - kISO8601Date, the ISO 8601 date format, YYYY-MM-DD -// - kISO8601DateHours, the ISO 8601 full date format, YYYY-MM-DDTHH:00:00.000Z -static string CurrentDate(string format) { - time_t now; - time(&now); - char buf[64]; // This should be plenty - strftime(buf, sizeof buf, format.c_str(), gmtime(&now)); - return buf; -} - -const char kTelemetryClientId[] = "TelemetryClientId"; -const char kTelemetryUrl[] = "TelemetryServerURL"; -const char kTelemetrySessionId[] = "TelemetrySessionId"; -const int kTelemetryVersion = 4; - -// Create the payload.metadata node of the crash ping using fields extracted -// from the .extra file -static Json::Value CreateMetadataNode(const Json::Value& aExtra) { - Json::Value node; - - for (Json::ValueConstIterator iter = aExtra.begin(); iter != aExtra.end(); - ++iter) { - Annotation annotation; - - if (AnnotationFromString(annotation, iter.memberName())) { - if (IsAnnotationAllowedForPing(annotation)) { - node[iter.memberName()] = *iter; - } - } - } - - return node; -} - -// Create the payload node of the crash ping -static Json::Value CreatePayloadNode(const Json::Value& aExtra, - const string& aHash, - const string& aSessionId) { - Json::Value payload; - - payload["sessionId"] = aSessionId; - payload["version"] = 1; - payload["crashDate"] = CurrentDate(kISO8601Date); - payload["crashTime"] = CurrentDate(kISO8601DateHours); - payload["hasCrashEnvironment"] = true; - payload["crashId"] = CrashReporter::GetDumpLocalID(); - payload["minidumpSha256Hash"] = aHash; - payload["processType"] = "main"; // This is always a main crash - if (aExtra.isMember("StackTraces")) { - payload["stackTraces"] = aExtra["StackTraces"]; - } - - // Assemble the payload metadata - payload["metadata"] = CreateMetadataNode(aExtra); - - return payload; -} - -// Create the application node of the crash ping -static Json::Value CreateApplicationNode( - const string& aVendor, const string& aName, const string& aVersion, - const string& aDisplayVersion, const string& aPlatformVersion, - const string& aChannel, const string& aBuildId, const string& aArchitecture, - const string& aXpcomAbi) { - Json::Value application; - - application["vendor"] = aVendor; - application["name"] = aName; - application["buildId"] = aBuildId; - application["displayVersion"] = aDisplayVersion; - application["platformVersion"] = aPlatformVersion; - application["version"] = aVersion; - application["channel"] = aChannel; - if (!aArchitecture.empty()) { - application["architecture"] = aArchitecture; - } - if (!aXpcomAbi.empty()) { - application["xpcomAbi"] = aXpcomAbi; - } - - return application; -} - -// Create the root node of the crash ping -static Json::Value CreateRootNode( - const Json::Value& aExtra, const string& aUuid, const string& aHash, - const string& aClientId, const string& aSessionId, const string& aName, - const string& aVersion, const string& aChannel, const string& aBuildId) { - Json::Value root; - root["type"] = "crash"; // This is a crash ping - root["id"] = aUuid; - root["version"] = kTelemetryVersion; - root["creationDate"] = CurrentDate(kISO8601DateHours); - root["clientId"] = aClientId; - - // Parse the telemetry environment - Json::Value environment; - Json::Reader reader; - string architecture; - string xpcomAbi; - string displayVersion; - string platformVersion; - - if (reader.parse(aExtra["TelemetryEnvironment"].asString(), environment, - /* collectComments */ false)) { - if (environment.isMember("build") && environment["build"].isObject()) { - Json::Value build = environment["build"]; - if (build.isMember("architecture") && build["architecture"].isString()) { - architecture = build["architecture"].asString(); - } - if (build.isMember("xpcomAbi") && build["xpcomAbi"].isString()) { - xpcomAbi = build["xpcomAbi"].asString(); - } - if (build.isMember("displayVersion") && - build["displayVersion"].isString()) { - displayVersion = build["displayVersion"].asString(); - } - if (build.isMember("platformVersion") && - build["platformVersion"].isString()) { - platformVersion = build["platformVersion"].asString(); - } - } - - root["environment"] = environment; - } - - root["payload"] = CreatePayloadNode(aExtra, aHash, aSessionId); - root["application"] = CreateApplicationNode( - aExtra["Vendor"].asString(), aName, aVersion, displayVersion, - platformVersion, aChannel, aBuildId, architecture, xpcomAbi); - - return root; -} - -// Generates the URL used to submit the crash ping, see TelemetrySend.sys.mjs -string GenerateSubmissionUrl(const string& aUrl, const string& aId, - const string& aName, const string& aVersion, - const string& aChannel, const string& aBuildId) { - return aUrl + "/submit/telemetry/" + aId + "/crash/" + aName + "/" + - aVersion + "/" + aChannel + "/" + aBuildId + - "?v=" + std::to_string(kTelemetryVersion); -} - -// Write out the ping into the specified file. -// -// Returns true if the ping was written out successfully, false otherwise. -static bool WritePing(const string& aPath, const string& aPing) { - std::ofstream* f = UIOpenWrite(aPath, std::ios::trunc); - bool success = false; - - if (f->is_open()) { - *f << aPing; - f->close(); - success = f->good(); - } - - delete f; - return success; -} - -// Assembles the crash ping using the JSON data extracted from the .extra file -// and sends it using the crash sender. All the telemetry specific data but the -// environment will be stripped from the annotations so that it won't be sent -// together with the crash report. -// -// Note that the crash ping sender is invoked in a fire-and-forget way so this -// won't block waiting for the ping to be delivered. -// -// Returns true if the ping was assembled and handed over to the pingsender -// correctly, also populates the aPingUuid parameter with the ping UUID. Returns -// false otherwise and leaves the aPingUuid parameter unmodified. -bool SendCrashPing(Json::Value& aExtra, const string& aHash, string& aPingUuid, - const string& pingDir) { - // Remove the telemetry-related data from the crash annotations - Json::Value value; - aExtra.removeMember(kTelemetryClientId, &value); - string clientId = value.asString(); - aExtra.removeMember(kTelemetryUrl, &value); - string serverUrl = value.asString(); - aExtra.removeMember(kTelemetrySessionId, &value); - string sessionId = value.asString(); - - if (clientId.empty() || serverUrl.empty() || sessionId.empty()) { - return false; - } - - string buildId = aExtra["BuildID"].asString(); - string channel = aExtra["ReleaseChannel"].asString(); - string name = aExtra["ProductName"].asString(); - string version = aExtra["Version"].asString(); - string uuid = GenerateUUID(); - string url = - GenerateSubmissionUrl(serverUrl, uuid, name, version, channel, buildId); - - if (serverUrl.empty() || uuid.empty()) { - return false; - } - - Json::Value root = CreateRootNode(aExtra, uuid, aHash, clientId, sessionId, - name, version, channel, buildId); - - // Write out the result to the pending pings directory - Json::StreamWriterBuilder builder; - builder["indentation"] = ""; - string ping = Json::writeString(builder, root); - string pingPath = pingDir + UI_DIR_SEPARATOR + uuid + ".json"; - - if (!WritePing(pingPath, ping)) { - return false; - } - - // Hand over the ping to the sender - std::vector<string> args = {url, pingPath}; - if (UIRunProgram(CrashReporter::GetProgramPath(UI_PING_SENDER_FILENAME), - args)) { - aPingUuid = uuid; - return true; - } else { - return false; - } -} - -} // namespace CrashReporter diff --git a/toolkit/crashreporter/client/resource.h b/toolkit/crashreporter/client/resource.h deleted file mode 100644 index 2e7917daa4..0000000000 --- a/toolkit/crashreporter/client/resource.h +++ /dev/null @@ -1,35 +0,0 @@ -/* 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/. */ - -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by crashreporter.rc -// -#define IDD_SENDDIALOG 102 -#define IDR_THROBBER 103 -#define IDD_VIEWREPORTDIALOG 104 -#define IDI_MAINICON 105 -#define IDC_PROGRESS 1003 -#define IDC_DESCRIPTIONTEXT 1004 -#define IDC_CLOSEBUTTON 1005 -#define IDC_VIEWREPORTBUTTON 1006 -#define IDC_SUBMITREPORTCHECK 1007 -#define IDC_INCLUDEURLCHECK 1010 -#define IDC_COMMENTTEXT 1011 -#define IDC_RESTARTBUTTON 1012 -#define IDC_DESCRIPTIONLABEL 1013 -#define IDC_PROGRESSTEXT 1014 -#define IDC_THROBBER 1015 -#define IDC_VIEWREPORTTEXT 1016 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -# ifndef APSTUDIO_READONLY_SYMBOLS -# define _APS_NEXT_RESOURCE_VALUE 106 -# define _APS_NEXT_COMMAND_VALUE 40001 -# define _APS_NEXT_CONTROL_VALUE 1017 -# define _APS_NEXT_SYMED_VALUE 101 -# endif -#endif |