From 22f8e1566cb3efa756432811d26d8514af455029 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 2 Jul 2026 13:30:24 -0400 Subject: [PATCH] feat(native): Linux parity + autostart + tray DND (P6-1; no macOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds out the native app on Linux (Windows features kept; macOS stays no-op): - power.rs: no-sleep during calls on Linux via a zbus org.freedesktop.ScreenSaver Inhibit/UnInhibit (cookie held in ScreenSaverInhibit managed state). - set_badge_count: Linux launcher badge via the Unity com.canonical.Unity.LauncherEntry.Update D-Bus signal (best-effort; app_uri = cinny.desktop per Tauri's mainBinaryName naming). - tauri-plugin-autostart registered (+ autostart:allow-enable/disable/is-enabled capabilities); web toggles it from Settings. - Tray "Do Not Disturb" CheckMenuItem → emits lotus-dnd-changed to the web, which ORs it into the notification quiet-gate. zbus 5 (Linux target dep; blocking-api default). CI-compile-verified (windows+linux); reviewer confirmed no build-breakers. Runtime to check on Linux: DND toggle polarity, badge .desktop id. Co-Authored-By: Claude Opus 4.8 --- src-tauri/Cargo.toml | 8 +++ src-tauri/capabilities/migrated.json | 3 + src-tauri/src/lib.rs | 76 ++++++++++++++++++++++- src-tauri/src/native/mod.rs | 3 +- src-tauri/src/native/power.rs | 91 ++++++++++++++++++++++++++-- 5 files changed, 171 insertions(+), 10 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a6dc43b..fd3a80e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,14 @@ custom-protocol = [ "tauri/custom-protocol" ] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" tauri-plugin-single-instance = "2" +tauri-plugin-autostart = "2" # P6-1 launch-on-login + +[target.'cfg(target_os = "linux")'.dependencies] +# P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher +# badge, both via the session D-Bus. zbus 5.x ships the blocking API under the +# default `blocking-api` feature and the default `async-io` runtime, so plain +# default features suffice (no tokio integration needed here). +zbus = "5" [target.'cfg(target_os = "windows")'.dependencies] webview2-com = "0.38" diff --git a/src-tauri/capabilities/migrated.json b/src-tauri/capabilities/migrated.json index 7bdd3af..dca8fa5 100644 --- a/src-tauri/capabilities/migrated.json +++ b/src-tauri/capabilities/migrated.json @@ -48,6 +48,9 @@ "notification:default", "core:app:allow-app-show", "core:app:allow-app-hide", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled", { "identifier": "opener:allow-open-url", "allow": [{ "url": "http://*" }, { "url": "https://*" }] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 30220b8..773124a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,7 +4,7 @@ )] use tauri::{ - menu::{Menu, MenuItem, PredefinedMenuItem}, + menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, webview::{NewWindowResponse, PageLoadEvent, WebviewWindowBuilder}, Manager, WebviewUrl, @@ -145,6 +145,11 @@ async fn install_update(app: tauri::AppHandle) -> Result<(), String> { #[tauri::command] fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> { + // `window` is only consulted on Windows (needs the HWND for the taskbar + // overlay). Bind it elsewhere so cross-platform builds don't warn. + #[cfg(not(target_os = "windows"))] + let _ = &window; + #[cfg(target_os = "windows")] { use windows::{ @@ -313,6 +318,44 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> { } } } + + // Linux (P6-1): emit the Unity `LauncherEntry.Update` broadcast signal so + // launchers/docks that speak the com.canonical.Unity.LauncherEntry protocol + // (GNOME "Dash to Dock", KDE task manager, Unity, etc.) render a count + // badge on the app's launcher icon. Best-effort: any D-Bus failure is + // logged and swallowed so a headless/unsupported environment never breaks + // the badge call. + #[cfg(target_os = "linux")] + { + use std::collections::HashMap; + use zbus::zvariant::Value; + + // application://.desktop — the installed .desktop + // basename. Tauri v2's Linux bundler names it after mainBinaryName + // ("cinny"), NOT the identifier, so the file is `cinny.desktop`. If the + // badge doesn't attach at runtime, verify against + // /usr/share/applications/ and adjust. + let app_uri = "application://cinny.desktop"; + let mut props: HashMap<&str, Value> = HashMap::new(); + props.insert("count", Value::from(count as i64)); + props.insert("count-visible", Value::from(count > 0)); + + match zbus::blocking::Connection::session() { + Ok(conn) => { + if let Err(e) = conn.emit_signal( + None::<&str>, + "/com/canonical/unity/launcherentry/lotuschat", + "com.canonical.Unity.LauncherEntry", + "Update", + &(app_uri, props), + ) { + eprintln!("badge: Unity LauncherEntry emit failed: {e}"); + } + } + Err(e) => eprintln!("badge: D-Bus session connection failed: {e}"), + } + } + Ok(()) } @@ -441,6 +484,14 @@ pub fn run() { #[cfg(not(any(target_os = "android", target_os = "ios")))] { builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + // P6-1 launch-on-login. The web side drives it via the plugin's own + // `plugin:autostart|enable`/`disable`/`is-enabled` commands (no wrapper + // command). The MacosLauncher arg is mandatory by the plugin API even + // though macOS is out of scope; `None` = no extra launch args. + builder = builder.plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + )); } builder @@ -451,17 +502,36 @@ pub fn run() { // panicking at startup (the tray simply isn't created). if let Some(base_icon) = app.default_window_icon().cloned() { let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?; + // P6-1: Do Not Disturb toggle. The CheckMenuItem auto-flips its + // own checkstate on click; we read the new state and push it to + // the web client, which owns the actual notification-muting. + let dnd_item = + CheckMenuItem::with_id(app, "dnd", "Do Not Disturb", true, false, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?; let separator = PredefinedMenuItem::separator(app)?; - let tray_menu = Menu::with_items(app, &[&open_item, &separator, &quit_item])?; + let tray_menu = + Menu::with_items(app, &[&open_item, &dnd_item, &separator, &quit_item])?; + // Clone the handle into the menu-event closure so we can query + // is_checked() after the auto-toggle. CheckMenuItem is a cheap + // clonable handle to the same underlying menu item. + let dnd_for_event = dnd_item.clone(); let tray = TrayIconBuilder::with_id("main-tray") .icon(base_icon.clone()) .tooltip("Lotus Chat") .menu(&tray_menu) .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { + .on_menu_event(move |app, event| match event.id.as_ref() { "open" => show_main(app), "quit" => app.exit(0), + "dnd" => { + let checked = dnd_for_event.is_checked().unwrap_or(false); + native::emit_to_web( + app, + "lotus-dnd-changed", + &serde_json::to_string(&serde_json::json!({ "active": checked })) + .unwrap_or_default(), + ); + } _ => {} }) .on_tray_icon_event(|tray, event| { diff --git a/src-tauri/src/native/mod.rs b/src-tauri/src/native/mod.rs index a168079..d2e62c6 100644 --- a/src-tauri/src/native/mod.rs +++ b/src-tauri/src/native/mod.rs @@ -31,9 +31,10 @@ pub fn emit_to_web(app: &AppHandle, event: &str, detail_json: &str) { } /// Called once from lib.rs `.setup()`. Feature modules that need to register OS -/// listeners or managed state get initialized here. (power/jumplist/chrome are +/// listeners or managed state get initialized here. (jumplist/chrome are /// command-only and need no setup.) pub fn setup(app: &AppHandle) -> tauri::Result<()> { + power::setup(app)?; thumbbar::setup(app)?; smtc::setup(app)?; network::setup(app)?; diff --git a/src-tauri/src/native/power.rs b/src-tauri/src/native/power.rs index f660ab9..55769e3 100644 --- a/src-tauri/src/native/power.rs +++ b/src-tauri/src/native/power.rs @@ -1,4 +1,4 @@ -//! P5-46 — System power management (call continuity). +//! P5-46 / P6-1 — System power management (call continuity). //! //! Prevents the system from sleeping / turning off the display while a voice or //! video call is active, then releases the request when the call ends. The web @@ -10,12 +10,36 @@ //! `run_on_main_thread`) to guarantee the clear cancels the matching set even //! though Tauri commands otherwise run on a pool thread. //! -//! Other platforms are a no-op for now (macOS would use `IOPMAssertionCreate`, -//! Linux `org.freedesktop.ScreenSaver`/`login1` inhibit) — tracked as a future -//! extension; the command stays cross-platform so the web side is unconditional. +//! 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. +//! +//! 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()`. +#[cfg(target_os = "linux")] +pub struct ScreenSaverInhibit(pub std::sync::Mutex>); + +/// Register the Linux screensaver-inhibit managed state. No-op elsewhere. +/// Called once from `native::setup()`. +pub fn setup(app: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "linux")] + { + use tauri::Manager; + app.manage(ScreenSaverInhibit(std::sync::Mutex::new(None))); + } + #[cfg(not(target_os = "linux"))] + let _ = app; + Ok(()) +} + #[tauri::command] pub fn set_call_active(app: AppHandle, active: bool) { #[cfg(target_os = "windows")] @@ -39,9 +63,64 @@ pub fn set_call_active(app: AppHandle, active: bool) { }); } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] { - // No-op on non-Windows platforms (see module docs). Bind the args so the + 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. + let state = app.state::(); + let mut cookie_guard = match state.0.lock() { + Ok(guard) => guard, + Err(e) => { + eprintln!("power: ScreenSaverInhibit mutex poisoned: {e}"); + return; + } + }; + + 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")); + match res { + Ok(cookie) => *cookie_guard = 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}"); + } + } + } + + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + { + // No-op on other platforms (see module docs). Bind the args so the // signature stays identical cross-platform and no unused warnings fire. let _ = (&app, active); }