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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user