//! 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 latest //! reading is also kept in [`LAST_STATE`] and served by [`get_focus_assist`]: the //! first read happens during app setup, before the page has loaded, so that //! event is lost, and the web atom resets on every reload anyway. The web hook //! queries it on mount (Gitea cinny-desktop #15). //! //! 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 std::sync::atomic::{AtomicU8, Ordering}; use tauri::AppHandle; /// Latest poll result: 0 = not read yet (or not Windows), 1 = inactive, 2 = active. static LAST_STATE: AtomicU8 = AtomicU8::new(0); /// Return the latest Focus Assist reading so the web side can hydrate /// `focusAssistActiveAtom` on mount. `None` until the first successful poll, and /// always `None` off Windows. #[tauri::command] pub fn get_focus_assist() -> Option { match LAST_STATE.load(Ordering::Relaxed) { 1 => Some(false), 2 => Some(true), _ => None, } } /// 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; LAST_STATE.store(if active { 2 } else { 1 }, Ordering::Relaxed); 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. }