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:
@@ -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.
|
||||
}
|
||||
Reference in New Issue
Block a user