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]
|
[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"
|
||||||
|
|||||||
@@ -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
@@ -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| {
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user