245 lines
9.6 KiB
Rust
245 lines
9.6 KiB
Rust
|
|
//! 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 `<input id="reply" type="text"/>` and a Send `<action>`. 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<String>,
|
||
|
|
room_id: Option<String>,
|
||
|
|
path: Option<String>,
|
||
|
|
) -> 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<Vec<windows::UI::Notifications::ToastNotification>> {
|
||
|
|
static STORE: std::sync::OnceLock<
|
||
|
|
std::sync::Mutex<Vec<windows::UI::Notifications::ToastNotification>>,
|
||
|
|
> = 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!("<text>{}</text>", 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#"<toast activationType="foreground" launch="{launch}">
|
||
|
|
<visual>
|
||
|
|
<binding template="ToastGeneric">
|
||
|
|
<text>{title}</text>
|
||
|
|
{body_line}
|
||
|
|
</binding>
|
||
|
|
</visual>
|
||
|
|
<actions>
|
||
|
|
<input id="reply" type="text" placeHolder="Reply..."/>
|
||
|
|
<action content="Send" arguments="reply" activationType="foreground" hint-inputId="reply"/>
|
||
|
|
</actions>
|
||
|
|
</toast>"#,
|
||
|
|
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::<ToastNotification, IInspectable>::new(
|
||
|
|
move |_sender, args| {
|
||
|
|
let Some(args) = args.as_ref() else {
|
||
|
|
return Ok(());
|
||
|
|
};
|
||
|
|
let Ok(activated_args) = args.cast::<ToastActivatedEventArgs>() 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::<ToastNotification, ToastDismissedEventArgs>::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<String> {
|
||
|
|
use windows::core::{HSTRING, IInspectable, Interface};
|
||
|
|
use windows::Foundation::Collections::IMap;
|
||
|
|
use windows::Foundation::IReference;
|
||
|
|
|
||
|
|
// UserInput() is an IPropertySet (IMap<String, Object>); the text input value
|
||
|
|
// is boxed as an IReference<HSTRING>.
|
||
|
|
let inputs: IMap<HSTRING, IInspectable> = 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<HSTRING> = value.cast().ok()?;
|
||
|
|
let text = reference.Value().ok()?.to_string();
|
||
|
|
if text.is_empty() {
|
||
|
|
None
|
||
|
|
} else {
|
||
|
|
Some(text)
|
||
|
|
}
|
||
|
|
}
|