feat(native): Linux parity + autostart + tray DND (P6-1; no macOS)
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-windows (push) Successful in 23m43s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m57s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 13:30:24 -04:00
parent 213f79d2a2
commit 22f8e1566c
5 changed files with 171 additions and 10 deletions
+8
View File
@@ -35,6 +35,14 @@ custom-protocol = [ "tauri/custom-protocol" ]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-single-instance = "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] [target.'cfg(target_os = "windows")'.dependencies]
webview2-com = "0.38" webview2-com = "0.38"
+3
View File
@@ -48,6 +48,9 @@
"notification:default", "notification:default",
"core:app:allow-app-show", "core:app:allow-app-show",
"core:app:allow-app-hide", "core:app:allow-app-hide",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
{ {
"identifier": "opener:allow-open-url", "identifier": "opener:allow-open-url",
"allow": [{ "url": "http://*" }, { "url": "https://*" }] "allow": [{ "url": "http://*" }, { "url": "https://*" }]
+73 -3
View File
@@ -4,7 +4,7 @@
)] )]
use tauri::{ use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem}, menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
webview::{NewWindowResponse, PageLoadEvent, WebviewWindowBuilder}, webview::{NewWindowResponse, PageLoadEvent, WebviewWindowBuilder},
Manager, WebviewUrl, Manager, WebviewUrl,
@@ -145,6 +145,11 @@ async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
#[tauri::command] #[tauri::command]
fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> { 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")] #[cfg(target_os = "windows")]
{ {
use 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-file-id>.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(()) Ok(())
} }
@@ -441,6 +484,14 @@ pub fn run() {
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
{ {
builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); 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 builder
@@ -451,17 +502,36 @@ pub fn run() {
// panicking at startup (the tray simply isn't created). // panicking at startup (the tray simply isn't created).
if let Some(base_icon) = app.default_window_icon().cloned() { if let Some(base_icon) = app.default_window_icon().cloned() {
let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?; 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 quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?; 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") let tray = TrayIconBuilder::with_id("main-tray")
.icon(base_icon.clone()) .icon(base_icon.clone())
.tooltip("Lotus Chat") .tooltip("Lotus Chat")
.menu(&tray_menu) .menu(&tray_menu)
.show_menu_on_left_click(false) .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), "open" => show_main(app),
"quit" => app.exit(0), "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| { .on_tray_icon_event(|tray, event| {
+2 -1
View File
@@ -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 /// 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.) /// command-only and need no setup.)
pub fn setup(app: &AppHandle) -> tauri::Result<()> { pub fn setup(app: &AppHandle) -> tauri::Result<()> {
power::setup(app)?;
thumbbar::setup(app)?; thumbbar::setup(app)?;
smtc::setup(app)?; smtc::setup(app)?;
network::setup(app)?; network::setup(app)?;
+85 -6
View File
@@ -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 //! 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 //! 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 //! `run_on_main_thread`) to guarantee the clear cancels the matching set even
//! though Tauri commands otherwise run on a pool thread. //! though Tauri commands otherwise run on a pool thread.
//! //!
//! Other platforms are a no-op for now (macOS would use `IOPMAssertionCreate`, //! Linux (P6-1): `org.freedesktop.ScreenSaver` `Inhibit`/`UnInhibit` over the
//! Linux `org.freedesktop.ScreenSaver`/`login1` inhibit) — tracked as a future //! session bus (zbus, blocking API). The inhibit cookie returned by `Inhibit`
//! extension; the command stays cross-platform so the web side is unconditional. //! 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; 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<Option<u32>>);
/// 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] #[tauri::command]
pub fn set_call_active(app: AppHandle, active: bool) { pub fn set_call_active(app: AppHandle, active: bool) {
#[cfg(target_os = "windows")] #[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::<ScreenSaverInhibit>();
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<u32> =
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. // signature stays identical cross-platform and no unused warnings fire.
let _ = (&app, active); let _ = (&app, active);
} }