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| {}, || {})
|
.download_and_install(|_chunk, _total| {}, || {})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.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(())
|
Ok(())
|
||||||
@@ -368,6 +375,23 @@ struct TrayUnreadState {
|
|||||||
height: u32,
|
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
|
/// 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).
|
/// an RGBA buffer, in place. Cross-platform (operates on raw pixels).
|
||||||
fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) {
|
fn draw_unread_dot(rgba: &mut [u8], width: u32, height: u32) {
|
||||||
@@ -448,6 +472,7 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
set_badge_count,
|
set_badge_count,
|
||||||
set_tray_unread,
|
set_tray_unread,
|
||||||
|
get_tray_dnd,
|
||||||
flash_window,
|
flash_window,
|
||||||
send_notification,
|
send_notification,
|
||||||
check_for_update,
|
check_for_update,
|
||||||
@@ -563,6 +588,10 @@ pub fn run() {
|
|||||||
width,
|
width,
|
||||||
height,
|
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 {
|
} else {
|
||||||
eprintln!("tray: no bundled window icon; skipping system tray setup");
|
eprintln!("tray: no bundled window icon; skipping system tray setup");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,19 +13,38 @@
|
|||||||
//! Linux (P6-1): `org.freedesktop.ScreenSaver` `Inhibit`/`UnInhibit` over the
|
//! Linux (P6-1): `org.freedesktop.ScreenSaver` `Inhibit`/`UnInhibit` over the
|
||||||
//! session bus (zbus, blocking API). The inhibit cookie returned by `Inhibit`
|
//! session bus (zbus, blocking API). The inhibit cookie returned by `Inhibit`
|
||||||
//! is stored in Tauri managed state (`ScreenSaverInhibit`) so the later
|
//! is stored in Tauri managed state (`ScreenSaverInhibit`) so the later
|
||||||
//! `UnInhibit` can release exactly that request. All D-Bus failures are logged
|
//! `UnInhibit` can release exactly that request. The owning D-Bus **connection**
|
||||||
//! and swallowed — a missing/absent screensaver service must never break a call.
|
//! 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
|
//! 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.
|
//! no-op; the command stays cross-platform so the web side is unconditional.
|
||||||
|
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
/// Holds the current `org.freedesktop.ScreenSaver` inhibit cookie (Linux only).
|
/// The long-lived screensaver-inhibit state (Linux only). Both fields live
|
||||||
/// `None` when no inhibit is active. Registered as Tauri managed state in
|
/// behind ONE mutex so the connection and the cookie it produced can never
|
||||||
/// `setup()` and read by `set_call_active` via `AppHandle::state()`.
|
/// 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")]
|
#[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.
|
/// Register the Linux screensaver-inhibit managed state. No-op elsewhere.
|
||||||
/// Called once from `native::setup()`.
|
/// Called once from `native::setup()`.
|
||||||
@@ -33,7 +52,9 @@ pub fn setup(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
use tauri::Manager;
|
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"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let _ = app;
|
let _ = app;
|
||||||
@@ -67,11 +88,11 @@ pub fn set_call_active(app: AppHandle, active: bool) {
|
|||||||
{
|
{
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
// Serialize access to the stored cookie for the duration of the D-Bus
|
// Serialize access to the stored connection+cookie for the duration of
|
||||||
// round-trip. This command is the only touch point, so holding the lock
|
// the D-Bus round-trip. This command is the only touch point, so holding
|
||||||
// across the (short, blocking) call cannot deadlock.
|
// the lock across the (short, blocking) call cannot deadlock.
|
||||||
let state = app.state::<ScreenSaverInhibit>();
|
let state = app.state::<ScreenSaverInhibit>();
|
||||||
let mut cookie_guard = match state.0.lock() {
|
let mut inner = match state.0.lock() {
|
||||||
Ok(guard) => guard,
|
Ok(guard) => guard,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("power: ScreenSaverInhibit mutex poisoned: {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 {
|
if active {
|
||||||
// Only take a new inhibit if one isn't already held, so repeated
|
// Only take a new inhibit if one isn't already held, so repeated
|
||||||
// set_call_active(true) calls don't leak cookies.
|
// set_call_active(true) calls don't leak cookies.
|
||||||
if cookie_guard.is_none() {
|
if inner.cookie.is_none() {
|
||||||
let res: zbus::Result<u32> =
|
// Lazily open the ONE long-lived session connection. Because the
|
||||||
proxy.call("Inhibit", &("Lotus Chat", "In a Lotus Chat call"));
|
// 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 {
|
match res {
|
||||||
Ok(cookie) => *cookie_guard = Some(cookie),
|
Ok(cookie) => inner.cookie = Some(cookie),
|
||||||
Err(e) => eprintln!("power: ScreenSaver Inhibit failed: {e}"),
|
Err(e) => eprintln!("power: ScreenSaver Inhibit failed: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(cookie) = cookie_guard.take() {
|
} else if let Some(cookie) = inner.cookie.take() {
|
||||||
let res: zbus::Result<()> = proxy.call("UnInhibit", &(cookie,));
|
// Release on the SAME connection that took the inhibit. If it's
|
||||||
if let Err(e) = res {
|
// somehow gone the inhibit was already auto-released, so nothing to do.
|
||||||
eprintln!("power: ScreenSaver UnInhibit failed: {e}");
|
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