Skip to main content

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>
34
35use 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
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: 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    /// 7 seconds
91    Short,
92
93    /// 25 seconds
94    Long,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq)]
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, 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    /// 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 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    // Win 10+
319    placement: Option<String>,
320    // Win 10+
321    crop_type: Option<IconCrop>,
322}
323
324impl Toast {
325    /// This can be used if you do not have a AppUserModelID.
326    ///
327    /// However, the toast will erroneously report its origin as powershell.
328    pub const POWERSHELL_APP_ID: &'static str = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\
329                                                 \\WindowsPowerShell\\v1.0\\powershell.exe";
330    /// Constructor for the toast builder.
331    ///
332    /// app_id is the running application's [AppUserModelID][1].
333    ///
334    /// [1]: https://msdn.microsoft.com/en-us/library/windows/desktop/dd378459(v=vs.85).aspx
335    ///
336    /// If the program you are using this in was not installed, use Toast::POWERSHELL_APP_ID for now
337    #[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    /// Sets the title of the toast.
356    ///
357    /// Will be white.
358    /// Supports Unicode ✓
359    pub fn title(mut self, content: &str) -> Toast {
360        self.title = Some(content.to_string());
361        self
362    }
363
364    /// Add/Sets the first line of text below title.
365    ///
366    /// Will be grey.
367    /// Supports Unicode ✓
368    pub fn text1(mut self, content: &str) -> Toast {
369        self.line1 = Some(content.to_string());
370        self
371    }
372
373    /// Add/Sets the second line of text below title.
374    ///
375    /// Will be grey.
376    /// Supports Unicode ✓
377    pub fn text2(mut self, content: &str) -> Toast {
378        self.line2 = Some(content.to_string());
379        self
380    }
381
382    /// Set the length of time to show the toast
383    pub fn duration(mut self, duration: Duration) -> Toast {
384        self.duration = Some(duration);
385        self
386    }
387
388    /// Set the scenario of the toast
389    ///
390    /// The system keeps the notification on screen until the user acts upon/dismisses it.
391    /// The system also plays the suitable notification sound as well.
392    pub fn scenario(mut self, scenario: Scenario) -> Toast {
393        self.scenario = Some(scenario);
394        self
395    }
396
397    /// Set the icon shown in the upper left of the toast
398    ///
399    /// Source paths must be absolute and not contain a UNC prefix.
400    ///
401    /// The default is determined by your app id.
402    /// If you are using the powershell workaround, it will be the powershell icon
403    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            // Win81 rejects the above xml, so we fall back to a simpler call
414            self.image(source, alt_text)
415        }
416    }
417
418    /// Add/Set a Hero image for the toast.
419    ///
420    /// Source paths must be absolute and not contain a UNC prefix.
421    ///
422    /// This will be above the toast text and the icon.
423    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            // win81 rejects the above xml, so we fall back to a simpler call
434            self.image(source, alt_text)
435        }
436    }
437
438    /// Add an image to the toast.
439    ///
440    /// Source paths must be absolute and not contain a UNC prefix.
441    ///
442    /// May be done many times.
443    /// Will appear below text.
444    pub fn image(mut self, source: &Path, alt_text: &str) -> Toast {
445        if !is_newer_than_windows81() {
446            // win81 cannot have more than 1 image and shows nothing if there is more than that
447            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    /// Set the sound for the toast or silence it
459    ///
460    /// Default is [Sound::IM](enum.Sound.html)
461    pub fn sound(mut self, src: Option<Sound>) -> Toast {
462        self.audio = src;
463        self
464    }
465
466    /// Adds a button to the notification
467    /// `content` is the text of the button.
468    /// `action` will be sent as an argument [on_activated](Self::on_activated) when the button is clicked.
469    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    /// Set the progress for the toast
478    pub fn progress(mut self, progress: &Progress) -> Toast {
479        self.progress = Some(progress.clone());
480        self
481    }
482
483    // HACK: f is static so that we know the function is valid to call.
484    //       this would be nice to remove at some point
485    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    /// Set the function to be called when the toast is dismissed
510    /// `f` will be called with the reason the toast was dismissed.
511    /// If the toast was dismissed by the user, the reason will be `ToastDismissalReason::UserCanceled`.
512    /// If the toast was dismissed by the application, the reason will be `ToastDismissalReason::ApplicationHidden`.
513    /// If the toast was dismissed because it timed out, the reason will be `ToastDismissalReason::TimedOut`.
514    /// If the reason is unknown, the reason will be `None`.
515    ///
516    /// # Example
517    /// ```rust
518    /// use tauri_winrt_notification::{Toast, ToastDismissalReason};
519    ///
520    /// let toast = Toast::new(Toast::POWERSHELL_APP_ID);
521    /// toast.on_dismissed(|reason| {
522    ///     match reason {
523    ///         Some(ToastDismissalReason::UserCanceled) => println!("UserCanceled"),
524    ///         Some(ToastDismissalReason::ApplicationHidden) => println!("ApplicationHidden"),
525    ///         Some(ToastDismissalReason::TimedOut) => println!("TimedOut"),
526    ///         _ => println!("Unknown"),
527    ///     }
528    ///     Ok(())
529    /// }).show().expect("notification failed");
530    /// ```
531    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            // Need to do this or an empty placeholder will be shown if no image is set
560            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            // This doesn't seem to be required. Also setting the same ID for all images sounds wrong but is kept to keep behavior unchanged.
594            // It may be required in Windows 8.
595            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        // audio
647        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        // Create the toast
695        ToastNotification::CreateToastNotification(&xml_doc).map_err(Into::into)
696    }
697
698    /// Update progress bar title, status, progress value, progress value string
699    /// If the notification update is successful, the reason will be `NotificationUpdateResult::Succeeded`.
700    /// If the update notification fails, the reason will be `NotificationUpdateResult::Failed`.
701    /// If no notification is found, the reason will be `NotificationUpdateResult::NotificationNotFound`.
702    ///
703    /// # Example
704    /// ```rust
705    /// use std::{thread::sleep, time::Duration as StdDuration};
706    /// use tauri_winrt_notification::{Toast, Progress};
707    ///
708    /// let mut progress = Progress {
709    ///     tag: "my_tag".to_string(),
710    ///     title: "video.mp4".to_string(),
711    ///     status: "Transferring files...".to_string(),
712    ///     value: 0.0,
713    ///     value_string: "0/1000 MB".to_string(),
714    /// };
715    ///
716    /// let toast = Toast::new(Toast::POWERSHELL_APP_ID).progress(&progress);
717    /// toast.show().expect("notification failed");
718    ///
719    /// for i in 1..=10 {
720    ///     sleep(StdDuration::from_secs(1));
721    ///
722    ///     progress.value = i as f32 / 10.0;
723    ///     progress.value_string = format!("{}/1000 MB", i * 100);
724    ///
725    ///     if i == 10 {
726    ///         progress.status = String::from("Completed!");
727    ///     };
728    ///
729    ///     toast.set_progress(&progress).expect("failed to set notification progress");
730    /// }
731    /// ```
732    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    /// Display the toast on the screen
750    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        // Show the toast.
778        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(Some(Sound::Loop(LoopableSound::Call)))
811            //.sound(Some(Sound::SMS))
812            .sound(None)
813            .show()
814            // silently consume errors
815            .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}