diff --git a/linux/daemon/src/core/notification_display.rs b/linux/daemon/src/core/notification_display.rs index 3390a2c..0aaae06 100644 --- a/linux/daemon/src/core/notification_display.rs +++ b/linux/daemon/src/core/notification_display.rs @@ -2,7 +2,15 @@ //! standard `org.freedesktop.Notifications` D-Bus service (the same bus //! `notify-send` uses). Best-effort — a missing notification daemon just //! logs a warning; it never gates anything. +//! +//! Notifications that carry action buttons (incoming-call banners, incoming-file +//! consent) are the delicate case: GNOME Shell and Plasma disagree about what +//! the SENDER must look like for the buttons to show up, so [`notify`] probes +//! the running server once and posts accordingly — see [`post_via_gdbus_child`]. +use std::collections::HashMap; + +use zbus::zvariant::Value; use zbus::{Connection, Proxy}; use crate::core::notif_mirror::NotificationMirror; @@ -15,6 +23,195 @@ fn gvariant_string(s: &str) -> String { format!("\"{escaped}\"") } +/// Process-lifetime session-bus connection shared by every call in here. It +/// MUST outlive the notifications it posts: servers key a notification's action +/// buttons to the sending bus name, so a connection opened per call and dropped +/// on return is a sender that has already vanished by the time the user looks at +/// the banner — see [`post_via_gdbus_child`]. +static CONN: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); + +async fn conn() -> Result<&'static Connection, String> { + CONN.get_or_try_init(|| async { + Connection::session() + .await + .map_err(|e| format!("session bus: {e}")) + }) + .await +} + +async fn notifications_proxy() -> Result, String> { + Proxy::new( + conn().await?, + "org.freedesktop.Notifications", + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + ) + .await + .map_err(|e| format!("notifications proxy: {e}")) +} + +/// How to post: the two big shells want OPPOSITE things from the sender. +/// +/// * **GNOME Shell** associates a notification with its SENDER process. Because +/// our sender owns a (Tauri) window, gnome-shell instantly auto-dismisses our +/// notifications (NotificationClosed reason=2 within ~ms, as if the user had +/// already seen them) so they never appear at all. A *windowless* sender — a +/// short-lived `gdbus` child — has no window to associate, so the banner stays. +/// * **Plasma** (and any server that watches the sender) does the reverse: when +/// the sending process leaves the bus it strips the notification's action +/// buttons, since a click could no longer be delivered to anyone. Posting from +/// a transient `gdbus` child there yields a banner with NOTHING to click — the +/// incoming-file consent prompt can then never be accepted. +/// +/// So: `gdbus` child on GNOME Shell only, our own long-lived connection +/// everywhere else. Either way ActionInvoked / NotificationClosed are broadcast +/// signals caught by the global sender-less watchers below, so routing a click +/// never depends on who posted the notification. +async fn post_via_gdbus_child() -> bool { + static VIA_CHILD: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); + *VIA_CHILD + .get_or_init(|| async { + let info = match notifications_proxy().await { + Ok(p) => p + .call::<_, _, (String, String, String, String)>("GetServerInformation", &()) + .await + .map_err(|e| format!("GetServerInformation: {e}")), + Err(e) => Err(e), + }; + match info { + Ok((name, vendor, version, _spec)) => { + let gnome = format!("{name} {vendor}").to_lowercase().contains("gnome"); + tracing::info!( + server = %name, vendor = %vendor, %version, via_gdbus_child = gnome, + "notification server probed" + ); + if let Ok(p) = notifications_proxy().await { + if let Ok(caps) = p.call::<_, _, Vec>("GetCapabilities", &()).await { + if !caps.iter().any(|c| c == "actions") { + tracing::warn!( + ?caps, + "notification server does NOT support actions — banners with \ + Accept/Decline (incoming file shares, calls) will have no \ + buttons; those prompts will time out as declined" + ); + } + } + } + gnome + } + // Can't tell → keep the historical GNOME-safe path. + Err(e) => { + tracing::warn!("notification server probe failed ({e}); assuming GNOME"); + true + } + } + }) + .await +} + +/// Post (or update in place, when `replaces_id` != 0) one notification and +/// return its id. `actions` is the freedesktop flat `[key, label, key, label, …]` +/// array. Transport picked by [`post_via_gdbus_child`]. +#[allow(clippy::too_many_arguments)] +async fn notify( + app_name: &str, + replaces_id: u32, + app_icon: &str, + summary: &str, + body: &str, + actions: &[String], + urgency: Option, + category: Option<&str>, + expire_timeout: i32, +) -> Result { + if !post_via_gdbus_child().await { + let mut hints: HashMap<&str, Value<'_>> = HashMap::new(); + if let Some(u) = urgency { + hints.insert("urgency", Value::U8(u)); + } + if let Some(c) = category { + hints.insert("category", Value::from(c)); + } + return notifications_proxy() + .await? + .call::<_, _, u32>( + "Notify", + &( + app_name, + replaces_id, + app_icon, + summary, + body, + actions, + hints, + expire_timeout, + ), + ) + .await + .map_err(|e| format!("Notify: {e}")); + } + + let actions_arg = format!( + "[{}]", + actions + .iter() + .map(|a| gvariant_string(a)) + .collect::>() + .join(", ") + ); + let mut hint_parts: Vec = Vec::new(); + if let Some(u) = urgency { + hint_parts.push(format!("'urgency': ")); + } + if let Some(c) = category { + hint_parts.push(format!("'category': <{}>", gvariant_string(c))); + } + // A bare `{}` is an ambiguous GVariant, so empty hints need the type prefix. + let hints_arg = if hint_parts.is_empty() { + "@a{sv} {}".to_string() + } else { + format!("{{{}}}", hint_parts.join(", ")) + }; + let output = tokio::process::Command::new("gdbus") + .arg("call") + .arg("--session") + .arg("--dest") + .arg("org.freedesktop.Notifications") + .arg("--object-path") + .arg("/org/freedesktop/Notifications") + .arg("--method") + .arg("org.freedesktop.Notifications.Notify") + .arg(gvariant_string(app_name)) + .arg(replaces_id.to_string()) // u32: 0 = new, else update in place + .arg(gvariant_string(app_icon)) + .arg(gvariant_string(summary)) + .arg(gvariant_string(body)) + .arg(&actions_arg) // as + .arg(&hints_arg) // a{sv} + .arg(expire_timeout.to_string()) // i32 + .output() + .await + .map_err(|e| format!("gdbus spawn: {e}"))?; + if !output.status.success() { + return Err(format!( + "gdbus Notify failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + // gdbus prints "(uint32 78,)" — strip the type keyword first (it ends in + // digits "32" which would otherwise be misread as the id), then take the + // remaining integer. + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .replace("uint32", " ") + .chars() + .skip_while(|c| !c.is_ascii_digit()) + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse() + .map_err(|_| format!("gdbus Notify: unparseable id {stdout:?}")) +} + /// Pop a desktop notification for a mirrored phone notification. The phone /// app label becomes the summary prefix so the user sees which app it's /// from. Content is taken as-is (already length-capped on the phone). @@ -37,15 +234,11 @@ pub async fn show(notif: &NotificationMirror, replaces_id: u32) -> Result = vec![ - gvariant_string("default"), - gvariant_string("Open"), - ]; + let mut actions: Vec = vec!["default".to_string(), "Open".to_string()]; for (i, label) in notif.actions.iter().enumerate() { - action_parts.push(gvariant_string(&format!("act:{i}"))); - action_parts.push(gvariant_string(label)); + actions.push(format!("act:{i}")); + actions.push(label.clone()); } - let actions_arg = format!("[{}]", action_parts.join(", ")); // Actionable notifications stay until dismissed (0); plain ones get a // short banner (GNOME drops a banner's buttons when it expires). let expire_timeout: i32 = if notif.actions.is_empty() { 8000 } else { 0 }; @@ -66,77 +259,32 @@ pub async fn show(notif: &NotificationMirror, replaces_id: u32) -> Result() - .parse() - .map_err(|_| format!("gdbus Notify: unparseable id {stdout:?}"))?; - Ok(id) + // GNOME collapses newlines in a notification body to spaces (even + // notify-send can't line-break), so stacked chat messages would run + // together. Use a middle-dot separator so they stay distinguishable + // on the one line GNOME gives us. (A future GNOME Shell extension + // could render the raw newlines as real lines.) + let body = notif.text.replace('\n', " · "); + notify( + &app_name, // = real phone app, not "Vortex" + replaces_id, + &app_icon, + &summary, + &body, + &actions, + None, + None, + expire_timeout, + ) + .await } /// Close a desktop notification we previously showed (the phone dismissed /// its original, so drop our mirrored copy). Emits a NotificationClosed /// signal with reason=3 (closed-by-call), which the watcher ignores. pub async fn close(id: u32) -> Result<(), String> { - let conn = Connection::session() - .await - .map_err(|e| format!("session bus: {e}"))?; - let proxy = Proxy::new( - &conn, - "org.freedesktop.Notifications", - "/org/freedesktop/Notifications", - "org.freedesktop.Notifications", - ) - .await - .map_err(|e| format!("notifications proxy: {e}"))?; - proxy + notifications_proxy() + .await? .call::<_, _, ()>("CloseNotification", &(id,)) .await .map_err(|e| format!("CloseNotification: {e}"))?; @@ -150,7 +298,8 @@ pub async fn close(id: u32) -> Result<(), String> { /// can route the click straight to a `CallControl` (disjoint from the /// notification-mirror's `act:` keys, so the two watchers never collide). /// `replaces_id` (0 = new) updates the same banner in place across phases. -/// Posted via a windowless `gdbus` child for the same reason as [`show`]. +/// Posted through [`notify`], which picks the transport the local shell needs +/// for the action buttons to actually appear and stay clickable. pub async fn show_call_banner( title: &str, body: &str, @@ -165,60 +314,32 @@ pub async fn show_call_banner( .filter(|p| p.exists()) .and_then(|p| p.to_str().map(str::to_string)) .unwrap_or_else(|| "call-start-symbolic".to_string()); - let mut action_parts: Vec = Vec::new(); + // Flatten (key, label) pairs into the freedesktop [key, label, …] array. + let mut flat: Vec = Vec::with_capacity(actions.len() * 2); for (key, label) in actions { - action_parts.push(gvariant_string(key)); - action_parts.push(gvariant_string(label)); + flat.push(key.clone()); + flat.push(label.clone()); } - let actions_arg = format!("[{}]", action_parts.join(", ")); // urgency=critical (byte 2) → GNOME keeps the banner on screen until acted // on. When the user dismisses it (the "silence" gesture) we re-show at // urgency=normal (byte 1): it tucks quietly into the notification list (no // aggressive re-pop) but stays there with its Accept/Decline actions. // category 'call.incoming' lets shells style it. - let hints = if critical { - "{'urgency': , 'category': <'call.incoming'>}" - } else { - "{'urgency': , 'category': <'call.incoming'>}" - }; + let urgency = if critical { 2u8 } else { 1u8 }; - let output = tokio::process::Command::new("gdbus") - .arg("call") - .arg("--session") - .arg("--dest") - .arg("org.freedesktop.Notifications") - .arg("--object-path") - .arg("/org/freedesktop/Notifications") - .arg("--method") - .arg("org.freedesktop.Notifications.Notify") - .arg(gvariant_string("Phone")) // app_name - .arg(replaces_id.to_string()) // replaces_id (0 = new) - .arg(gvariant_string(&icon)) // app_icon (real dialer logo or call glyph) - .arg(gvariant_string(title)) // summary = caller - .arg(gvariant_string(body)) // body = "Incoming call" / number - .arg(&actions_arg) // as - .arg(hints) // a{sv} - .arg("0") // expire_timeout: never (critical + resident) - .output() - .await - .map_err(|e| format!("gdbus spawn: {e}"))?; - if !output.status.success() { - return Err(format!( - "gdbus call-banner failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - let id: u32 = stdout - .replace("uint32", " ") - .chars() - .skip_while(|c| !c.is_ascii_digit()) - .take_while(|c| c.is_ascii_digit()) - .collect::() - .parse() - .map_err(|_| format!("gdbus call-banner: unparseable id {stdout:?}"))?; - Ok(id) + notify( + "Phone", // app_name + replaces_id, + &icon, // real dialer logo or call glyph + title, // summary = caller + body, // "Incoming call" / number + &flat, + Some(urgency), + Some("call.incoming"), + 0, // expire_timeout: never + ) + .await } /// Build a sender-less signal match rule for one of the Notifications diff --git a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs index 996612d..cf3c2d4 100644 --- a/linux/ui-tauri/src-tauri/src/clipboard_sync.rs +++ b/linux/ui-tauri/src-tauri/src/clipboard_sync.rs @@ -1,14 +1,15 @@ //! Phone↔laptop clipboard sync + instant-share receive (universal-clipboard //! style) — split out of `clipboard.rs`. Sends locally-copied text/images to the //! phone, applies what the phone sends back to the system clipboard + history, -//! and pulls instant-share file/image offers to ~/Downloads (with batch consent). The +//! and pulls instant-share file/image offers to the user's download folder (with +//! batch consent — see [`downloads_dir`], which is localised, NOT always ~/Downloads). The //! local-history capture/store stays in `clipboard.rs`; this module calls into //! it (store_capture/hash_id/now_ms) and the capture loop calls back here //! (queue_clipboard_for_sync / queue_clipboard_image_for_sync). use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Mutex; +use std::sync::{Mutex, OnceLock}; use tauri::{AppHandle, Emitter}; @@ -344,10 +345,101 @@ pub(crate) async fn apply_synced_image(app: &AppHandle, png: Vec) { } } -/// `~/Downloads` — where instant-share received files land (visible, standard). -fn downloads_dir() -> Option { - let home = std::env::var_os("HOME")?; - Some(PathBuf::from(home).join("Downloads")) +/// Where instant-share received files land: the user's REAL download folder, +/// which is localised — `~/Téléchargements` on a French desktop, `~/Downloads` +/// only on an English one. Hardcoding `~/Downloads` doesn't just miss it, it +/// silently *creates* a second, English-named folder beside the real one and +/// drops every received file where the user never looks. Resolved once per run +/// (neither `$HOME` nor the XDG config changes under us). +pub(crate) fn downloads_dir() -> Option { + static DIR: OnceLock> = OnceLock::new(); + DIR.get_or_init(|| { + let home = PathBuf::from(std::env::var_os("HOME")?); + let dir = xdg_download_dir(&home).unwrap_or_else(|| home.join("Downloads")); + tracing::info!("received files → {}", dir.display()); + Some(dir) + }) + .clone() +} + +/// The download folder's own name ("Téléchargements"), for UI copy — so a +/// "Saved to …" message can never name a folder the file didn't go to. +pub(crate) fn downloads_label() -> String { + downloads_dir() + .and_then(|d| { + d.file_name() + .map(|n| n.to_string_lossy().to_string()) + .filter(|n| !n.is_empty()) + }) + .unwrap_or_else(|| "Downloads".to_string()) +} + +/// The configured `XDG_DOWNLOAD_DIR`: the environment first, else the +/// `user-dirs.dirs` file that `xdg-user-dir(1)` reads. Not required to exist — +/// a configured-but-missing folder is still the user's stated intent, and +/// `apply_synced_file` creates it; falling back to `~/Downloads` there would +/// reintroduce exactly the bug this avoids. +fn xdg_download_dir(home: &std::path::Path) -> Option { + if let Some(v) = std::env::var_os("XDG_DOWNLOAD_DIR") { + if let Some(p) = expand_home(&v.to_string_lossy(), home) { + return Some(p); + } + } + let config = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(".config")); + let text = std::fs::read_to_string(config.join("user-dirs.dirs")).ok()?; + expand_home(&parse_user_dirs(&text, "XDG_DOWNLOAD_DIR")?, home) +} + +/// Pull one key out of a `user-dirs.dirs` file. It's shell-syntax: +/// `# comment` lines and `KEY="value"` assignments. Last assignment wins, as +/// a shell sourcing it would give. +fn parse_user_dirs(text: &str, key: &str) -> Option { + let mut found = None; + for line in text.lines() { + let line = line.trim(); + if line.starts_with('#') { + continue; + } + let Some((k, v)) = line.split_once('=') else { + continue; + }; + if k.trim() != key { + continue; + } + let v = v.trim(); + // Strip one layer of matching quotes. + let v = v + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .or_else(|| v.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) + .unwrap_or(v); + if !v.is_empty() { + found = Some(v.to_string()); + } + } + found +} + +/// Expand the `$HOME/…` (or `~/…`) prefix the spec mandates for these values. +/// Anything else must already be absolute — a bare relative path is malformed, +/// and guessing at it could scatter files into the process's cwd. +fn expand_home(raw: &str, home: &std::path::Path) -> Option { + let raw = raw.trim(); + for prefix in ["$HOME", "${HOME}", "~"] { + if let Some(rest) = raw.strip_prefix(prefix) { + let rest = rest.trim_start_matches('/'); + return Some(if rest.is_empty() { + home.to_path_buf() + } else { + home.join(rest) + }); + } + } + let p = PathBuf::from(raw); + p.is_absolute().then_some(p) } /// A non-clobbering path in `dir` for `name`: if it exists, append " (1)", @@ -376,8 +468,8 @@ fn unique_path(dir: &std::path::Path, name: &str) -> PathBuf { } /// Apply a fully-received FILE shared from the phone (instant-share style, NOT the -/// clipboard): save it to `~/Downloads` under its original name and pop a -/// desktop notification. Bytes are never logged; only size + name. +/// clipboard): save it to the user's download folder ([`downloads_dir`]) under its +/// original name and pop a desktop notification. Bytes are never logged; only size + name. /// Returns the saved path on success (for the transfer panel), `None` on error. pub(crate) async fn apply_synced_file( _app: &AppHandle, @@ -520,3 +612,74 @@ pub fn get_clipboard_sync() -> bool { CLIPBOARD_SYNC.load(Ordering::Relaxed) } +#[cfg(test)] +mod tests { + use super::*; + + /// A real French `user-dirs.dirs`, verbatim shape (comment header, quoted + /// `$HOME` values) — the case where hardcoding `~/Downloads` lost files. + const FR: &str = r#"# This file is written by xdg-user-dirs-update +# If you want to change or add directories, just edit the line you're +XDG_DESKTOP_DIR="$HOME/Bureau" +XDG_DOWNLOAD_DIR="$HOME/Téléchargements" +XDG_DOCUMENTS_DIR="$HOME/Documents" +"#; + + #[test] + fn parses_localised_download_dir() { + let raw = parse_user_dirs(FR, "XDG_DOWNLOAD_DIR").expect("download dir"); + assert_eq!(raw, "$HOME/Téléchargements"); + assert_eq!( + expand_home(&raw, std::path::Path::new("/home/cyril")), + Some(PathBuf::from("/home/cyril/Téléchargements")) + ); + } + + #[test] + fn ignores_comments_and_other_keys() { + assert_eq!(parse_user_dirs(FR, "XDG_MUSIC_DIR"), None); + // A commented-out assignment must not win. + let text = "#XDG_DOWNLOAD_DIR=\"$HOME/nope\"\nXDG_DOWNLOAD_DIR=\"$HOME/yes\"\n"; + assert_eq!( + parse_user_dirs(text, "XDG_DOWNLOAD_DIR"), + Some("$HOME/yes".to_string()) + ); + } + + #[test] + fn last_assignment_wins_like_a_shell() { + let text = "XDG_DOWNLOAD_DIR=\"$HOME/first\"\nXDG_DOWNLOAD_DIR=\"$HOME/second\"\n"; + assert_eq!( + parse_user_dirs(text, "XDG_DOWNLOAD_DIR"), + Some("$HOME/second".to_string()) + ); + } + + #[test] + fn expands_home_forms_and_rejects_relative() { + let home = std::path::Path::new("/home/cyril"); + for raw in ["$HOME/Dl", "${HOME}/Dl", "~/Dl"] { + assert_eq!(expand_home(raw, home), Some(PathBuf::from("/home/cyril/Dl"))); + } + // Download dir set to the home directory itself. + assert_eq!(expand_home("$HOME/", home), Some(home.to_path_buf())); + // Absolute paths pass through; relative ones are malformed → fall back. + assert_eq!(expand_home("/data/dl", home), Some(PathBuf::from("/data/dl"))); + assert_eq!(expand_home("Downloads", home), None); + assert_eq!(expand_home("", home), None); + } + + /// Unquoted and single-quoted values are valid shell too. + #[test] + fn handles_unquoted_and_single_quoted() { + assert_eq!( + parse_user_dirs("XDG_DOWNLOAD_DIR=$HOME/Dl\n", "XDG_DOWNLOAD_DIR"), + Some("$HOME/Dl".to_string()) + ); + assert_eq!( + parse_user_dirs("XDG_DOWNLOAD_DIR='$HOME/Dl'\n", "XDG_DOWNLOAD_DIR"), + Some("$HOME/Dl".to_string()) + ); + } +} + diff --git a/linux/ui-tauri/src-tauri/src/transfers.rs b/linux/ui-tauri/src-tauri/src/transfers.rs index f500077..9af3a62 100644 --- a/linux/ui-tauri/src-tauri/src/transfers.rs +++ b/linux/ui-tauri/src-tauri/src/transfers.rs @@ -158,7 +158,10 @@ fn emit() { if all_done { ( format!("Received {label}"), - "Saved to Downloads".to_string(), + // Name the folder they actually landed in — it's localised + // ("Téléchargements", …), and a wrong name here sends the user + // hunting in a folder that hasn't got the files. + format!("Saved to {}", crate::clipboard_sync::downloads_label()), 100, true, )