1use windows::{
36 core::{h, IInspectable, Interface},
37 Data::Xml::Dom::XmlDocument,
38 Foundation::{Collections::StringMap, TypedEventHandler},
39 UI::Notifications::{
40 NotificationData, ToastActivatedEventArgs, ToastDismissedEventArgs,
41 ToastNotificationManager,
42 },
43};
44
45use std::fmt::Display;
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: Option<Duration>,
75 title: Option<String>,
76 line1: Option<String>,
77 line2: Option<String>,
78 images: Vec<Image>,
79 audio: Option<Sound>,
80 app_id: String,
81 progress: Option<Progress>,
82 scenario: Option<Scenario>,
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, PartialEq)]
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, PartialEq)]
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, PartialEq)]
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 tag(&self) -> HSTRING {
295 HSTRING::from(&self.tag)
296 }
297
298 fn title(&self) -> HSTRING {
299 HSTRING::from(&self.title)
300 }
301
302 fn status(&self) -> HSTRING {
303 HSTRING::from(&self.status)
304 }
305
306 fn value(&self) -> HSTRING {
307 HSTRING::from(&self.value.to_string())
308 }
309
310 fn value_string(&self) -> HSTRING {
311 HSTRING::from(&self.value_string)
312 }
313}
314
315struct Image {
316 source: String,
317 alt_text: String,
318 placement: Option<String>,
320 crop_type: Option<IconCrop>,
322}
323
324impl Toast {
325 pub const POWERSHELL_APP_ID: &'static str = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\
329 \\WindowsPowerShell\\v1.0\\powershell.exe";
330 #[allow(dead_code)]
338 pub fn new(app_id: &str) -> Toast {
339 Toast {
340 duration: None,
341 title: None,
342 line1: None,
343 line2: None,
344 images: Vec::new(),
345 audio: Some(Sound::Default),
346 app_id: app_id.to_string(),
347 progress: None,
348 scenario: None,
349 on_activated: None,
350 on_dismissed: None,
351 buttons: Vec::new(),
352 }
353 }
354
355 pub fn title(mut self, content: &str) -> Toast {
360 self.title = Some(content.to_string());
361 self
362 }
363
364 pub fn text1(mut self, content: &str) -> Toast {
369 self.line1 = Some(content.to_string());
370 self
371 }
372
373 pub fn text2(mut self, content: &str) -> Toast {
378 self.line2 = Some(content.to_string());
379 self
380 }
381
382 pub fn duration(mut self, duration: Duration) -> Toast {
384 self.duration = Some(duration);
385 self
386 }
387
388 pub fn scenario(mut self, scenario: Scenario) -> Toast {
393 self.scenario = Some(scenario);
394 self
395 }
396
397 pub fn icon(mut self, source: &Path, crop: IconCrop, alt_text: &str) -> Toast {
404 if is_newer_than_windows81() {
405 self.images.push(Image {
406 source: source.display().to_string(),
407 alt_text: alt_text.to_string(),
408 placement: Some("appLogoOverride".to_string()),
409 crop_type: Some(crop),
410 });
411 self
412 } else {
413 self.image(source, alt_text)
415 }
416 }
417
418 pub fn hero(mut self, source: &Path, alt_text: &str) -> Toast {
424 if is_newer_than_windows81() {
425 self.images.push(Image {
426 source: source.display().to_string(),
427 alt_text: alt_text.to_string(),
428 placement: Some("Hero".to_string()),
429 crop_type: None,
430 });
431 self
432 } else {
433 self.image(source, alt_text)
435 }
436 }
437
438 pub fn image(mut self, source: &Path, alt_text: &str) -> Toast {
445 if !is_newer_than_windows81() {
446 self.images.clear();
448 }
449 self.images.push(Image {
450 source: source.display().to_string(),
451 alt_text: alt_text.to_string(),
452 placement: None,
453 crop_type: None,
454 });
455 self
456 }
457
458 pub fn sound(mut self, src: Option<Sound>) -> Toast {
462 self.audio = src;
463 self
464 }
465
466 pub fn add_button(mut self, content: &str, action: &str) -> Toast {
470 self.buttons.push(Button {
471 content: content.to_owned(),
472 action: action.to_owned(),
473 });
474 self
475 }
476
477 pub fn progress(mut self, progress: &Progress) -> Toast {
479 self.progress = Some(progress.clone());
480 self
481 }
482
483 pub fn on_activated<F>(mut self, f: F) -> Self
486 where
487 F: Fn(Option<String>) -> Result<()> + Send + 'static,
488 {
489 self.on_activated = Some(TypedEventHandler::new(move |_, insp| {
490 let _ = f(Self::get_activated_action(&insp));
491 Ok(())
492 }));
493 self
494 }
495
496 fn get_activated_action(insp: &Option<IInspectable>) -> Option<String> {
497 if let Some(insp) = insp {
498 if let Ok(args) = insp.cast::<ToastActivatedEventArgs>() {
499 if let Ok(arguments) = args.Arguments() {
500 if !arguments.is_empty() {
501 return Some(arguments.to_string());
502 }
503 }
504 }
505 }
506 None
507 }
508
509 pub fn on_dismissed<F>(mut self, f: F) -> Self
532 where
533 F: Fn(Option<ToastDismissalReason>) -> Result<()> + Send + 'static,
534 {
535 self.on_dismissed = Some(TypedEventHandler::new(move |_, args| {
536 let _ = f(Self::get_dismissed_reason(&args));
537 Ok(())
538 }));
539 self
540 }
541
542 fn get_dismissed_reason(
543 args: &Option<ToastDismissedEventArgs>,
544 ) -> Option<ToastDismissalReason> {
545 if let Some(args) = args {
546 if let Ok(reason) = args.Reason() {
547 return Some(reason);
548 }
549 }
550 None
551 }
552
553 fn create_template(&self) -> Result<ToastNotification> {
554 let xml_doc = XmlDocument::new()?;
555
556 let template_binding = if is_newer_than_windows81() {
557 h!("ToastGeneric")
558 } else {
559 if self.images.is_empty() {
561 h!("ToastText04")
562 } else {
563 h!("ToastImageAndText04")
564 }
565 };
566
567 let xml_el_toast = xml_doc.CreateElement(h!("toast"))?;
568 let xml_el_visual = xml_doc.CreateElement(h!("visual"))?;
569 let xml_el_binding = xml_doc.CreateElement(h!("binding"))?;
570
571 if let Some(duration) = self.duration {
572 let duration = match duration {
573 Duration::Long => h!("long"),
574 Duration::Short => h!("short"),
575 };
576 xml_el_toast.SetAttribute(h!("duration"), duration)?;
577 }
578
579 if let Some(scenario) = self.scenario {
580 let scenario = match scenario {
581 Scenario::Default => h!(""),
582 Scenario::Alarm => h!("alarm"),
583 Scenario::Reminder => h!("reminder"),
584 Scenario::IncomingCall => h!("incomingCall"),
585 };
586 xml_el_toast.SetAttribute(h!("scenario"), scenario)?;
587 }
588
589 xml_el_binding.SetAttribute(h!("template"), template_binding)?;
590
591 for image in &self.images {
592 let xml_el = xml_doc.CreateElement(h!("image"))?;
593 xml_el.SetAttribute(h!("id"), h!("1"))?;
596 xml_el.SetAttribute(
597 h!("src"),
598 &format!("file:///{}", image.source.clone()).into(),
599 )?;
600 xml_el.SetAttribute(h!("alt"), &HSTRING::from(&image.alt_text))?;
601 if let Some(placement) = &image.placement {
602 xml_el.SetAttribute(h!("placement"), &placement.into())?;
603 }
604 if let Some(crop_type) = image.crop_type {
605 if crop_type == IconCrop::Circular {
606 xml_el.SetAttribute(h!("hint-crop"), h!("circle"))?;
607 }
608 };
609
610 xml_el_binding.AppendChild(&xml_el)?;
611 }
612
613 if let Some(title) = &self.title {
614 let xml_el = xml_doc.CreateElement(h!("text"))?;
615 xml_el.SetAttribute(h!("id"), h!("1"))?;
616 xml_el.SetInnerText(&title.into())?;
617 xml_el_binding.AppendChild(&xml_el)?;
618 }
619
620 if let Some(line1) = &self.line1 {
621 let xml_el = xml_doc.CreateElement(h!("text"))?;
622 xml_el.SetAttribute(h!("id"), h!("2"))?;
623 xml_el.SetInnerText(&line1.into())?;
624 xml_el_binding.AppendChild(&xml_el)?;
625 }
626
627 if let Some(line2) = &self.line2 {
628 let xml_el = xml_doc.CreateElement(h!("text"))?;
629 xml_el.SetAttribute(h!("id"), h!("3"))?;
630 xml_el.SetInnerText(&line2.into())?;
631 xml_el_binding.AppendChild(&xml_el)?;
632 }
633
634 if let Some(progress) = &self.progress {
635 let xml_el = xml_doc.CreateElement(h!("progress"))?;
636 xml_el.SetAttribute(h!("title"), &progress.title())?;
637 xml_el.SetAttribute(h!("value"), &progress.value())?;
638 xml_el.SetAttribute(h!("valueStringOverride"), &progress.value_string())?;
639 xml_el.SetAttribute(h!("status"), &progress.status())?;
640 xml_el_binding.AppendChild(&xml_el)?;
641 }
642
643 xml_el_visual.AppendChild(&xml_el_binding)?;
644 xml_el_toast.AppendChild(&xml_el_visual)?;
645
646 if let Some(sound) = self.audio {
648 if sound != Sound::Default {
649 let xml_el = xml_doc.CreateElement(h!("audio"))?;
650 match sound {
651 Sound::Default => unreachable!(),
652 Sound::Single(loopable_sound) => {
653 xml_el.SetAttribute(
654 h!("src"),
655 &format!("ms-winsoundevent:Notification.Looping.{loopable_sound}")
656 .into(),
657 )?;
658 }
659 Sound::Loop(loopable_sound) => {
660 xml_el.SetAttribute(
661 h!("src"),
662 &format!("ms-winsoundevent:Notification.Looping.{loopable_sound}")
663 .into(),
664 )?;
665 xml_el.SetAttribute(h!("loop"), h!("true"))?;
666 }
667 _ => {
668 xml_el.SetAttribute(
669 h!("src"),
670 &format!("ms-winsoundevent:Notification.{sound}").into(),
671 )?;
672 }
673 }
674 xml_el_toast.AppendChild(&xml_el)?;
675 }
676 } else {
677 let xml_el = xml_doc.CreateElement(h!("audio"))?;
678 xml_el.SetAttribute(h!("silent"), h!("true"))?;
679 xml_el_toast.AppendChild(&xml_el)?;
680 }
681
682 if !self.buttons.is_empty() {
683 let xml_el_actions = xml_doc.CreateElement(h!("actions"))?;
684 for button in &self.buttons {
685 let xml_el = xml_doc.CreateElement(h!("action"))?;
686 xml_el.SetAttribute(h!("content"), &HSTRING::from(&button.content))?;
687 xml_el.SetAttribute(h!("arguments"), &HSTRING::from(&button.action))?;
688 xml_el_actions.AppendChild(&xml_el)?;
689 }
690 xml_el_toast.AppendChild(&xml_el_actions)?;
691 }
692
693 xml_doc.AppendChild(&xml_el_toast)?;
694
695 ToastNotification::CreateToastNotification(&xml_doc).map_err(Into::into)
697 }
698
699 pub fn set_progress(&self, progress: &Progress) -> Result<NotificationUpdateResult> {
734 let map = StringMap::new()?;
735 map.Insert(h!("progressTitle"), &progress.title())?;
736 map.Insert(h!("progressStatus"), &progress.status())?;
737 map.Insert(h!("progressValue"), &progress.value())?;
738 map.Insert(h!("progressValueString"), &progress.value_string())?;
739
740 let data = NotificationData::CreateNotificationDataWithValuesAndSequenceNumber(&map, 2)?;
741
742 let toast_notifier =
743 ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(&self.app_id))?;
744
745 toast_notifier
746 .UpdateWithTag(&data, &progress.tag())
747 .map_err(Into::into)
748 }
749
750 pub fn show(&self) -> Result<()> {
752 let toast_template = self.create_template()?;
753 if let Some(handler) = &self.on_activated {
754 toast_template.Activated(handler)?;
755 }
756
757 if let Some(handler) = &self.on_dismissed {
758 toast_template.Dismissed(handler)?;
759 }
760
761 let toast_notifier =
762 ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(&self.app_id))?;
763
764 if let Some(progress) = &self.progress {
765 toast_template.SetTag(&progress.tag())?;
766
767 let map = StringMap::new()?;
768 map.Insert(h!("progressTitle"), &progress.title())?;
769 map.Insert(h!("progressStatus"), &progress.status())?;
770 map.Insert(h!("progressValue"), &progress.value())?;
771 map.Insert(h!("progressValueString"), &progress.value_string())?;
772
773 let data =
774 NotificationData::CreateNotificationDataWithValuesAndSequenceNumber(&map, 1)?;
775 toast_template.SetData(&data)?;
776 }
777
778 let result = toast_notifier.Show(&toast_template).map_err(Into::into);
780 std::thread::sleep(std::time::Duration::from_millis(10));
781 result
782 }
783}
784
785fn is_newer_than_windows81() -> bool {
786 let os = windows_version::OsVersion::current();
787 os.major > 6
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 #[test]
795 fn simple_toast() {
796 let toast = Toast::new(Toast::POWERSHELL_APP_ID);
797 toast
798 .hero(
799 &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/flower.jpeg"),
800 "flower",
801 )
802 .icon(
803 &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/chick.jpeg"),
804 IconCrop::Circular,
805 "chicken",
806 )
807 .title("title")
808 .text1("line1")
809 .text2("line2")
810 .duration(Duration::Short)
811 .sound(None)
814 .show()
815 .expect("notification failed");
817 }
818
819 #[test]
820 fn create_template_xml_simple() {
821 let expected = r#"<toast><visual><binding template="ToastGeneric"/></visual><audio silent="true"/></toast>"#;
822 let toast = Toast::new(Toast::POWERSHELL_APP_ID);
823 let template = toast.create_template().unwrap();
824 assert_eq!(
825 template
826 .Content()
827 .unwrap()
828 .GetXml()
829 .unwrap()
830 .to_string_lossy(),
831 expected
832 );
833 }
834
835 #[test]
836 fn create_template_xml_audio_loop() {
837 let expected = r#"<toast><visual><binding template="ToastGeneric"/></visual><progress src="ms-winsoundevent:Notification.Looping.Call" loop="true"/></toast>"#;
838 let toast =
839 Toast::new(Toast::POWERSHELL_APP_ID).sound(Some(Sound::Loop(LoopableSound::Call)));
840
841 let template = toast.create_template().unwrap();
842 assert_eq!(
843 template
844 .Content()
845 .unwrap()
846 .GetXml()
847 .unwrap()
848 .to_string_lossy(),
849 expected
850 );
851 }
852
853 #[test]
854 fn create_template_xml_full() {
855 let img1 = &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/flower.jpeg");
856 let img2 = &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/chick.jpeg");
857 let expected = format!(
858 r#"<toast duration="short"><visual><binding template="ToastGeneric"><image id="1" src="file:///{}" alt="flower" placement="Hero"/><image id="1" src="file:///{}" alt="chicken" placement="appLogoOverride" hint-crop="circle"/><text id="1">title</text><text id="2">line1</text><text id="3">line2</text></binding></visual><audio silent="true"/></toast>"#,
859 img1.display(),
860 img2.display()
861 );
862 let toast = Toast::new(Toast::POWERSHELL_APP_ID)
863 .hero(img1, "flower")
864 .icon(img2, IconCrop::Circular, "chicken")
865 .title("title")
866 .text1("line1")
867 .text2("line2")
868 .duration(Duration::Short)
869 .sound(None);
870
871 let template = toast.create_template().unwrap();
872 assert_eq!(
873 template
874 .Content()
875 .unwrap()
876 .GetXml()
877 .unwrap()
878 .to_string_lossy(),
879 expected
880 );
881 }
882}