Files
cinny-desktop/src-tauri/src/native/focus_assist.rs
T
Lotus CIandClaude Opus 5.5 c1ae01bf41
Build Lotus Chat Desktop / prepare (push) Canceled after 0s
Build Lotus Chat Desktop / build-windows (push) Canceled after 0s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s
fix(native): toast coalescing, real reply target, Focus Assist mount query
- Rich toasts set a WinRT Tag (hash of the web notification tag, since
  room:thread tags exceed the 64-char limit) in a fixed Group, so a newer
  toast for the same room/thread replaces the older one instead of
  stacking; the replaced toast's keep-alive entry is dropped (#16).
- The bridge passes tag, roomId and threadId separately. The reply target
  is the real room id (+ thread), never the coalescing tag, which was
  `room:thread` for threads and `lotus-invites` for invites, so those
  replies failed silently. Toasts with no room id (invites) get no reply
  box (#17).
- New `get_focus_assist` command serves the latest Focus Assist poll so
  the web hook can hydrate on mount; the setup-time first emit was lost
  before the page listened (#15).
- Bump cinny to 3b6de2fd (the matching web-side hooks).

Closes #15, #16, #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 19:33:30 -04:00

121 lines
5.0 KiB
Rust

//! 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<bool> {
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<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;
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.
}