use windows::{
core::{h, IInspectable, Interface},
Data::Xml::Dom::XmlDocument,
Foundation::{Collections::StringMap, TypedEventHandler},
UI::Notifications::{
NotificationData, ToastActivatedEventArgs, ToastDismissedEventArgs,
ToastNotificationManager,
},
};
use std::fmt::Display;
use std::path::Path;
use std::str::FromStr;
pub use windows::core::HSTRING;
pub use windows::UI::Notifications::NotificationUpdateResult;
pub use windows::UI::Notifications::ToastNotification;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("Windows API error: {0}")]
Os(#[from] windows::core::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
pub use windows::UI::Notifications::ToastDismissalReason;
pub struct Toast {
duration: Option<Duration>,
title: Option<String>,
line1: Option<String>,
line2: Option<String>,
images: Vec<Image>,
audio: Option<Sound>,
app_id: String,
progress: Option<Progress>,
scenario: Option<Scenario>,
on_activated: Option<TypedEventHandler<ToastNotification, IInspectable>>,
on_dismissed: Option<TypedEventHandler<ToastNotification, ToastDismissedEventArgs>>,
buttons: Vec<Button>,
}
#[derive(Clone, Copy)]
pub enum Duration {
Short,
Long,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Sound {
Default,
IM,
Mail,
Reminder,
SMS,
Single(LoopableSound),
Loop(LoopableSound),
}
impl TryFrom<&str> for Sound {
type Error = SoundParsingError;
fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
Self::from_str(value)
}
}
impl FromStr for Sound {
type Err = SoundParsingError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(match s {
"Default" => Sound::Default,
"IM" => Sound::IM,
"Mail" => Sound::Mail,
"Reminder" => Sound::Reminder,
"SMS" => Sound::SMS,
_ => Sound::Single(LoopableSound::from_str(s)?),
})
}
}
impl Display for Sound {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match &self {
Sound::Default => "Default",
Sound::IM => "IM",
Sound::Mail => "Mail",
Sound::Reminder => "Reminder",
Sound::SMS => "SMS",
Sound::Single(s) | Sound::Loop(s) => return write!(f, "{s}"),
}
)
}
}
struct Button {
content: String,
action: String,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LoopableSound {
Alarm,
Alarm2,
Alarm3,
Alarm4,
Alarm5,
Alarm6,
Alarm7,
Alarm8,
Alarm9,
Alarm10,
Call,
Call2,
Call3,
Call4,
Call5,
Call6,
Call7,
Call8,
Call9,
Call10,
}
#[derive(Debug)]
pub struct SoundParsingError;
impl Display for SoundParsingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "couldn't parse string as a valid sound")
}
}
impl std::error::Error for SoundParsingError {}
impl TryFrom<&str> for LoopableSound {
type Error = SoundParsingError;
fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
Self::from_str(value)
}
}
impl FromStr for LoopableSound {
type Err = SoundParsingError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(match s {
"Alarm" => LoopableSound::Alarm,
"Alarm2" => LoopableSound::Alarm2,
"Alarm3" => LoopableSound::Alarm3,
"Alarm4" => LoopableSound::Alarm4,
"Alarm5" => LoopableSound::Alarm5,
"Alarm6" => LoopableSound::Alarm6,
"Alarm7" => LoopableSound::Alarm7,
"Alarm8" => LoopableSound::Alarm8,
"Alarm9" => LoopableSound::Alarm9,
"Alarm10" => LoopableSound::Alarm10,
"Call" => LoopableSound::Call,
"Call2" => LoopableSound::Call2,
"Call3" => LoopableSound::Call3,
"Call4" => LoopableSound::Call4,
"Call5" => LoopableSound::Call5,
"Call6" => LoopableSound::Call6,
"Call7" => LoopableSound::Call7,
"Call8" => LoopableSound::Call8,
"Call9" => LoopableSound::Call9,
"Call10" => LoopableSound::Call10,
_ => return Err(SoundParsingError),
})
}
}
impl Display for LoopableSound {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
LoopableSound::Alarm => "Alarm",
LoopableSound::Alarm2 => "Alarm2",
LoopableSound::Alarm3 => "Alarm3",
LoopableSound::Alarm4 => "Alarm4",
LoopableSound::Alarm5 => "Alarm5",
LoopableSound::Alarm6 => "Alarm6",
LoopableSound::Alarm7 => "Alarm7",
LoopableSound::Alarm8 => "Alarm8",
LoopableSound::Alarm9 => "Alarm9",
LoopableSound::Alarm10 => "Alarm10",
LoopableSound::Call => "Call",
LoopableSound::Call2 => "Call2",
LoopableSound::Call3 => "Call3",
LoopableSound::Call4 => "Call4",
LoopableSound::Call5 => "Call5",
LoopableSound::Call6 => "Call6",
LoopableSound::Call7 => "Call7",
LoopableSound::Call8 => "Call8",
LoopableSound::Call9 => "Call9",
LoopableSound::Call10 => "Call10",
}
)
}
}
#[allow(dead_code)]
#[derive(Clone, Copy, PartialEq)]
pub enum IconCrop {
Square,
Circular,
}
#[allow(dead_code)]
#[derive(Clone, Copy)]
pub enum Scenario {
Default,
Alarm,
Reminder,
IncomingCall,
}
#[derive(Clone)]
pub struct Progress {
pub tag: String,
pub title: String,
pub status: String,
pub value: f32,
pub value_string: String,
}
impl Progress {
fn tag(&self) -> HSTRING {
HSTRING::from(&self.tag)
}
fn title(&self) -> HSTRING {
HSTRING::from(&self.title)
}
fn status(&self) -> HSTRING {
HSTRING::from(&self.status)
}
fn value(&self) -> HSTRING {
HSTRING::from(&self.value.to_string())
}
fn value_string(&self) -> HSTRING {
HSTRING::from(&self.value_string)
}
}
struct Image {
source: String,
alt_text: String,
placement: Option<String>,
crop_type: Option<IconCrop>,
}
impl Toast {
pub const POWERSHELL_APP_ID: &'static str = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\
\\WindowsPowerShell\\v1.0\\powershell.exe";
#[allow(dead_code)]
pub fn new(app_id: &str) -> Toast {
Toast {
duration: None,
title: None,
line1: None,
line2: None,
images: Vec::new(),
audio: None,
app_id: app_id.to_string(),
progress: None,
scenario: None,
on_activated: None,
on_dismissed: None,
buttons: Vec::new(),
}
}
pub fn title(mut self, content: &str) -> Toast {
self.title = Some(content.to_string());
self
}
pub fn text1(mut self, content: &str) -> Toast {
self.line1 = Some(content.to_string());
self
}
pub fn text2(mut self, content: &str) -> Toast {
self.line2 = Some(content.to_string());
self
}
pub fn duration(mut self, duration: Duration) -> Toast {
self.duration = Some(duration);
self
}
pub fn scenario(mut self, scenario: Scenario) -> Toast {
self.scenario = Some(scenario);
self
}
pub fn icon(mut self, source: &Path, crop: IconCrop, alt_text: &str) -> Toast {
if is_newer_than_windows81() {
self.images.push(Image {
source: source.display().to_string(),
alt_text: alt_text.to_string(),
placement: Some("appLogoOverride".to_string()),
crop_type: Some(crop),
});
self
} else {
self.image(source, alt_text)
}
}
pub fn hero(mut self, source: &Path, alt_text: &str) -> Toast {
if is_newer_than_windows81() {
self.images.push(Image {
source: source.display().to_string(),
alt_text: alt_text.to_string(),
placement: Some("Hero".to_string()),
crop_type: None,
});
self
} else {
self.image(source, alt_text)
}
}
pub fn image(mut self, source: &Path, alt_text: &str) -> Toast {
if !is_newer_than_windows81() {
self.images.clear();
}
self.images.push(Image {
source: source.display().to_string(),
alt_text: alt_text.to_string(),
placement: None,
crop_type: None,
});
self
}
pub fn sound(mut self, src: Option<Sound>) -> Toast {
self.audio = src;
self
}
pub fn add_button(mut self, content: &str, action: &str) -> Toast {
self.buttons.push(Button {
content: content.to_owned(),
action: action.to_owned(),
});
self
}
pub fn progress(mut self, progress: &Progress) -> Toast {
self.progress = Some(progress.clone());
self
}
pub fn on_activated<F>(mut self, f: F) -> Self
where
F: Fn(Option<String>) -> Result<()> + Send + 'static,
{
self.on_activated = Some(TypedEventHandler::new(move |_, insp| {
let _ = f(Self::get_activated_action(&insp));
Ok(())
}));
self
}
fn get_activated_action(insp: &Option<IInspectable>) -> Option<String> {
if let Some(insp) = insp {
if let Ok(args) = insp.cast::<ToastActivatedEventArgs>() {
if let Ok(arguments) = args.Arguments() {
if !arguments.is_empty() {
return Some(arguments.to_string());
}
}
}
}
None
}
pub fn on_dismissed<F>(mut self, f: F) -> Self
where
F: Fn(Option<ToastDismissalReason>) -> Result<()> + Send + 'static,
{
self.on_dismissed = Some(TypedEventHandler::new(move |_, args| {
let _ = f(Self::get_dismissed_reason(&args));
Ok(())
}));
self
}
fn get_dismissed_reason(
args: &Option<ToastDismissedEventArgs>,
) -> Option<ToastDismissalReason> {
if let Some(args) = args {
if let Ok(reason) = args.Reason() {
return Some(reason);
}
}
None
}
fn create_template(&self) -> Result<ToastNotification> {
let xml_doc = XmlDocument::new()?;
let template_binding = if is_newer_than_windows81() {
h!("ToastGeneric")
} else {
if self.images.is_empty() {
h!("ToastText04")
} else {
h!("ToastImageAndText04")
}
};
let xml_el_toast = xml_doc.CreateElement(h!("toast"))?;
let xml_el_visual = xml_doc.CreateElement(h!("visual"))?;
let xml_el_binding = xml_doc.CreateElement(h!("binding"))?;
if let Some(duration) = self.duration {
let duration = match duration {
Duration::Long => h!("long"),
Duration::Short => h!("short"),
};
xml_el_toast.SetAttribute(h!("duration"), duration)?;
}
if let Some(scenario) = self.scenario {
let scenario = match scenario {
Scenario::Default => h!(""),
Scenario::Alarm => h!("alarm"),
Scenario::Reminder => h!("reminder"),
Scenario::IncomingCall => h!("incomingCall"),
};
xml_el_toast.SetAttribute(h!("scenario"), scenario)?;
}
xml_el_binding.SetAttribute(h!("template"), template_binding)?;
for image in &self.images {
let xml_el = xml_doc.CreateElement(h!("image"))?;
xml_el.SetAttribute(h!("id"), h!("1"))?;
xml_el.SetAttribute(
h!("src"),
&format!("file:///{}", image.source.clone()).into(),
)?;
xml_el.SetAttribute(h!("alt"), &HSTRING::from(&image.alt_text))?;
if let Some(placement) = &image.placement {
xml_el.SetAttribute(h!("placement"), &placement.into())?;
}
if let Some(crop_type) = image.crop_type {
if crop_type == IconCrop::Circular {
xml_el.SetAttribute(h!("hint-crop"), h!("circle"))?;
}
};
xml_el_binding.AppendChild(&xml_el)?;
}
if let Some(title) = &self.title {
let xml_el = xml_doc.CreateElement(h!("text"))?;
xml_el.SetAttribute(h!("id"), h!("1"))?;
xml_el.SetInnerText(&title.into())?;
xml_el_binding.AppendChild(&xml_el)?;
}
if let Some(line1) = &self.line1 {
let xml_el = xml_doc.CreateElement(h!("text"))?;
xml_el.SetAttribute(h!("id"), h!("2"))?;
xml_el.SetInnerText(&line1.into())?;
xml_el_binding.AppendChild(&xml_el)?;
}
if let Some(line2) = &self.line2 {
let xml_el = xml_doc.CreateElement(h!("text"))?;
xml_el.SetAttribute(h!("id"), h!("3"))?;
xml_el.SetInnerText(&line2.into())?;
xml_el_binding.AppendChild(&xml_el)?;
}
if let Some(progress) = &self.progress {
let xml_el = xml_doc.CreateElement(h!("progress"))?;
xml_el.SetAttribute(h!("title"), &progress.title())?;
xml_el.SetAttribute(h!("value"), &progress.value())?;
xml_el.SetAttribute(h!("valueStringOverride"), &progress.value_string())?;
xml_el.SetAttribute(h!("status"), &progress.status())?;
xml_el_binding.AppendChild(&xml_el)?;
}
xml_el_visual.AppendChild(&xml_el_binding)?;
xml_el_toast.AppendChild(&xml_el_visual)?;
if let Some(sound) = self.audio {
if sound != Sound::Default {
let xml_el = xml_doc.CreateElement(h!("progress"))?;
match sound {
Sound::Default => unreachable!(),
Sound::Single(loopable_sound) => {
xml_el.SetAttribute(
h!("src"),
&format!("ms-winsoundevent:Notification.Looping.{loopable_sound}")
.into(),
)?;
}
Sound::Loop(loopable_sound) => {
xml_el.SetAttribute(
h!("src"),
&format!("ms-winsoundevent:Notification.Looping.{loopable_sound}")
.into(),
)?;
xml_el.SetAttribute(h!("loop"), h!("true"))?;
}
_ => {
xml_el.SetAttribute(
h!("src"),
&format!("ms-winsoundevent:Notification.{sound}").into(),
)?;
}
}
xml_el_toast.AppendChild(&xml_el)?;
}
} else {
let xml_el = xml_doc.CreateElement(h!("audio"))?;
xml_el.SetAttribute(h!("silent"), h!("true"))?;
xml_el_toast.AppendChild(&xml_el)?;
}
if !self.buttons.is_empty() {
let xml_el_actions = xml_doc.CreateElement(h!("actions"))?;
for button in &self.buttons {
let xml_el = xml_doc.CreateElement(h!("action"))?;
xml_el.SetAttribute(h!("content"), &HSTRING::from(&button.content))?;
xml_el.SetAttribute(h!("arguments"), &HSTRING::from(&button.action))?;
}
xml_el_toast.AppendChild(&xml_el_actions)?;
}
xml_doc.AppendChild(&xml_el_toast)?;
ToastNotification::CreateToastNotification(&xml_doc).map_err(Into::into)
}
pub fn set_progress(&self, progress: &Progress) -> Result<NotificationUpdateResult> {
let map = StringMap::new()?;
map.Insert(h!("progressTitle"), &progress.title())?;
map.Insert(h!("progressStatus"), &progress.status())?;
map.Insert(h!("progressValue"), &progress.value())?;
map.Insert(h!("progressValueString"), &progress.value_string())?;
let data = NotificationData::CreateNotificationDataWithValuesAndSequenceNumber(&map, 2)?;
let toast_notifier =
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(&self.app_id))?;
toast_notifier
.UpdateWithTag(&data, &progress.tag())
.map_err(Into::into)
}
pub fn show(&self) -> Result<()> {
let toast_template = self.create_template()?;
if let Some(handler) = &self.on_activated {
toast_template.Activated(handler)?;
}
if let Some(handler) = &self.on_dismissed {
toast_template.Dismissed(handler)?;
}
let toast_notifier =
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(&self.app_id))?;
if let Some(progress) = &self.progress {
toast_template.SetTag(&progress.tag())?;
let map = StringMap::new()?;
map.Insert(h!("progressTitle"), &progress.title())?;
map.Insert(h!("progressStatus"), &progress.status())?;
map.Insert(h!("progressValue"), &progress.value())?;
map.Insert(h!("progressValueString"), &progress.value_string())?;
let data =
NotificationData::CreateNotificationDataWithValuesAndSequenceNumber(&map, 1)?;
toast_template.SetData(&data)?;
}
let result = toast_notifier.Show(&toast_template).map_err(Into::into);
std::thread::sleep(std::time::Duration::from_millis(10));
result
}
}
fn is_newer_than_windows81() -> bool {
let os = windows_version::OsVersion::current();
os.major > 6
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_toast() {
let toast = Toast::new(Toast::POWERSHELL_APP_ID);
toast
.hero(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/flower.jpeg"),
"flower",
)
.icon(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/chick.jpeg"),
IconCrop::Circular,
"chicken",
)
.title("title")
.text1("line1")
.text2("line2")
.duration(Duration::Short)
.sound(None)
.show()
.expect("notification failed");
}
#[test]
fn create_template_xml_simple() {
let expected = r#"<toast><visual><binding template="ToastGeneric"/></visual><audio silent="true"/></toast>"#;
let toast = Toast::new(Toast::POWERSHELL_APP_ID);
let template = toast.create_template().unwrap();
assert_eq!(
template
.Content()
.unwrap()
.GetXml()
.unwrap()
.to_string_lossy(),
expected
);
}
#[test]
fn create_template_xml_audio_loop() {
let expected = r#"<toast><visual><binding template="ToastGeneric"/></visual><progress src="ms-winsoundevent:Notification.Looping.Call" loop="true"/></toast>"#;
let toast =
Toast::new(Toast::POWERSHELL_APP_ID).sound(Some(Sound::Loop(LoopableSound::Call)));
let template = toast.create_template().unwrap();
assert_eq!(
template
.Content()
.unwrap()
.GetXml()
.unwrap()
.to_string_lossy(),
expected
);
}
#[test]
fn create_template_xml_full() {
let img1 = &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/flower.jpeg");
let img2 = &Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/chick.jpeg");
let expected = format!(
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>"#,
img1.display(),
img2.display()
);
let toast = Toast::new(Toast::POWERSHELL_APP_ID)
.hero(img1, "flower")
.icon(img2, IconCrop::Circular, "chicken")
.title("title")
.text1("line1")
.text2("line2")
.duration(Duration::Short)
.sound(None);
let template = toast.create_template().unwrap();
assert_eq!(
template
.Content()
.unwrap()
.GetXml()
.unwrap()
.to_string_lossy(),
expected
);
}
}