diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e45218d..8205261 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,9 +48,12 @@ tauri-plugin-single-instance = "2" webview2-com = "0.38" window-vibrancy = "0.6" windows = { version = "0.61", features = [ - # WinRT namespaces (SMTC — P5-43) + # WinRT namespaces + "Data_Xml_Dom", # P5-41 toast XML "Foundation", + "Foundation_Collections", # P5-41 toast UserInput IMap "Media", + "UI_Notifications", # P5-41 WinRT toast notifications # Win32 namespaces "Win32_Foundation", "Win32_Graphics_Gdi", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7474b02..4cb68b1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -51,10 +51,21 @@ const NOTIFICATION_BRIDGE: &str = r#"(function(){ function TauriNotification(title,options){ var opts=options||{}; try{ - window.__TAURI_INTERNALS__.invoke('send_notification',{ - title:String(title), - body:opts.body!=null?String(opts.body):undefined - }).catch(function(){}); + var body=opts.body!=null?String(opts.body):undefined; + // cinny tags message notifications with the roomId (options.tag) and the + // in-app route (options.data.path). When present, route to the rich WinRT + // toast (click-opens-room + quick reply); otherwise a plain toast. + var roomId=opts.tag!=null?String(opts.tag):undefined; + var path=(opts.data&&opts.data.path!=null)?String(opts.data.path):undefined; + if(roomId){ + window.__TAURI_INTERNALS__.invoke('show_rich_toast',{ + title:String(title),body:body,roomId:roomId,path:path + }).catch(function(){}); + }else{ + window.__TAURI_INTERNALS__.invoke('send_notification',{ + title:String(title),body:body + }).catch(function(){}); + } }catch(_){} } TauriNotification.prototype=Object.create(EventTarget.prototype); @@ -397,6 +408,7 @@ pub fn run() { native::chrome::window_toggle_maximize, native::chrome::window_start_drag, native::chrome::window_close, + native::toast::show_rich_toast, ]) .plugin(tauri_plugin_localhost::Builder::new(port).build()) .plugin(tauri_plugin_window_state::Builder::default().build()) diff --git a/src-tauri/src/native/focus_assist.rs b/src-tauri/src/native/focus_assist.rs new file mode 100644 index 0000000..bdeb6f8 --- /dev/null +++ b/src-tauri/src/native/focus_assist.rs @@ -0,0 +1,100 @@ +//! P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync. +//! +//! Mirrors the shell's own notification-suppression state so Lotus Chat stops +//! popping desktop notifications while the user is in Focus Assist / Quiet Hours, +//! presenting, gaming full-screen, or otherwise "busy". The web client keeps this +//! in a live jotai atom (`focusAssistActiveAtom`) that the notification gate reads +//! alongside its existing quiet-hours check. +//! +//! Windows: a lightweight background thread polls `SHQueryUserNotificationState` +//! (the same API the shell exposes for "should I show a toast right now?") every +//! ~5 seconds. We prefer a robust poll over hooking shell events — the poll is +//! trivial to reason about and a 5s cadence is more than responsive enough for a +//! notification-suppression hint. We emit **only on a boolean transition**, so the +//! web side gets one event per change rather than a steady heartbeat; the first +//! read always emits so the frontend learns the initial state. +//! +//! Other platforms are a no-op: there's no equivalent cross-platform signal, and +//! the web hook stays unconditional so nothing there needs guarding. + +use tauri::AppHandle; + +/// Payload for the `focus-assist-changed` DOM event (`{ active: bool }`). +#[cfg(target_os = "windows")] +#[derive(serde::Serialize)] +struct St { + active: bool, +} + +/// Called once from lib.rs `native::setup()`. On Windows, spawns the poll +/// thread; elsewhere it does nothing. +pub fn setup(app: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "windows")] + { + // Own a handle inside the thread so the poll outlives this call and runs + // for the lifetime of the app. + let app = app.clone(); + std::thread::spawn(move || watch_focus_assist(app)); + } + + #[cfg(not(target_os = "windows"))] + { + // No-op on non-Windows platforms (see module docs). Bind the arg so the + // signature stays identical cross-platform with no unused warning. + let _ = app; + } + + Ok(()) +} + +/// Poll loop, runs on its own thread for the app's lifetime. +#[cfg(target_os = "windows")] +fn watch_focus_assist(app: AppHandle) { + use std::time::Duration; + use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED}; + use windows::Win32::UI::Shell::{ + SHQueryUserNotificationState, QUNS_BUSY, QUNS_PRESENTATION_MODE, QUNS_QUIET_TIME, + QUNS_RUNNING_D3D_FULL_SCREEN, + }; + + // Initialize COM for this thread in the multithreaded apartment. This is a + // dedicated thread, so it should be the first to init and succeed (S_FALSE — + // "already initialized, same mode" — also counts as success). If it fails + // outright (e.g. RPC_E_CHANGED_MODE) we can't proceed, so bail. + // Safety: FFI call; `None` reserved param per the API contract. + if unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }.is_err() { + return; + } + + // `None` = unknown; the first successful read is treated as a transition so + // the web side always learns the initial suppression state. + let mut last: Option = None; + + loop { + // `SHQueryUserNotificationState` reports the shell's current + // notification-presentation state. Treat the states where the shell + // itself suppresses toasts as "focus/DND active". Skip transient read + // errors without emitting. + // Safety: FFI call; writes the state into the provided out-param. + if let Ok(state) = unsafe { SHQueryUserNotificationState() } { + let active = state == QUNS_QUIET_TIME + || state == QUNS_PRESENTATION_MODE + || state == QUNS_RUNNING_D3D_FULL_SCREEN + || state == QUNS_BUSY; + if last != Some(active) { + last = Some(active); + super::emit_to_web( + &app, + "focus-assist-changed", + &serde_json::to_string(&St { active }).unwrap_or_default(), + ); + } + } + + std::thread::sleep(Duration::from_secs(5)); + } + + // Note: the loop never returns, so we intentionally don't call + // `CoUninitialize` here — COM stays initialized for this thread until the + // process exits, which is exactly the desired lifetime. +} diff --git a/src-tauri/src/native/mod.rs b/src-tauri/src/native/mod.rs index fb2d6b3..a168079 100644 --- a/src-tauri/src/native/mod.rs +++ b/src-tauri/src/native/mod.rs @@ -9,11 +9,13 @@ use tauri::{AppHandle, Manager}; pub mod chrome; +pub mod focus_assist; pub mod jumplist; pub mod network; pub mod power; pub mod smtc; pub mod thumbbar; +pub mod toast; /// Dispatch a DOM `CustomEvent` to the web client (mirrors `forward_deeplink` in /// lib.rs) so native modules can push data to the frontend without pulling in @@ -35,5 +37,6 @@ pub fn setup(app: &AppHandle) -> tauri::Result<()> { thumbbar::setup(app)?; smtc::setup(app)?; network::setup(app)?; + focus_assist::setup(app)?; Ok(()) } diff --git a/src-tauri/src/native/toast.rs b/src-tauri/src/native/toast.rs new file mode 100644 index 0000000..f5f8fc0 --- /dev/null +++ b/src-tauri/src/native/toast.rs @@ -0,0 +1,244 @@ +//! 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) + } +}