fix(native): Wave-2 audit fixes (D1, D3, D5)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 20:46:52 -04:00
parent de368b2056
commit 3a48771588
2 changed files with 116 additions and 39 deletions
+29
View File
@@ -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<tauri::Wry>);
/// 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::<TrayDndState>()
.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");
}
+87 -39
View File
@@ -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<Option<u32>>);
#[derive(Default)]
struct InhibitState {
conn: Option<zbus::blocking::Connection>,
cookie: Option<u32>,
}
/// 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<InhibitState>);
/// 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::<ScreenSaverInhibit>();
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<u32> =
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<u32> = {
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}"),
}
}
}
}