1use windows::{
35 core::{IInspectable, Interface},
36 Data::Xml::Dom::XmlDocument,
37 Foundation::{Collections::StringMap, TypedEventHandler},
38 UI::Notifications::{
39 NotificationData, ToastActivatedEventArgs, ToastDismissedEventArgs,
40 ToastNotificationManager,
41 },
42};
43
44use std::fmt::Display;
45use std::fmt::Write;
46use std::path::Path;
47use std::str::FromStr;
48
49pub use windows::core::HSTRING;
50pub use windows::UI::Notifications::NotificationUpdateResult;
51pub use windows::UI::Notifications::ToastNotification;
52
53use thiserror::Error;
54
55#[derive(Error, Debug)]
56pub enum Error {
57 #[error("Windows API error: {0}")]
58 Os(#[from] windows::core::Error),
59 #[error("IO error: {0}")]
60 Io(#[from] std::io::Error),
61}
62
63pub type Result<T> = std::result::Result<T, Error>;
64
65pub use windows::UI::Notifications::ToastDismissalReason;
72
73pub struct Toast {
74 duration: String,
75 title: String,
76 line1: String,
77 line2: String,
78 images: String,
79 audio: String,
80 app_id: String,
81 progress: Option<Progress>,
82 scenario: String,
83 on_activated: Option<TypedEventHandler<ToastNotification, IInspectable>>,
84 on_dismissed: Option<TypedEventHandler<ToastNotification, ToastDismissedEventArgs>>,
85 buttons: Vec<Button>,
86}
87
88#[derive(Clone, Copy)]
89pub enum Duration {
90 Short,
92
93 Long,
95}
96
97#[derive(Debug, Clone, Copy)]
98pub enum Sound {
99 Default,
100 IM,
101 Mail,
102 Reminder,
103 SMS,
104 Single(LoopableSound),
106 Loop(LoopableSound),
108}
109
110impl TryFrom<&str> for Sound {
111 type Error = SoundParsingError;
112
113 fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
114 Self::from_str(value)
115 }
116}
117
118impl FromStr for Sound {
119 type Err = SoundParsingError;
120
121 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
122 Ok(match s {
123 "Default" => Sound::Default,
124 "IM" => Sound::IM,
125 "Mail" => Sound::Mail,
126 "Reminder" => Sound::Reminder,
127 "SMS" => Sound::SMS,
128 _ => Sound::Single(LoopableSound::from_str(s)?),
129 })
130 }
131}
132
133impl Display for Sound {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 write!(
136 f,
137 "{}",
138 match &self {
139 Sound::Default => "Default",
140 Sound::IM => "IM",
141 Sound::Mail => "Mail",
142 Sound::Reminder => "Reminder",
143 Sound::SMS => "SMS",
144 Sound::Single(s) | Sound::Loop(s) => return write!(f, "{s}"),
145 }
146 )
147 }
148}
149
150struct Button {
151 content: String,
152 action: String,
153}
154
155#[allow(dead_code)]
157#[derive(Debug, Clone, Copy)]
158pub enum LoopableSound {
159 Alarm,
160 Alarm2,
161 Alarm3,
162 Alarm4,
163 Alarm5,
164 Alarm6,
165 Alarm7,
166 Alarm8,
167 Alarm9,
168 Alarm10,
169 Call,
170 Call2,
171 Call3,
172 Call4,
173 Call5,
174 Call6,
175 Call7,
176 Call8,
177 Call9,
178 Call10,
179}
180
181#[derive(Debug)]
182pub struct SoundParsingError;
183impl Display for SoundParsingError {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 write!(f, "couldn't parse string as a valid sound")
186 }
187}
188impl std::error::Error for SoundParsingError {}
189
190impl TryFrom<&str> for LoopableSound {
191 type Error = SoundParsingError;
192
193 fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
194 Self::from_str(value)
195 }
196}
197
198impl FromStr for LoopableSound {
199 type Err = SoundParsingError;
200
201 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
202 Ok(match s {
203 "Alarm" => LoopableSound::Alarm,
204 "Alarm2" => LoopableSound::Alarm2,
205 "Alarm3" => LoopableSound::Alarm3,
206 "Alarm4" => LoopableSound::Alarm4,
207 "Alarm5" => LoopableSound::Alarm5,
208 "Alarm6" => LoopableSound::Alarm6,
209 "Alarm7" => LoopableSound::Alarm7,
210 "Alarm8" => LoopableSound::Alarm8,
211 "Alarm9" => LoopableSound::Alarm9,
212 "Alarm10" => LoopableSound::Alarm10,
213 "Call" => LoopableSound::Call,
214 "Call2" => LoopableSound::Call2,
215 "Call3" => LoopableSound::Call3,
216 "Call4" => LoopableSound::Call4,
217 "Call5" => LoopableSound::Call5,
218 "Call6" => LoopableSound::Call6,
219 "Call7" => LoopableSound::Call7,
220 "Call8" => LoopableSound::Call8,
221 "Call9" => LoopableSound::Call9,
222 "Call10" => LoopableSound::Call10,
223 _ => return Err(SoundParsingError),
224 })
225 }
226}
227
228impl Display for LoopableSound {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 write!(
231 f,
232 "{}",
233 match self {
234 LoopableSound::Alarm => "Alarm",
235 LoopableSound::Alarm2 => "Alarm2",
236 LoopableSound::Alarm3 => "Alarm3",
237 LoopableSound::Alarm4 => "Alarm4",
238 LoopableSound::Alarm5 => "Alarm5",
239 LoopableSound::Alarm6 => "Alarm6",
240 LoopableSound::Alarm7 => "Alarm7",
241 LoopableSound::Alarm8 => "Alarm8",
242 LoopableSound::Alarm9 => "Alarm9",
243 LoopableSound::Alarm10 => "Alarm10",
244 LoopableSound::Call => "Call",
245 LoopableSound::Call2 => "Call2",
246 LoopableSound::Call3 => "Call3",
247 LoopableSound::Call4 => "Call4",
248 LoopableSound::Call5 => "Call5",
249 LoopableSound::Call6 => "Call6",
250 LoopableSound::Call7 => "Call7",
251 LoopableSound::Call8 => "Call8",
252 LoopableSound::Call9 => "Call9",
253 LoopableSound::Call10 => "Call10",
254 }
255 )
256 }
257}
258
259#[allow(dead_code)]
260#[derive(Clone, Copy)]
261pub enum IconCrop {
262 Square,
263 Circular,
264}
265
266#[allow(dead_code)]
267#[derive(Clone, Copy)]
268pub enum Scenario {
269 Default,
271 Alarm,
273 Reminder,
275 IncomingCall,
277}
278
279#[derive(Clone)]
280pub struct Progress {
281 pub tag: String,
283 pub title: String,
285 pub status: String,
287 pub value: f32,
289 pub value_string: String,
291}
292
293impl Progress {
294 fn xml() -> &'static str {
295 r#"<progress
296 title="{progressTitle}"
297 value="{progressValue}"
298 valueStringOverride="{progressValueString}"
299 status="{progressStatus}"/>"#
300 }
301
302 fn tag(&self) -> HSTRING {
303 HSTRING::from(&self.tag)
304 }
305
306 fn title(&self) -> HSTRING {
307 HSTRING::from(&self.title)
308 }
309
310 fn status(&self) -> HSTRING {
311 HSTRING::from(&self.status)
312 }
313
314 fn value(&self) -> HSTRING {
315 HSTRING::from(&self.value.to_string())
316 }
317
318 fn value_string(&self) -> HSTRING {
319 HSTRING::from(&self.value_string)
320 }
321}
322
323impl Toast {
324 pub const POWERSHELL_APP_ID: &'static str = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\
328 \\WindowsPowerShell\\v1.0\\powershell.exe";
329 #[allow(dead_code)]
337 pub fn new(app_id: &str) -> Toast {
338 Toast {
339 duration: String::new(),
340 title: String::new(),
341 line1: String::new(),
342 line2: String::new(),
343 images: String::new(),
344 audio: String::new(),
345 app_id: app_id.to_string(),
346 progress: None,
347 scenario: String::new(),
348 on_activated: None,
349 on_dismissed: None,
350 buttons: Vec::new(),
351 }
352 }
353
354 pub fn title(mut self, content: &str) -> Toast {
359 self.title = format!(
360 r#"<text id="1">{}</text>"#,
361 &quick_xml::escape::escape(content)
362 );
363 self
364 }
365
366 pub fn text1(mut self, content: &str) -> Toast {
371 self.line1 = format!(
372 r#"<text id="2">{}</text>"#,
373 &quick_xml::escape::escape(content)
374 );
375 self
376 }
377
378 pub fn text2(mut self, content: &str) -> Toast {
383 self.line2 = format!(
384 r#"<text id="3">{}</text>"#,
385 &quick_xml::escape::escape(content)
386 );
387 self
388 }
389
390 pub fn duration(mut self, duration: Duration) -> Toast {
392 self.duration = match duration {
393 Duration::Long => "duration=\"long\"",
394 Duration::Short => "duration=\"short\"",
395 }
396 .to_string();
397 self
398 }
399
400 pub fn scenario(mut self, scenario: Scenario) -> Toast {
405 self.scenario = match scenario {
406 Scenario::Default => "",
407 Scenario::Alarm => "scenario=\"alarm\"",
408 Scenario::Reminder => "scenario=\"reminder\"",
409 Scenario::IncomingCall => "scenario=\"incomingCall\"",
410 }
411 .to_string();
412 self
413 }
414
415 pub fn icon(mut self, source: &Path, crop: IconCrop, alt_text: &str) -> Toast {
420 if is_newer_than_windows81() {
421 let crop_type_attr = match crop {
422 IconCrop::Square => "".to_string(),
423 IconCrop::Circular => "hint-crop=\"circle\"".to_string(),
424 };
425
426 self.images = format!(
427 r#"{}<image placement="appLogoOverride" {} src="file:///{}" alt="{}" />"#,
428 self.images,
429 crop_type_attr,
430 quick_xml::escape::escape(source.display().to_string()),
431 quick_xml::escape::escape(alt_text)
432 );
433 self
434 } else {
435 self.image(source, alt_text)
437 }
438 }
439
440 pub fn hero(mut self, source: &Path, alt_text: &str) -> Toast {
444 if is_newer_than_windows81() {
445 self.images = format!(
446 r#"{}<image placement="Hero" src="file:///{}" alt="{}" />"#,
447 self.images,
448 quick_xml::escape::escape(source.display().to_string()),
449 quick_xml::escape::escape(alt_text)
450 );
451 self
452 } else {
453 self.image(source, alt_text)
455 }
456 }
457
458 pub fn image(mut self, source: &Path, alt_text: &str) -> Toast {
463 if !is_newer_than_windows81() {
464 self.images = String::new();
466 }
467 self.images = format!(
468 r#"{}<image id="1" src="file:///{}" alt="{}" />"#,
469 self.images,
470 quick_xml::escape::escape(source.display().to_string()),
471 quick_xml::escape::escape(alt_text)
472 );
473 self
474 }
475
476 pub fn sound(mut self, src: Option<Sound>) -> Toast {
480 self.audio = match src {
481 None => "<audio silent=\"true\" />".to_owned(),
482 Some(Sound::Default) => "".to_owned(),
483 Some(Sound::Loop(sound)) => format!(
484 r#"<audio loop="true" src="ms-winsoundevent:Notification.Looping.{}" />"#,
485 sound
486 ),
487 Some(Sound::Single(sound)) => format!(
488 r#"<audio src="ms-winsoundevent:Notification.Looping.{}" />"#,
489 sound
490 ),
491 Some(sound) => format!(r#"<audio src="ms-winsoundevent:Notification.{}" />"#, sound),
492 };
493
494 self
495 }
496
497 pub fn add_button(mut self, content: &str, action: &str) -> Toast {
501 self.buttons.push(Button {
502 content: content.to_owned(),
503 action: action.to_owned(),
504 });
505 self
506 }
507
508 pub fn progress(mut self, progress: &Progress) -> Toast {
510 self.progress = Some(progress.clone());
511 self
512 }
513
514 pub fn on_activated<F>(mut self, mut f: F) -> Self
517 where
518 F: FnMut(Option<String>) -> Result<()> + Send + 'static,
519 {
520 self.on_activated = Some(TypedEventHandler::new(move |_, insp| {
521 let _ = f(Self::get_activated_action(&insp));
522 Ok(())
523 }));
524 self
525 }
526
527 fn get_activated_action(insp: &Option<IInspectable>) -> Option<String> {
528 if let Some(insp) = insp {
529 if let Ok(args) = insp.cast::<ToastActivatedEventArgs>() {
530 if let Ok(arguments) = args.Arguments() {
531 if !arguments.is_empty() {
532 return Some(arguments.to_string());
533 }
534 }
535 }
536 }
537 None
538 }
539
540 pub fn on_dismissed<F>(mut self, f: F) -> Self
563 where
564 F: Fn(Option<ToastDismissalReason>) -> Result<()> + Send + 'static,
565 {
566 self.on_dismissed = Some(TypedEventHandler::new(move |_, args| {
567 let _ = f(Self::get_dismissed_reason(&args));
568 Ok(())
569 }));
570 self
571 }
572
573 fn get_dismissed_reason(
574 args: &Option<ToastDismissedEventArgs>,
575 ) -> Option<ToastDismissalReason> {
576 if let Some(args) = args {
577 if let Ok(reason) = args.Reason() {
578 return Some(reason);
579 }
580 }
581 None
582 }
583
584 fn create_template(&self) -> Result<ToastNotification> {
585 let toast_xml = XmlDocument::new()?;
587
588 let template_binding = if is_newer_than_windows81() {
589 "ToastGeneric"
590 } else {
591 if self.images.is_empty() {
593 "ToastText04"
594 } else {
595 "ToastImageAndText04"
596 }
597 };
598
599 let progress = match self.progress {
600 Some(_) => Progress::xml(),
601 None => "",
602 };
603
604 let mut actions = String::new();
605 if !self.buttons.is_empty() {
606 let _ = write!(actions, "<actions>");
607 for b in &self.buttons {
608 let _ = write!(
609 actions,
610 "<action content='{}' arguments='{}'/>",
611 b.content, b.action
612 );
613 }
614 let _ = write!(actions, "</actions>");
615 }
616
617 toast_xml.LoadXml(&HSTRING::from(format!(
618 r#"<toast {} {}>
619 <visual>
620 <binding template="{}">
621 {}
622 {}{}{}
623 {}
624 </binding>
625 </visual>
626 {}
627 {}
628 </toast>"#,
629 self.duration,
630 self.scenario,
631 template_binding,
632 self.images,
633 self.title,
634 self.line1,
635 self.line2,
636 progress,
637 self.audio,
638 actions
639 )))?;
640
641 ToastNotification::CreateToastNotification(&toast_xml).map_err(Into::into)
643 }
644
645 pub fn set_progress(&self, progress: &Progress) -> Result<NotificationUpdateResult> {
680 let map = StringMap::new()?;
681 map.Insert(&HSTRING::from("progressTitle"), &progress.title())?;
682 map.Insert(&HSTRING::from("progressStatus"), &progress.status())?;
683 map.Insert(&HSTRING::from("progressValue"), &progress.value())?;
684 map.Insert(
685 &HSTRING::from("progressValueString"),
686 &progress.value_string(),
687 )?;
688
689 let data = NotificationData::CreateNotificationDataWithValuesAndSequenceNumber(&map, 2)?;
690
691 let toast_notifier =
692 ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(&self.app_id))?;
693
694 toast_notifier
695 .UpdateWithTag(&data, &progress.tag())
696 .map_err(Into::into)
697 }
698
699 pub fn show(&self) -> Result<()> {
701 let toast_template = self.create_template()?;
702 if let Some(handler) = &self.on_activated {
703 toast_template.Activated(handler)?;
704 }
705
706 if let Some(handler) = &self.on_dismissed {
707 toast_template.Dismissed(handler)?;
708 }
709
710 let toast_notifier =
711 ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(&self.app_id))?;
712
713 if let Some(progress) = &self.progress {
714 toast_template.SetTag(&progress.tag())?;
715
716 let map = StringMap::new()?;
717 map.Insert(&HSTRING::from("progressTitle"), &progress.title())?;
718 map.Insert(&HSTRING::from("progressStatus"), &progress.status())?;
719 map.Insert(&HSTRING::from("progressValue"), &progress.value())?;
720 map.Insert(
721 &HSTRING::from("progressValueString"),
722 &progress.value_string(),
723 )?;
724
725 let data =
726 NotificationData::CreateNotificationDataWithValuesAndSequenceNumber(&map, 1)?;
727 toast_template.SetData(&data)?;
728 }
729
730 let result = toast_notifier.Show(&toast_template).map_err(Into::into);
732 std::thread::sleep(std::time::Duration::from_millis(10));
733 result
734 }
735}
736
737fn is_newer_than_windows81() -> bool {
738 let os = windows_version::OsVersion::current();
739 os.major > 6
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745
746 #[test]
747 fn simple_toast() {
748 let toast = Toast::new(Toast::POWERSHELL_APP_ID);
749 toast
750 .hero(
751 &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/flower.jpeg"),
752 "flower",
753 )
754 .icon(
755 &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/chick.jpeg"),
756 IconCrop::Circular,
757 "chicken",
758 )
759 .title("title")
760 .text1("line1")
761 .text2("line2")
762 .duration(Duration::Short)
763 .sound(None)
766 .show()
767 .expect("notification failed");
769 }
770}