feat(native): Tier B — WinRT rich toasts (P5-41/35) + Focus Assist sync (P5-56)

- toast.rs: Windows.UI.Notifications rich toast (reply input + Send action);
  in-process Activated event → emit lotus-notification-activate {path} (click) /
  lotus-notification-reply {roomId,text}. Falls back to tauri-plugin-notification
  (WinRT error / non-Windows). The NOTIFICATION_BRIDGE now routes notifications
  carrying a roomId (tag) to show_rich_toast. Features: UI_Notifications,
  Data_Xml_Dom, Foundation_Collections.
- focus_assist.rs: SHQueryUserNotificationState poll thread → emit
  focus-assist-changed {active} on QUNS_QUIET_TIME/PRESENTATION/D3D_FULLSCREEN/BUSY.
  No new Cargo features.

CI Windows compile pending (no local Rust toolchain). Runtime caveat: WinRT toasts
need a Start-menu shortcut + matching AppUserModelID (org.lotusguild.lotus-chat);
without it CreateToastNotifier errors and the code falls back to the plugin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 10:01:31 -04:00
parent 73d15b13d2
commit a923650d02
5 changed files with 367 additions and 5 deletions
+4 -1
View File
@@ -48,9 +48,12 @@ tauri-plugin-single-instance = "2"
webview2-com = "0.38" webview2-com = "0.38"
window-vibrancy = "0.6" window-vibrancy = "0.6"
windows = { version = "0.61", features = [ windows = { version = "0.61", features = [
# WinRT namespaces (SMTC — P5-43) # WinRT namespaces
"Data_Xml_Dom", # P5-41 toast XML
"Foundation", "Foundation",
"Foundation_Collections", # P5-41 toast UserInput IMap
"Media", "Media",
"UI_Notifications", # P5-41 WinRT toast notifications
# Win32 namespaces # Win32 namespaces
"Win32_Foundation", "Win32_Foundation",
"Win32_Graphics_Gdi", "Win32_Graphics_Gdi",
+16 -4
View File
@@ -51,10 +51,21 @@ const NOTIFICATION_BRIDGE: &str = r#"(function(){
function TauriNotification(title,options){ function TauriNotification(title,options){
var opts=options||{}; var opts=options||{};
try{ try{
window.__TAURI_INTERNALS__.invoke('send_notification',{ var body=opts.body!=null?String(opts.body):undefined;
title:String(title), // cinny tags message notifications with the roomId (options.tag) and the
body:opts.body!=null?String(opts.body):undefined // in-app route (options.data.path). When present, route to the rich WinRT
}).catch(function(){}); // 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(_){} }catch(_){}
} }
TauriNotification.prototype=Object.create(EventTarget.prototype); TauriNotification.prototype=Object.create(EventTarget.prototype);
@@ -397,6 +408,7 @@ pub fn run() {
native::chrome::window_toggle_maximize, native::chrome::window_toggle_maximize,
native::chrome::window_start_drag, native::chrome::window_start_drag,
native::chrome::window_close, native::chrome::window_close,
native::toast::show_rich_toast,
]) ])
.plugin(tauri_plugin_localhost::Builder::new(port).build()) .plugin(tauri_plugin_localhost::Builder::new(port).build())
.plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_window_state::Builder::default().build())
+100
View File
@@ -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<bool> = 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.
}
+3
View File
@@ -9,11 +9,13 @@
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
pub mod chrome; pub mod chrome;
pub mod focus_assist;
pub mod jumplist; pub mod jumplist;
pub mod network; pub mod network;
pub mod power; pub mod power;
pub mod smtc; pub mod smtc;
pub mod thumbbar; pub mod thumbbar;
pub mod toast;
/// Dispatch a DOM `CustomEvent` to the web client (mirrors `forward_deeplink` in /// 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 /// 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)?; thumbbar::setup(app)?;
smtc::setup(app)?; smtc::setup(app)?;
network::setup(app)?; network::setup(app)?;
focus_assist::setup(app)?;
Ok(()) Ok(())
} }
+244
View File
@@ -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 `<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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[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)
}
}