pub const MAX_STARTUP_TITLE_BYTES: usize = 120;
pub const MAX_STARTUP_SUMMARY_BYTES: usize = 500;
pub const MAX_STARTUP_HELP_BYTES: usize = 500;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartupFailure {
title: String,
summary: String,
help: String,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum StartupFailureError {
#[error("{field} is empty; help: provide a short user-facing {field}")]
Empty {
field: &'static str,
},
#[error("{field} exceeds {max_bytes} UTF-8 bytes; help: shorten the {field}")]
TooLong {
field: &'static str,
max_bytes: usize,
},
#[error("{field} contains a control or bidirectional formatting character; help: use plain single-line text")]
UnsafeCharacter {
field: &'static str,
},
}
impl StartupFailure {
pub fn new(
title: impl AsRef<str>,
summary: impl AsRef<str>,
help: impl AsRef<str>,
) -> Result<Self, StartupFailureError> {
let title = title.as_ref();
let summary = summary.as_ref();
let help = help.as_ref();
check_text(title, "title", MAX_STARTUP_TITLE_BYTES)?;
check_text(summary, "summary", MAX_STARTUP_SUMMARY_BYTES)?;
check_text(help, "help", MAX_STARTUP_HELP_BYTES)?;
Ok(Self {
title: title.to_owned(),
summary: summary.to_owned(),
help: help.to_owned(),
})
}
fn detail(&self) -> String {
let mut detail = String::with_capacity(self.summary.len() + self.help.len() + 2);
detail.push_str(&self.summary);
detail.push_str("\n\n");
detail.push_str(&self.help);
detail
}
}
#[cold]
#[inline(never)]
fn check_text(
text: &str,
field: &'static str,
max_bytes: usize,
) -> Result<(), StartupFailureError> {
if text.len() > max_bytes {
return Err(StartupFailureError::TooLong { field, max_bytes });
}
if text.trim().is_empty() {
return Err(StartupFailureError::Empty { field });
}
if text.chars().any(|c| {
c.is_control()
|| matches!(
c,
'\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}'
| '\u{2066}'..='\u{2069}'
)
}) {
return Err(StartupFailureError::UnsafeCharacter { field });
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum StartupPresentationError {
#[error("startup alert is unsupported on this platform; help: record and report the startup error through your host")]
Unsupported,
#[error("startup alert must run on the macOS main thread; help: present before starting background server work")]
NotMainThread,
#[error("startup alert has no available GUI session; help: retain the host's durable log and startup error")]
Unavailable,
#[error("startup alert was dismissed without acknowledgement; help: retain the host's durable log and startup error")]
NotAcknowledged,
}
#[must_use = "handle native presentation failure separately from the original startup error"]
pub fn present_startup_failure(failure: &StartupFailure) -> Result<(), StartupPresentationError> {
#[cfg(target_os = "macos")]
{
macos::present(failure)
}
#[cfg(windows)]
{
windows::present(failure)
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = failure;
Err(StartupPresentationError::Unsupported)
}
}
#[cfg(windows)]
#[allow(unsafe_code)]
mod windows {
use windows::core::HSTRING;
use windows::Win32::UI::WindowsAndMessaging::{
MessageBoxW, IDOK, MB_ICONERROR, MB_OK, MB_SETFOREGROUND,
};
use super::{StartupFailure, StartupPresentationError};
pub(super) fn present(failure: &StartupFailure) -> Result<(), StartupPresentationError> {
let title = HSTRING::from(failure.title.as_str());
let detail = HSTRING::from(failure.detail());
let response = unsafe {
MessageBoxW(
None,
&detail,
&title,
MB_OK | MB_ICONERROR | MB_SETFOREGROUND,
)
};
if response == IDOK {
Ok(())
} else if response.0 == 0 {
Err(StartupPresentationError::Unavailable)
} else {
Err(StartupPresentationError::NotAcknowledged)
}
}
}
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos {
use objc2::MainThreadMarker;
use objc2_app_kit::{
NSAlert, NSAlertFirstButtonReturn, NSAlertStyle, NSApplication,
NSApplicationActivationPolicy, NSScreen,
};
use objc2_foundation::NSString;
use super::{StartupFailure, StartupPresentationError};
pub(super) fn present(failure: &StartupFailure) -> Result<(), StartupPresentationError> {
let mtm = MainThreadMarker::new().ok_or(StartupPresentationError::NotMainThread)?;
if NSScreen::mainScreen(mtm).is_none() {
return Err(StartupPresentationError::Unavailable);
}
let app = NSApplication::sharedApplication(mtm);
if !app.setActivationPolicy(NSApplicationActivationPolicy::Regular) {
return Err(StartupPresentationError::Unavailable);
}
app.finishLaunching();
#[allow(deprecated)]
app.activateIgnoringOtherApps(true);
let alert = NSAlert::new(mtm);
alert.setAlertStyle(NSAlertStyle::Critical);
alert.setMessageText(&NSString::from_str(&failure.title));
let detail = failure.detail();
alert.setInformativeText(&NSString::from_str(&detail));
alert.addButtonWithTitle(&NSString::from_str("OK"));
if alert.runModal() == NSAlertFirstButtonReturn {
Ok(())
} else {
Err(StartupPresentationError::NotAcknowledged)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creates_bounded_explicit_copy() -> Result<(), StartupFailureError> {
let failure = StartupFailure::new(
"Unable to open",
"The application could not start.",
"Check the installation and try again.",
)?;
assert_eq!(failure.title, "Unable to open");
assert_eq!(failure.help, "Check the installation and try again.");
assert_eq!(
failure.detail(),
"The application could not start.\n\nCheck the installation and try again."
);
Ok(())
}
#[test]
fn rejects_missing_oversize_and_disguised_multiline_text() {
assert_eq!(
StartupFailure::new(" ", "Failed", "Try again"),
Err(StartupFailureError::Empty { field: "title" })
);
assert_eq!(
StartupFailure::new(
"Failed",
"x".repeat(MAX_STARTUP_SUMMARY_BYTES + 1),
"Try again"
),
Err(StartupFailureError::TooLong {
field: "summary",
max_bytes: MAX_STARTUP_SUMMARY_BYTES,
})
);
assert_eq!(
StartupFailure::new("é".repeat(61), "Failed", "Try again"),
Err(StartupFailureError::TooLong {
field: "title",
max_bytes: MAX_STARTUP_TITLE_BYTES,
})
);
assert_eq!(
StartupFailure::new("Failed", "Failed", "x".repeat(MAX_STARTUP_HELP_BYTES + 1)),
Err(StartupFailureError::TooLong {
field: "help",
max_bytes: MAX_STARTUP_HELP_BYTES,
})
);
for unsafe_text in [
"Try\nagain",
"Try\ragain",
"Try\u{0000}again",
"Try\u{061c}again",
"Try\u{200e}again",
"Try\u{200f}again",
"Try\u{202e}again",
"Try\u{2069}again",
] {
assert_eq!(
StartupFailure::new("Failed", "Startup failed", unsafe_text),
Err(StartupFailureError::UnsafeCharacter { field: "help" })
);
}
}
#[cfg(not(any(target_os = "macos", windows)))]
#[test]
fn unsupported_target_reports_error() -> Result<(), StartupFailureError> {
let failure = StartupFailure::new("Failed", "Startup failed", "Try again")?;
assert_eq!(
present_startup_failure(&failure),
Err(StartupPresentationError::Unsupported)
);
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn non_main_thread_reports_error_without_starting_appkit() -> Result<(), StartupFailureError> {
let failure = StartupFailure::new("Failed", "Startup failed", "Try again")?;
let result = std::thread::spawn(move || present_startup_failure(&failure)).join();
assert!(matches!(
result,
Ok(Err(StartupPresentationError::NotMainThread))
));
Ok(())
}
}