use windows::{
core::{IInspectable, Interface},
Data::Xml::Dom::XmlDocument,
Foundation::TypedEventHandler,
UI::Notifications::{ToastActivatedEventArgs, ToastNotificationManager},
};
use std::fmt::Display;
use std::fmt::Write;
use std::path::Path;
use std::str::FromStr;
pub use windows::core::{Error, Result, HSTRING};
pub use windows::UI::Notifications::ToastNotification;
pub struct Toast {
duration: String,
title: String,
line1: String,
line2: String,
images: String,
audio: String,
app_id: String,
scenario: String,
on_activated: Option<TypedEventHandler<ToastNotification, IInspectable>>,
buttons: Vec<Button>,
}
#[derive(Clone, Copy)]
pub enum Duration {
Short,
Long,
}
#[derive(Debug, Clone, Copy)]
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)]
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)]
pub enum IconCrop {
Square,
Circular,
}
#[allow(dead_code)]
#[derive(Clone, Copy)]
pub enum Scenario {
Default,
Alarm,
Reminder,
IncomingCall,
}
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: String::new(),
title: String::new(),
line1: String::new(),
line2: String::new(),
images: String::new(),
audio: String::new(),
app_id: app_id.to_string(),
scenario: String::new(),
on_activated: None,
buttons: Vec::new(),
}
}
pub fn title(mut self, content: &str) -> Toast {
self.title = format!(
r#"<text id="1">{}</text>"#,
&quick_xml::escape::escape(content)
);
self
}
pub fn text1(mut self, content: &str) -> Toast {
self.line1 = format!(
r#"<text id="2">{}</text>"#,
&quick_xml::escape::escape(content)
);
self
}
pub fn text2(mut self, content: &str) -> Toast {
self.line2 = format!(
r#"<text id="3">{}</text>"#,
&quick_xml::escape::escape(content)
);
self
}
pub fn duration(mut self, duration: Duration) -> Toast {
self.duration = match duration {
Duration::Long => "duration=\"long\"",
Duration::Short => "duration=\"short\"",
}
.to_owned();
self
}
pub fn scenario(mut self, scenario: Scenario) -> Toast {
self.scenario = match scenario {
Scenario::Default => "",
Scenario::Alarm => "scenario=\"alarm\"",
Scenario::Reminder => "scenario=\"reminder\"",
Scenario::IncomingCall => "scenario=\"incomingCall\"",
}
.to_owned();
self
}
pub fn icon(mut self, source: &Path, crop: IconCrop, alt_text: &str) -> Toast {
if is_newer_than_windows81() {
let crop_type_attr = match crop {
IconCrop::Square => "".to_string(),
IconCrop::Circular => "hint-crop=\"circle\"".to_string(),
};
self.images = format!(
r#"{}<image placement="appLogoOverride" {} src="file:///{}" alt="{}" />"#,
self.images,
crop_type_attr,
quick_xml::escape::escape(&source.display().to_string()),
quick_xml::escape::escape(alt_text)
);
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 = format!(
r#"{}<image placement="Hero" src="file:///{}" alt="{}" />"#,
self.images,
quick_xml::escape::escape(&source.display().to_string()),
quick_xml::escape::escape(alt_text)
);
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 = "".to_owned();
}
self.images = format!(
r#"{}<image id="1" src="file:///{}" alt="{}" />"#,
self.images,
quick_xml::escape::escape(&source.display().to_string()),
quick_xml::escape::escape(alt_text)
);
self
}
pub fn sound(mut self, src: Option<Sound>) -> Toast {
self.audio = match src {
None => "<audio silent=\"true\" />".to_owned(),
Some(Sound::Default) => "".to_owned(),
Some(Sound::Loop(sound)) => format!(
r#"<audio loop="true" src="ms-winsoundevent:Notification.Looping.{}" />"#,
sound
),
Some(Sound::Single(sound)) => format!(
r#"<audio src="ms-winsoundevent:Notification.Looping.{}" />"#,
sound
),
Some(sound) => format!(r#"<audio src="ms-winsoundevent:Notification.{}" />"#, sound),
};
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 on_activated<F: FnMut(Option<String>) -> Result<()> + Send + 'static>(
mut self,
mut f: F,
) -> Self {
self.on_activated = Some(TypedEventHandler::new(move |_, insp| {
f(Self::get_activated_action(insp))
}));
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
}
fn create_template(&self) -> windows::core::Result<ToastNotification> {
let toast_xml = XmlDocument::new()?;
let template_binding = if is_newer_than_windows81() {
"ToastGeneric"
} else {
if self.images.is_empty() {
"ToastText04"
} else {
"ToastImageAndText04"
}
};
let mut actions = String::new();
if !self.buttons.is_empty() {
let _ = write!(actions, "<actions>");
for b in &self.buttons {
let _ = write!(
actions,
"<action content='{}' arguments='{}'/>",
b.content, b.action
);
}
let _ = write!(actions, "</actions>");
}
toast_xml.LoadXml(&HSTRING::from(format!(
"<toast {} {}>
<visual>
<binding template=\"{}\">
{}
{}{}{}
</binding>
</visual>
{}
{}
</toast>",
self.duration,
self.scenario,
template_binding,
self.images,
self.title,
self.line1,
self.line2,
self.audio,
actions
)))?;
ToastNotification::CreateToastNotification(&toast_xml)
}
pub fn show(&self) -> windows::core::Result<()> {
let toast_template = self.create_template()?;
if let Some(handler) = &self.on_activated {
toast_template.Activated(handler)?;
}
let toast_notifier =
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(&self.app_id))?;
let result = toast_notifier.Show(&toast_template);
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");
}
}