use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct LocalNotificationArgs {
pub title: String,
pub body: String,
#[serde(default)]
pub subtitle: Option<String>,
#[serde(default)]
pub sound: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct LocalNotificationResult {
pub delivered: bool,
pub platform: &'static str,
pub backend: &'static str,
}
pub async fn local(args_json: &str) -> Result<String, String> {
let args: LocalNotificationArgs =
serde_json::from_str(args_json).map_err(|e| format!("invalid args: {e}"))?;
validate(&args)?;
#[cfg(target_os = "macos")]
{
deliver_macos(args).await?;
serde_json::to_string(&LocalNotificationResult {
delivered: true,
platform: "macos",
backend: "user-notification-apple-event",
})
.map_err(|e| format!("serialize: {e}"))
}
#[cfg(target_os = "ios")]
{
let _ = args;
return Err(
"local notifications on iOS must be delivered by the signed host app via UserNotifications"
.to_string(),
);
}
#[cfg(target_os = "windows")]
{
deliver_windows(&args)?;
return serde_json::to_string(&LocalNotificationResult {
delivered: true,
platform: "windows",
backend: "winrt-toast",
})
.map_err(|e| format!("serialize: {e}"));
}
#[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))]
{
let _ = args;
Err("local notifications are not supported on this platform".to_string())
}
}
fn validate(args: &LocalNotificationArgs) -> Result<(), String> {
if args.title.trim().is_empty() {
return Err("title is required".to_string());
}
if args.body.trim().is_empty() {
return Err("body is required".to_string());
}
Ok(())
}
#[cfg(target_os = "windows")]
const AUMID: &str = "ai.parslee.car";
#[cfg(target_os = "windows")]
fn deliver_windows(args: &LocalNotificationArgs) -> Result<(), String> {
use windows::core::HSTRING;
use windows::Data::Xml::Dom::XmlDocument;
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
use windows::UI::Notifications::{ToastNotification, ToastNotificationManager};
register_aumid().map_err(|e| format!("register AppUserModelId: {e}"))?;
unsafe {
let _ = SetCurrentProcessExplicitAppUserModelID(&HSTRING::from(AUMID));
}
let mut lines = format!("<text>{}</text>", xml_escape(&args.title));
if let Some(sub) = args.subtitle.as_deref() {
if !sub.trim().is_empty() {
lines.push_str(&format!("<text>{}</text>", xml_escape(sub)));
}
}
lines.push_str(&format!("<text>{}</text>", xml_escape(&args.body)));
let xml = format!(
"<toast><visual><binding template=\"ToastGeneric\">{lines}</binding></visual></toast>"
);
let doc = XmlDocument::new().map_err(|e| format!("XmlDocument: {e}"))?;
doc.LoadXml(&HSTRING::from(xml))
.map_err(|e| format!("LoadXml: {e}"))?;
let toast = ToastNotification::CreateToastNotification(&doc)
.map_err(|e| format!("CreateToastNotification: {e}"))?;
let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(AUMID))
.map_err(|e| format!("CreateToastNotifierWithId: {e}"))?;
notifier.Show(&toast).map_err(|e| format!("Show: {e}"))?;
Ok(())
}
#[cfg(target_os = "windows")]
fn register_aumid() -> Result<(), String> {
use windows::core::PCWSTR;
use windows::Win32::Foundation::ERROR_SUCCESS;
use windows::Win32::System::Registry::{
RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE,
REG_OPTION_NON_VOLATILE, REG_SZ,
};
let subkey = wide(&format!("Software\\Classes\\AppUserModelId\\{AUMID}"));
let mut hkey = HKEY::default();
let rc = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
PCWSTR(subkey.as_ptr()),
0,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
None,
&mut hkey,
None,
)
};
if rc != ERROR_SUCCESS {
return Err(format!("RegCreateKeyExW ({})", rc.0));
}
let name = wide("DisplayName");
let data = wide("CAR");
let data_bytes: &[u8] =
unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, data.len() * 2) };
let rc = unsafe { RegSetValueExW(hkey, PCWSTR(name.as_ptr()), 0, REG_SZ, Some(data_bytes)) };
unsafe {
let _ = RegCloseKey(hkey);
}
if rc != ERROR_SUCCESS {
return Err(format!("RegSetValueExW ({})", rc.0));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
#[cfg(target_os = "windows")]
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(all(test, target_os = "windows"))]
mod windows_tests {
use super::*;
#[test]
fn xml_escape_escapes_entities() {
assert_eq!(xml_escape("a & b <c>"), "a & b <c>");
}
#[tokio::test]
#[ignore = "shows a real toast; run explicitly on an interactive Windows session"]
async fn shows_a_real_toast() {
let json = serde_json::json!({
"title": "CAR",
"body": "Toast notifications work on Windows (car#522).",
"subtitle": "self-test",
})
.to_string();
let out = local(&json).await.expect("toast should deliver");
assert!(out.contains("winrt-toast"), "{out}");
}
}
#[cfg(target_os = "macos")]
async fn deliver_macos(args: LocalNotificationArgs) -> Result<(), String> {
let script = r#"
on run argv
set notificationTitle to item 1 of argv
set notificationBody to item 2 of argv
set notificationSubtitle to item 3 of argv
set notificationSound to item 4 of argv
if notificationSubtitle is "" and notificationSound is "" then
display notification notificationBody with title notificationTitle
else if notificationSubtitle is "" then
display notification notificationBody with title notificationTitle sound name notificationSound
else if notificationSound is "" then
display notification notificationBody with title notificationTitle subtitle notificationSubtitle
else
display notification notificationBody with title notificationTitle subtitle notificationSubtitle sound name notificationSound
end if
end run
"#;
let subtitle = args.subtitle.unwrap_or_default();
let sound = args.sound.unwrap_or_default();
let argv = [
args.title.as_str(),
args.body.as_str(),
subtitle.as_str(),
sound.as_str(),
];
car_automation::applescript::run_with_args(
script,
car_automation::applescript::Language::AppleScript,
&argv,
Some(std::time::Duration::from_secs(10)),
)
.await
.map(|_| ())
.map_err(|e| format!("{e}"))
}