//! P5-41 — Native WinRT toast notifications (+ P5-35 click-opens-room, P5-41 quick reply). //! //! The web notification bridge calls `show_rich_toast` (see lib.rs //! `NOTIFICATION_BRIDGE`) instead of the basic plugin notification so desktop //! notifications gain a text reply box and a body-click that reopens the room. //! //! Windows: we build a `Windows.UI.Notifications.ToastNotification` from a toast //! XML document (`Windows.Data.Xml.Dom.XmlDocument`) carrying the title + body, //! an inline `` and a Send ``. Because the //! app lives in the tray (always alive) we subscribe to the toast's **in-process** //! `Activated` event rather than relying on COM activation: the handler downcasts //! the event args to `ToastActivatedEventArgs`, reads the reply text from //! `UserInput()` (keyed `"reply"`) and forwards it to the web client. A body click //! (no reply text) forwards the launch `path` so the web side can route to the //! room. Live `ToastNotification` objects are parked in a process-global `Vec` //! (behind a `Mutex`) so their handlers survive until the toast is dismissed. //! //! If ANY WinRT step fails (most importantly: no registered AppUserModelID — see //! the runtime note below), we fall back to the plain `tauri-plugin-notification` //! notification so notifications always work. //! //! Other platforms always take the fallback path; the command keeps an identical //! cross-platform signature so the web bridge stays unconditional. //! //! RUNTIME NOTE (AppUserModelID): WinRT toasts require the process to run under an //! AppUserModelID that maps to a Start-menu shortcut. The installed app's bundle //! id is `org.lotusguild.lotus-chat`; if no matching shortcut/AUMID is registered, //! `CreateToastNotifier()` / `Show()` will error and we silently fall back. Wiring //! `SetCurrentProcessExplicitAppUserModelID` (+ shortcut install) is handled //! separately. use tauri::AppHandle; /// Show a rich desktop notification. On Windows this is a WinRT toast with a /// reply box and click-to-open; elsewhere (or on any WinRT error) it degrades to /// a basic plugin notification. `room_id` is the raw Matrix room id used for the /// reply payload; `path` is the web hash route used for a body click. #[tauri::command] pub fn show_rich_toast( app: AppHandle, title: String, body: Option, room_id: Option, path: Option, ) -> Result<(), String> { #[cfg(target_os = "windows")] { match show_windows_toast( &app, &title, body.as_deref(), room_id.as_deref(), path.as_deref(), ) { Ok(()) => return Ok(()), Err(err) => { // Most commonly a missing AppUserModelID (see module note). Fall // through to the plugin notification so the user still sees it. eprintln!("toast: WinRT toast failed, falling back to plugin: {err:?}"); } } } // Bind the routing args so the signature is identical cross-platform and no // unused warnings fire on the fallback (non-Windows) path. let _ = (&room_id, &path); show_fallback(&app, &title, body.as_deref()) } /// Cross-platform fallback: a basic notification via `tauri-plugin-notification` /// (mirrors `send_notification` in lib.rs). Used off Windows and whenever the /// WinRT toast path errors. fn show_fallback(app: &AppHandle, title: &str, body: Option<&str>) -> Result<(), String> { use tauri_plugin_notification::NotificationExt; let mut builder = app.notification().builder().title(title); if let Some(b) = body { builder = builder.body(b); } builder.show().map_err(|e| e.to_string()) } /// Process-global store keeping live `ToastNotification` objects (and therefore /// their `Activated`/`Dismissed` handler registrations) alive until dismissed. /// Lazily initialized so no `native::setup()` wiring is required. #[cfg(target_os = "windows")] fn toast_store() -> &'static std::sync::Mutex> { static STORE: std::sync::OnceLock< std::sync::Mutex>, > = std::sync::OnceLock::new(); STORE.get_or_init(|| std::sync::Mutex::new(Vec::new())) } /// Escape text for inclusion in the toast XML (attribute or element content). #[cfg(target_os = "windows")] fn xml_escape(input: &str) -> String { input .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } #[cfg(target_os = "windows")] fn show_windows_toast( app: &AppHandle, title: &str, body: Option<&str>, room_id: Option<&str>, path: Option<&str>, ) -> windows::core::Result<()> { use windows::core::{HSTRING, IInspectable, Interface}; use windows::Data::Xml::Dom::XmlDocument; use windows::Foundation::TypedEventHandler; use windows::UI::Notifications::{ ToastActivatedEventArgs, ToastDismissedEventArgs, ToastNotification, ToastNotificationManager, }; // A body click carries the launch arguments back to us; prefer the web hash // route (`path`), falling back to the raw room id so clicks are never inert. let launch = path.or(room_id).unwrap_or_default(); let body_line = match body { Some(b) if !b.is_empty() => format!("{}", xml_escape(b)), _ => String::new(), }; // ToastGeneric visual + an inline reply input and a foreground Send action. // `hint-inputId="reply"` binds the Send button to the text box so the reply // text arrives in `UserInput()` keyed "reply". let xml = format!( r#" {title} {body_line} "#, launch = xml_escape(launch), title = xml_escape(title), body_line = body_line, ); let doc = XmlDocument::new()?; doc.LoadXml(&HSTRING::from(xml))?; let toast = ToastNotification::CreateToastNotification(&doc)?; // In-process activation: the app is always alive in the tray, so we handle // clicks/replies directly instead of via COM activation. let app_activated = app.clone(); let room_id_owned = room_id.map(|s| s.to_string()); let path_owned = path.map(|s| s.to_string()); let activated = TypedEventHandler::::new( move |_sender, args| { let Some(args) = args.as_ref() else { return Ok(()); }; let Ok(activated_args) = args.cast::() else { return Ok(()); }; // Extract the reply text (if the Send action / input was used). let reply = read_reply(&activated_args).unwrap_or_default(); if !reply.is_empty() { // Quick reply: forward the room id + text to the web client. let payload = serde_json::json!({ "roomId": room_id_owned.as_deref(), "text": reply, }) .to_string(); super::emit_to_web(&app_activated, "lotus-notification-reply", &payload); } else { // Plain body click: forward the launch path so the web routes to it. let payload = serde_json::json!({ "path": path_owned.as_deref(), }) .to_string(); super::emit_to_web(&app_activated, "lotus-notification-activate", &payload); } Ok(()) }, ); let _ = toast.Activated(&activated)?; // Prune the store once the toast leaves the action center so we don't leak // handler registrations for the app's lifetime. let dismissed = TypedEventHandler::::new( move |sender, _args| { if let Some(sender) = sender.as_ref() { if let Ok(mut store) = toast_store().lock() { store.retain(|t| t != sender); } } Ok(()) }, ); let _ = toast.Dismissed(&dismissed)?; // Keep the toast (and its handlers) alive until dismissed. if let Ok(mut store) = toast_store().lock() { store.push(toast.clone()); } // No AUMID argument: relies on the process AppUserModelID (see module note). let notifier = ToastNotificationManager::CreateToastNotifier()?; notifier.Show(&toast)?; Ok(()) } /// Read the quick-reply text from a toast activation. Returns `None` when the /// toast was activated without submitting the "reply" input (a plain click). #[cfg(target_os = "windows")] fn read_reply( args: &windows::UI::Notifications::ToastActivatedEventArgs, ) -> Option { use windows::core::{HSTRING, IInspectable, Interface}; use windows::Foundation::Collections::IMap; use windows::Foundation::IReference; // UserInput() is an IPropertySet (IMap); the text input value // is boxed as an IReference. let inputs: IMap = args.UserInput().ok()?.cast().ok()?; let key = HSTRING::from("reply"); if !inputs.HasKey(&key).ok()? { return None; } let value = inputs.Lookup(&key).ok()?; let reference: IReference = value.cast().ok()?; let text = reference.Value().ok()?.to_string(); if text.is_empty() { None } else { Some(text) } }