feat(native): Linux parity + autostart + tray DND (P6-1; no macOS)
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:
@@ -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"
|
||||
|
||||
@@ -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://*" }]
|
||||
|
||||
+73
-3
@@ -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-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(())
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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<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]
|
||||
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::<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.
|
||||
let _ = (&app, active);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user