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: None,
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!("progress"))?;
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 }
689 xml_el_toast.AppendChild(&xml_el_actions)?;
690 }
691
692 xml_doc.AppendChild(&xml_el_toast)?;
693
694 ToastNotification::CreateToastNotification(&xml_doc).map_err(Into::into)
696 }
697
698 pub fn set_progress(&self, progress: &Progress) -> Result<NotificationUpdateResult> {
733 let map = StringMap::new()?;
734 map.Insert(h!("progressTitle"), &progress.title())?;
735 map.Insert(h!("progressStatus"), &progress.status())?;
736 map.Insert(h!("progressValue"), &progress.value())?;
737 map.Insert(h!("progressValueString"), &progress.value_string())?;
738
739 let data = NotificationData::CreateNotificationDataWithValuesAndSequenceNumber(&map, 2)?;
740
741 let toast_notifier =
742 ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(&self.app_id))?;
743
744 toast_notifier
745 .UpdateWithTag(&data, &progress.tag())
746 .map_err(Into::into)
747 }
748
749 pub fn show(&self) -> Result<()> {
751 let toast_template = self.create_template()?;
752 if let Some(handler) = &self.on_activated {
753 toast_template.Activated(handler)?;
754 }
755
756 if let Some(handler) = &self.on_dismissed {
757 toast_template.Dismissed(handler)?;
758 }
759
760 let toast_notifier =
761 ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(&self.app_id))?;
762
763 if let Some(progress) = &self.progress {
764 toast_template.SetTag(&progress.tag())?;
765
766 let map = StringMap::new()?;
767 map.Insert(h!("progressTitle"), &progress.title())?;
768 map.Insert(h!("progressStatus"), &progress.status())?;
769 map.Insert(h!("progressValue"), &progress.value())?;
770 map.Insert(h!("progressValueString"), &progress.value_string())?;
771
772 let data =
773 NotificationData::CreateNotificationDataWithValuesAndSequenceNumber(&map, 1)?;
774 toast_template.SetData(&data)?;
775 }
776
777 let result = toast_notifier.Show(&toast_template).map_err(Into::into);
779 std::thread::sleep(std::time::Duration::from_millis(10));
780 result
781 }
782}
783
784fn is_newer_than_windows81() -> bool {
785 let os = windows_version::OsVersion::current();
786 os.major > 6
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn simple_toast() {
795 let toast = Toast::new(Toast::POWERSHELL_APP_ID);
796 toast
797 .hero(
798 &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/flower.jpeg"),
799 "flower",
800 )
801 .icon(
802 &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/chick.jpeg"),
803 IconCrop::Circular,
804 "chicken",
805 )
806 .title("title")
807 .text1("line1")
808 .text2("line2")
809 .duration(Duration::Short)
810 .sound(None)
813 .show()
814 .expect("notification failed");
816 }
817
818 #[test]
819 fn create_template_xml_simple() {
820 let expected = r#"<toast><visual><binding template="ToastGeneric"/></visual><audio silent="true"/></toast>"#;
821 let toast = Toast::new(Toast::POWERSHELL_APP_ID);
822 let template = toast.create_template().unwrap();
823 assert_eq!(
824 template
825 .Content()
826 .unwrap()
827 .GetXml()
828 .unwrap()
829 .to_string_lossy(),
830 expected
831 );
832 }
833
834 #[test]
835 fn create_template_xml_audio_loop() {
836 let expected = r#"<toast><visual><binding template="ToastGeneric"/></visual><progress src="ms-winsoundevent:Notification.Looping.Call" loop="true"/></toast>"#;
837 let toast =
838 Toast::new(Toast::POWERSHELL_APP_ID).sound(Some(Sound::Loop(LoopableSound::Call)));
839
840 let template = toast.create_template().unwrap();
841 assert_eq!(
842 template
843 .Content()
844 .unwrap()
845 .GetXml()
846 .unwrap()
847 .to_string_lossy(),
848 expected
849 );
850 }
851
852 #[test]
853 fn create_template_xml_full() {
854 let img1 = &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/flower.jpeg");
855 let img2 = &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/chick.jpeg");
856 let expected = format!(
857 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>"#,
858 img1.display(),
859 img2.display()
860 );
861 let toast = Toast::new(Toast::POWERSHELL_APP_ID)
862 .hero(img1, "flower")
863 .icon(img2, IconCrop::Circular, "chicken")
864 .title("title")
865 .text1("line1")
866 .text2("line2")
867 .duration(Duration::Short)
868 .sound(None);
869
870 let template = toast.create_template().unwrap();
871 assert_eq!(
872 template
873 .Content()
874 .unwrap()
875 .GetXml()
876 .unwrap()
877 .to_string_lossy(),
878 expected
879 );
880 }
881}