tauri_winrt_notification/
lib.rs

1// Copyright 2017-2022 allenbenz <allenbenz@users.noreply.github.com>
2// Copyright 2022-2022 Tauri Programme within The Commons Conservancy
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6//! An incomplete wrapper over the WinRT toast api
7//!
8//! Tested in Windows 10 and 8.1. Untested in Windows 8, might work.
9//!
10//! Todo:
11//!
12//! * Add support for Adaptive Content
13//!
14//! Known Issues:
15//!
16//! * Will not work for Windows 7.
17//!
18//! Limitations:
19//!
20//! * Windows 8.1 only supports a single image, the last image (icon, hero, image) will be the one on the toast
21//!
22//! for xml schema details check out:
23//!
24//! * <https://docs.microsoft.com/en-us/uwp/schemas/tiles/toastschema/root-elements>
25//! * <https://docs.microsoft.com/en-us/windows/uwp/controls-and-patterns/tiles-and-notifications-toast-xml-schema>
26//! * <https://docs.microsoft.com/en-us/windows/uwp/controls-and-patterns/tiles-and-notifications-adaptive-interactive-toasts>
27//! * <https://msdn.microsoft.com/library/14a07fce-d631-4bad-ab99-305b703713e6#Sending_toast_notifications_from_desktop_apps>
28//!
29//! For Windows 7 and older support look into Shell_NotifyIcon
30//! * <https://msdn.microsoft.com/en-us/library/windows/desktop/ee330740(v=vs.85).aspx>
31//! * <https://softwareengineering.stackexchange.com/questions/222339/using-the-system-tray-notification-area-app-in-windows-7>
32//!
33//! For actions look at <https://docs.microsoft.com/en-us/dotnet/api/microsoft.toolkit.uwp.notifications.toastactionscustom?view=win-comm-toolkit-dotnet-7.0>
34use 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
65/// `ToastDismissalReason` is a struct representing the reason a toast notification was dismissed.
66///
67/// Variants:
68/// - `UserCanceled`: The user explicitly dismissed the toast notification.
69/// - `ApplicationHidden`: The application hid the toast notification programmatically.
70/// - `TimedOut`: The toast notification was dismissed because it timed out.
71pub 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    /// 7 seconds
91    Short,
92
93    /// 25 seconds
94    Long,
95}
96
97#[derive(Debug, Clone, Copy)]
98pub enum Sound {
99    Default,
100    IM,
101    Mail,
102    Reminder,
103    SMS,
104    /// Play the loopable sound only once
105    Single(LoopableSound),
106    /// Loop the loopable sound for the entire duration of the toast
107    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/// Sounds suitable for Looping
156#[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    /// The normal toast behavior.
270    Default,
271    /// This will be displayed pre-expanded and stay on the user's screen till dismissed. Audio will loop by default and will use alarm audio.
272    Alarm,
273    /// This will be displayed pre-expanded and stay on the user's screen till dismissed.
274    Reminder,
275    /// This will be displayed pre-expanded in a special call format and stay on the user's screen till dismissed. Audio will loop by default and will use ringtone audio.
276    IncomingCall,
277}
278
279#[derive(Clone)]
280pub struct Progress {
281    /// Define a tag to uniquely identify the notification, in order update the notification data later.
282    pub tag: String,
283    /// Gets or sets an optional title string. Supports data binding.
284    pub title: String,
285    /// Gets or sets a status string (required), which is displayed underneath the progress bar on the left. This string should reflect the status of the operation, like "Downloading..." or "Installing..."
286    pub status: String,
287    /// Gets or sets the value of the progress bar. Supports data binding. Defaults to 0. Can either be a double between 0.0 and 1.0,
288    pub value: f32,
289    /// Gets or sets an optional string to be displayed instead of the default percentage string.
290    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    /// This can be used if you do not have a AppUserModelID.
325    ///
326    /// However, the toast will erroneously report its origin as powershell.
327    pub const POWERSHELL_APP_ID: &'static str = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\
328                                                 \\WindowsPowerShell\\v1.0\\powershell.exe";
329    /// Constructor for the toast builder.
330    ///
331    /// app_id is the running application's [AppUserModelID][1].
332    ///
333    /// [1]: https://msdn.microsoft.com/en-us/library/windows/desktop/dd378459(v=vs.85).aspx
334    ///
335    /// If the program you are using this in was not installed, use Toast::POWERSHELL_APP_ID for now
336    #[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    /// Sets the title of the toast.
355    ///
356    /// Will be white.
357    /// Supports Unicode ✓
358    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    /// Add/Sets the first line of text below title.
367    ///
368    /// Will be grey.
369    /// Supports Unicode ✓
370    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    /// Add/Sets the second line of text below title.
379    ///
380    /// Will be grey.
381    /// Supports Unicode ✓
382    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    /// Set the length of time to show the toast
391    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    /// Set the scenario of the toast
401    ///
402    /// The system keeps the notification on screen until the user acts upon/dismisses it.
403    /// The system also plays the suitable notification sound as well.
404    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    /// Set the icon shown in the upper left of the toast
416    ///
417    /// The default is determined by your app id.
418    /// If you are using the powershell workaround, it will be the powershell icon
419    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            // Win81 rejects the above xml, so we fall back to a simpler call
436            self.image(source, alt_text)
437        }
438    }
439
440    /// Add/Set a Hero image for the toast.
441    ///
442    /// This will be above the toast text and the icon.
443    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            // win81 rejects the above xml, so we fall back to a simpler call
454            self.image(source, alt_text)
455        }
456    }
457
458    /// Add an image to the toast
459    ///
460    /// May be done many times.
461    /// Will appear below text.
462    pub fn image(mut self, source: &Path, alt_text: &str) -> Toast {
463        if !is_newer_than_windows81() {
464            // win81 cannot have more than 1 image and shows nothing if there is more than that
465            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    /// Set the sound for the toast or silence it
477    ///
478    /// Default is [Sound::IM](enum.Sound.html)
479    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    /// Adds a button to the notification
498    /// `content` is the text of the button.
499    /// `action` will be sent as an argument [on_activated](Self::on_activated) when the button is clicked.
500    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    /// Set the progress for the toast
509    pub fn progress(mut self, progress: &Progress) -> Toast {
510        self.progress = Some(progress.clone());
511        self
512    }
513
514    // HACK: f is static so that we know the function is valid to call.
515    //       this would be nice to remove at some point
516    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    /// Set the function to be called when the toast is dismissed
541    /// `f` will be called with the reason the toast was dismissed.
542    /// If the toast was dismissed by the user, the reason will be `ToastDismissalReason::UserCanceled`.
543    /// If the toast was dismissed by the application, the reason will be `ToastDismissalReason::ApplicationHidden`.
544    /// If the toast was dismissed because it timed out, the reason will be `ToastDismissalReason::TimedOut`.
545    /// If the reason is unknown, the reason will be `None`.
546    ///
547    /// # Example
548    /// ```rust
549    /// use tauri_winrt_notification::{Toast, ToastDismissalReason};
550    ///
551    /// let toast = Toast::new(Toast::POWERSHELL_APP_ID);
552    /// toast.on_dismissed(|reason| {
553    ///     match reason {
554    ///         Some(ToastDismissalReason::UserCanceled) => println!("UserCanceled"),
555    ///         Some(ToastDismissalReason::ApplicationHidden) => println!("ApplicationHidden"),
556    ///         Some(ToastDismissalReason::TimedOut) => println!("TimedOut"),
557    ///         _ => println!("Unknown"),
558    ///     }
559    ///     Ok(())
560    /// }).show().expect("notification failed");
561    /// ```
562    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        //using this to get an instance of XmlDocument
586        let toast_xml = XmlDocument::new()?;
587
588        let template_binding = if is_newer_than_windows81() {
589            "ToastGeneric"
590        } else {
591            // Need to do this or an empty placeholder will be shown if no image is set
592            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        // Create the toast
642        ToastNotification::CreateToastNotification(&toast_xml).map_err(Into::into)
643    }
644
645    /// Update progress bar title, status, progress value, progress value string
646    /// If the notification update is successful, the reason will be `NotificationUpdateResult::Succeeded`.
647    /// If the update notification fails, the reason will be `NotificationUpdateResult::Failed`.
648    /// If no notification is found, the reason will be `NotificationUpdateResult::NotificationNotFound`.
649    ///
650    /// # Example
651    /// ```rust
652    /// use std::{thread::sleep, time::Duration as StdDuration};
653    /// use tauri_winrt_notification::{Toast, Progress};
654    ///
655    /// let mut progress = Progress {
656    ///     tag: "my_tag".to_string(),
657    ///     title: "video.mp4".to_string(),
658    ///     status: "Transferring files...".to_string(),
659    ///     value: 0.0,
660    ///     value_string: "0/1000 MB".to_string(),
661    /// };
662    ///
663    /// let toast = Toast::new(Toast::POWERSHELL_APP_ID).progress(&progress);
664    /// toast.show().expect("notification failed");
665    ///
666    /// for i in 1..=10 {
667    ///     sleep(StdDuration::from_secs(1));
668    ///
669    ///     progress.value = i as f32 / 10.0;
670    ///     progress.value_string = format!("{}/1000 MB", i * 100);
671    ///
672    ///     if i == 10 {
673    ///         progress.status = String::from("Completed!");
674    ///     };
675    ///
676    ///     toast.set_progress(&progress).expect("failed to set notification progress");
677    /// }
678    /// ```
679    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    /// Display the toast on the screen
700    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        // Show the toast.
731        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(Some(Sound::Loop(LoopableSound::Call)))
764            //.sound(Some(Sound::SMS))
765            .sound(None)
766            .show()
767            // silently consume errors
768            .expect("notification failed");
769    }
770}