From 3a48771588262c1e88b2d9f70961a4bb3a5328ce Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 2 Jul 2026 20:46:52 -0400 Subject: [PATCH] fix(native): Wave-2 audit fixes (D1, D3, D5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D1 (HIGH): Linux no-sleep was completely non-functional — the zbus ScreenSaver inhibit was bound to a function-local D-Bus connection dropped on return, so the screensaver service auto-released it instantly. Keep a long-lived Connection in managed state (InhibitState { conn, cookie }) so the same connection holds Inhibit and issues UnInhibit; created once, reused. - D3: tray "Do Not Disturb" desynced from the web manualDndAtom after any reload (custom-chrome toggle / logout) — the atom is in-memory and reset while the tray stayed checked. Added TrayDndState + a get_tray_dnd command so the web hook re-hydrates the atom on mount. - D5: install_update now calls app.restart() after a successful install so the new version actually runs (Linux AppImage kept running the old binary; the UI hung on "installing"). CI-compile-verified (windows + linux). Web-side wiring (get_tray_dnd query, updater terminal state) landed on cinny:lotus. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/lib.rs | 29 ++++++++ src-tauri/src/native/power.rs | 126 +++++++++++++++++++++++----------- 2 files changed, 116 insertions(+), 39 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 773124a..d051a37 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -138,6 +138,13 @@ async fn install_update(app: tauri::AppHandle) -> Result<(), String> { .download_and_install(|_chunk, _total| {}, || {}) .await .map_err(|e| e.to_string())?; + // Only reached on a successful download+install (the `?` above bails + // otherwise). Relaunch so the freshly installed version actually + // runs — without this the UI hangs on "installing", and on a Linux + // AppImage the running process is still the old binary. `restart()` + // exits the current process and never returns, so nothing after it + // runs for the update case. + app.restart(); } } Ok(()) @@ -368,6 +375,23 @@ struct TrayUnreadState { height: u32, } +/// Holds a clone of the tray "Do Not Disturb" `CheckMenuItem` so `get_tray_dnd` +/// can read its live checkstate. The tray only emits `lotus-dnd-changed` on +/// click, but the web `manualDndAtom` is in-memory and resets on every reload, +/// so the web hook re-hydrates from this on mount. `CheckMenuItem` is a cheap +/// clonable handle to the same underlying menu item. +struct TrayDndState(CheckMenuItem); + +/// Return the tray DND toggle's current checkstate so the web side can +/// re-hydrate `manualDndAtom` after a reload. Returns `false` when the tray +/// wasn't created (e.g. missing bundled icon) rather than erroring the call. +#[tauri::command] +fn get_tray_dnd(app: tauri::AppHandle) -> bool { + app.try_state::() + .map(|s| s.0.is_checked().unwrap_or(false)) + .unwrap_or(false) +} + /// Paint a small white-ringed red "unread" dot into the bottom-right corner of /// an RGBA buffer, in place. Cross-platform (operates on raw pixels). fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) { @@ -448,6 +472,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ set_badge_count, set_tray_unread, + get_tray_dnd, flash_window, send_notification, check_for_update, @@ -563,6 +588,10 @@ pub fn run() { width, height, }); + + // Keep a handle to the DND CheckMenuItem so `get_tray_dnd` can + // report its live checkstate for web re-hydration after reload. + app.manage(TrayDndState(dnd_item)); } else { eprintln!("tray: no bundled window icon; skipping system tray setup"); } diff --git a/src-tauri/src/native/power.rs b/src-tauri/src/native/power.rs index 55769e3..e9bb689 100644 --- a/src-tauri/src/native/power.rs +++ b/src-tauri/src/native/power.rs @@ -13,19 +13,38 @@ //! Linux (P6-1): `org.freedesktop.ScreenSaver` `Inhibit`/`UnInhibit` over the //! session bus (zbus, blocking API). The inhibit cookie returned by `Inhibit` //! is stored in Tauri managed state (`ScreenSaverInhibit`) so the later -//! `UnInhibit` can release exactly that request. All D-Bus failures are logged -//! and swallowed — a missing/absent screensaver service must never break a call. +//! `UnInhibit` can release exactly that request. The owning D-Bus **connection** +//! is stored alongside the cookie and kept alive for the inhibit's whole +//! duration: `org.freedesktop.ScreenSaver` auto-releases an inhibit the instant +//! the connection that took it disappears, so a per-call function-local +//! connection would drop the inhibit immediately. The one connection is opened +//! lazily on first inhibit and reused for the matching `UnInhibit`. All D-Bus +//! failures are logged and swallowed — a missing/absent screensaver service must +//! never break a call. //! //! macOS is out of scope for P6-1 (would use `IOPMAssertionCreate`) and stays a //! no-op; the command stays cross-platform so the web side is unconditional. use tauri::AppHandle; -/// Holds the current `org.freedesktop.ScreenSaver` inhibit cookie (Linux only). -/// `None` when no inhibit is active. Registered as Tauri managed state in -/// `setup()` and read by `set_call_active` via `AppHandle::state()`. +/// The long-lived screensaver-inhibit state (Linux only). Both fields live +/// behind ONE mutex so the connection and the cookie it produced can never +/// desync: `conn` is the session-bus connection that *owns* the inhibit, and +/// `cookie` is the handle returned by `Inhibit`. The connection is opened once +/// (lazily, on the first inhibit) and reused for the matching `UnInhibit`; +/// keeping it alive here is what stops the screensaver service from +/// auto-releasing the inhibit. #[cfg(target_os = "linux")] -pub struct ScreenSaverInhibit(pub std::sync::Mutex>); +#[derive(Default)] +struct InhibitState { + conn: Option, + cookie: Option, +} + +/// Tauri managed state wrapper. Registered in `setup()` and read by +/// `set_call_active` via `AppHandle::state()`. +#[cfg(target_os = "linux")] +pub struct ScreenSaverInhibit(std::sync::Mutex); /// Register the Linux screensaver-inhibit managed state. No-op elsewhere. /// Called once from `native::setup()`. @@ -33,7 +52,9 @@ pub fn setup(app: &AppHandle) -> tauri::Result<()> { #[cfg(target_os = "linux")] { use tauri::Manager; - app.manage(ScreenSaverInhibit(std::sync::Mutex::new(None))); + app.manage(ScreenSaverInhibit(std::sync::Mutex::new( + InhibitState::default(), + ))); } #[cfg(not(target_os = "linux"))] let _ = app; @@ -67,11 +88,11 @@ pub fn set_call_active(app: AppHandle, active: bool) { { use tauri::Manager; - // Serialize access to the stored cookie for the duration of the D-Bus - // round-trip. This command is the only touch point, so holding the lock - // across the (short, blocking) call cannot deadlock. + // Serialize access to the stored connection+cookie for the duration of + // the D-Bus round-trip. This command is the only touch point, so holding + // the lock across the (short, blocking) call cannot deadlock. let state = app.state::(); - let mut cookie_guard = match state.0.lock() { + let mut inner = match state.0.lock() { Ok(guard) => guard, Err(e) => { eprintln!("power: ScreenSaverInhibit mutex poisoned: {e}"); @@ -79,41 +100,68 @@ pub fn set_call_active(app: AppHandle, active: bool) { } }; - let conn = match zbus::blocking::Connection::session() { - Ok(conn) => conn, - Err(e) => { - eprintln!("power: D-Bus session connection failed: {e}"); - return; - } - }; - let proxy = match zbus::blocking::Proxy::new( - &conn, - "org.freedesktop.ScreenSaver", - "/org/freedesktop/ScreenSaver", - "org.freedesktop.ScreenSaver", - ) { - Ok(proxy) => proxy, - Err(e) => { - eprintln!("power: ScreenSaver proxy init failed: {e}"); - return; - } - }; - if active { // Only take a new inhibit if one isn't already held, so repeated // set_call_active(true) calls don't leak cookies. - if cookie_guard.is_none() { - let res: zbus::Result = - proxy.call("Inhibit", &("Lotus Chat", "In a Lotus Chat call")); + if inner.cookie.is_none() { + // Lazily open the ONE long-lived session connection. Because the + // screensaver service auto-releases an inhibit when the owning + // connection disappears, this connection must outlive the + // inhibit — it stays in managed state and is reused below for + // UnInhibit. Never reopened once established. + if inner.conn.is_none() { + match zbus::blocking::Connection::session() { + Ok(conn) => inner.conn = Some(conn), + Err(e) => { + eprintln!("power: D-Bus session connection failed: {e}"); + return; + } + } + } + // Scope the connection borrow so it ends before we mutate + // `inner.cookie` (both go through the MutexGuard's Deref, which + // borrows the whole guard, so the borrows must not overlap). + let res: zbus::Result = { + let conn = inner + .conn + .as_ref() + .expect("connection set immediately above"); + match zbus::blocking::Proxy::new( + conn, + "org.freedesktop.ScreenSaver", + "/org/freedesktop/ScreenSaver", + "org.freedesktop.ScreenSaver", + ) { + Ok(proxy) => proxy.call("Inhibit", &("Lotus Chat", "In a Lotus Chat call")), + Err(e) => { + eprintln!("power: ScreenSaver proxy init failed: {e}"); + return; + } + } + }; match res { - Ok(cookie) => *cookie_guard = Some(cookie), + Ok(cookie) => inner.cookie = Some(cookie), Err(e) => eprintln!("power: ScreenSaver Inhibit failed: {e}"), } } - } else if let Some(cookie) = cookie_guard.take() { - let res: zbus::Result<()> = proxy.call("UnInhibit", &(cookie,)); - if let Err(e) = res { - eprintln!("power: ScreenSaver UnInhibit failed: {e}"); + } else if let Some(cookie) = inner.cookie.take() { + // Release on the SAME connection that took the inhibit. If it's + // somehow gone the inhibit was already auto-released, so nothing to do. + if let Some(conn) = inner.conn.as_ref() { + match zbus::blocking::Proxy::new( + conn, + "org.freedesktop.ScreenSaver", + "/org/freedesktop/ScreenSaver", + "org.freedesktop.ScreenSaver", + ) { + Ok(proxy) => { + let res: zbus::Result<()> = proxy.call("UnInhibit", &(cookie,)); + if let Err(e) = res { + eprintln!("power: ScreenSaver UnInhibit failed: {e}"); + } + } + Err(e) => eprintln!("power: ScreenSaver proxy init failed: {e}"), + } } } }