use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use windows::core::{Interface as _, HSTRING};
use windows::Data::Xml::Dom::XmlDocument;
use windows::Foundation::TypedEventHandler;
use windows::UI::Notifications::{
ToastActivatedEventArgs, ToastNotification, ToastNotificationManager, ToastNotifier,
};
use super::{Answer, Channel, Choice, Question, Report, Weight};
pub struct Toast {
notifier: ToastNotifier,
outstanding: Mutex<HashMap<String, Vec<ToastNotification>>>,
answers: Arc<Mutex<Vec<Answer>>>,
dialogs: Dialogs,
}
impl Toast {
pub fn connect() -> windows::core::Result<Self> {
apartment();
let notifier = ToastNotificationManager::CreateToastNotifier()?;
Ok(Self {
notifier,
outstanding: Mutex::default(),
answers: Arc::default(),
dialogs: Dialogs::default(),
})
}
fn show(&self, xml: &str, about: Option<&str>) -> windows::core::Result<()> {
let document = XmlDocument::new()?;
document.LoadXml(&HSTRING::from(xml))?;
let notification = ToastNotification::CreateToastNotification(&document)?;
if let Some(about) = about {
let answers = Arc::clone(&self.answers);
let about = about.to_owned();
notification.Activated(&TypedEventHandler::new(
move |_sender: windows::core::Ref<'_, ToastNotification>,
args: windows::core::Ref<'_, windows::core::IInspectable>| {
if let Some(pressed) = args
.as_ref()
.and_then(|a| a.cast::<ToastActivatedEventArgs>().ok())
.and_then(|a| a.Arguments().ok())
.and_then(|k| Choice::from_key(&k.to_string()))
{
if let Ok(mut answers) = answers.lock() {
answers.push(Answer {
about: about.clone(),
choice: pressed,
});
}
}
Ok(())
},
))?;
}
self.notifier.Show(¬ification)?;
if let Some(about) = about {
if let Ok(mut outstanding) = self.outstanding.lock() {
outstanding
.entry(about.to_owned())
.or_default()
.push(notification);
}
}
Ok(())
}
}
impl Channel for Toast {
fn report(&self, report: &Report) {
let _ = self.show(
&body(&report.summary, &report.detail, &[], report.weight),
None,
);
}
fn ask(&self, question: &Question) {
let xml = body(
&question.summary,
&question.detail,
&question.choices,
Weight::Interrupt,
);
let _ = self.show(&xml, Some(&question.about));
}
fn withdraw(&self, about: &str) {
let Ok(mut outstanding) = self.outstanding.lock() else {
return;
};
for notification in outstanding.remove(about).unwrap_or_default() {
let _ = self.notifier.Hide(¬ification);
}
}
fn answers(&self) -> Vec<Answer> {
self.answers
.lock()
.map_or_else(|_| Vec::new(), |mut a| std::mem::take(&mut *a))
}
fn insist(&self, report: &Report) {
self.report(report);
self.dialogs.raise(message_box(report));
}
fn stay_until_seen(&self) {
self.dialogs.settle();
}
}
#[derive(Default)]
struct Dialogs(Mutex<Vec<std::thread::JoinHandle<()>>>);
impl Dialogs {
fn raise(&self, show: impl FnOnce() + Send + 'static) {
let spawned = std::thread::Builder::new()
.name("slipcase-open dialog".to_owned())
.spawn(show);
if let (Ok(handle), Ok(mut held)) = (spawned, self.0.lock()) {
held.push(handle);
}
}
fn settle(&self) {
let waiting = match self.0.lock() {
Ok(mut held) => std::mem::take(&mut *held),
Err(_) => return,
};
for handle in waiting {
let _ = handle.join();
}
}
}
fn message_box(report: &Report) -> impl FnOnce() + Send + 'static {
use windows::core::HSTRING;
let mut body = report.summary.clone();
for line in &report.detail {
body.push_str("\n\n");
body.push_str(line);
}
let text = HSTRING::from(body);
let title = HSTRING::from("Slipcase Open");
move || show_box(&text, &title)
}
#[allow(unsafe_code)]
fn show_box(text: &windows::core::HSTRING, title: &windows::core::HSTRING) {
use windows::Win32::UI::WindowsAndMessaging::{
MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, MB_SYSTEMMODAL,
};
unsafe {
MessageBoxW(
None,
windows::core::PCWSTR(text.as_ptr()),
windows::core::PCWSTR(title.as_ptr()),
MB_OK | MB_ICONERROR | MB_SETFOREGROUND | MB_SYSTEMMODAL,
);
}
}
#[allow(unsafe_code)]
fn apartment() {
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
}
fn body(summary: &str, detail: &[String], choices: &[Choice], weight: Weight) -> String {
let mut xml = String::from("<toast");
if weight == Weight::Interrupt {
xml.push_str(" duration=\"long\" scenario=\"reminder\"");
}
xml.push_str("><visual><binding template=\"ToastGeneric\"><text>");
xml.push_str(&escape(summary));
xml.push_str("</text>");
for line in detail {
xml.push_str("<text>");
xml.push_str(&escape(line));
xml.push_str("</text>");
}
xml.push_str("</binding></visual>");
if !choices.is_empty() {
xml.push_str("<actions>");
for choice in choices {
xml.push_str("<action activationType=\"foreground\" content=\"");
xml.push_str(&escape(choice.label()));
xml.push_str("\" arguments=\"");
xml.push_str(&escape(choice.key()));
xml.push_str("\"/>");
}
xml.push_str("</actions>");
}
xml.push_str("</toast>");
xml
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::{body, escape, Dialogs};
use crate::present::{Choice, Weight};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[test]
fn nothing_is_left_running_once_the_dialogs_have_settled() {
let dialogs = Dialogs::default();
let closed = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&closed);
dialogs.raise(move || {
std::thread::sleep(std::time::Duration::from_millis(120));
flag.store(true, Ordering::SeqCst);
});
dialogs.settle();
assert!(
closed.load(Ordering::SeqCst),
"settle returned while a box was still up, which is the defect"
);
}
#[test]
fn settling_with_no_dialog_up_returns_rather_than_waits() {
let dialogs = Dialogs::default();
let started = std::time::Instant::now();
dialogs.settle();
dialogs.settle();
assert!(started.elapsed() < std::time::Duration::from_secs(1));
}
#[test]
fn a_payload_name_with_markup_in_it_is_escaped() {
let xml = body(
"R&D <draft>.txt is open",
&["It came from \"somewhere else\"".to_string()],
&[],
Weight::Routine,
);
assert!(xml.contains("R&D <draft>.txt"), "{xml}");
assert!(xml.contains(""somewhere else""), "{xml}");
assert!(!xml.contains("<draft>"), "{xml}");
}
#[test]
fn a_question_carries_its_choices_as_keys_not_labels() {
let xml = body(
"A session was left behind",
&[],
&[Choice::WriteBack, Choice::Discard],
Weight::Interrupt,
);
assert!(
xml.contains(&format!("arguments=\"{}\"", Choice::WriteBack.key())),
"{xml}"
);
assert!(
xml.contains(&format!("content=\"{}\"", Choice::Discard.label())),
"{xml}"
);
assert!(xml.contains("scenario=\"reminder\""), "{xml}");
}
#[test]
fn a_report_has_no_actions_and_does_not_linger() {
let xml = body("Written back", &[], &[], Weight::Routine);
assert!(!xml.contains("<actions>"), "{xml}");
assert!(!xml.contains("scenario="), "{xml}");
}
#[test]
fn every_key_survives_the_round_trip_a_button_makes() {
for choice in [Choice::WriteBack, Choice::Discard, Choice::Reveal] {
assert_eq!(Choice::from_key(choice.key()), Some(choice));
assert_eq!(escape(choice.key()), choice.key(), "a key needs escaping");
}
}
}