From aab156ab7bedbd01614fbe1c48387372849cd688 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Thu, 13 Aug 2026 17:27:09 +0200 Subject: [PATCH 1/2] fix(notif): keep action buttons alive on non-GNOME shells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications were always posted through a short-lived `gdbus` child. That was a GNOME Shell workaround: it ties a notification to its sender process and instantly auto-dismisses one whose sender owns a window, so a windowless throwaway sender was needed for the banner to appear at all. Plasma wants the opposite. It strips a notification's action buttons the moment the sending process leaves the bus, since a click could no longer be delivered to anyone — and the `gdbus` child exits milliseconds after posting. Every actionable banner therefore had nothing to click: an incoming file share could not be accepted (the consent request fail-closed to a decline after its 45 s timeout), and the incoming-call banner plus the mirrored notification action buttons were just as dead. Probe the running server once via GetServerInformation and use the transport it actually needs: the `gdbus` child on GNOME Shell only, this process's own long-lived session connection everywhere else. The connection is a process-lifetime OnceCell, because one opened per call and dropped on return is a sender that has already vanished — the same bug by another route. ActionInvoked is broadcast on both shells, so the global sender-less watchers keep routing clicks regardless of who posted. Both Notify call sites now share one helper instead of duplicating the gdbus plumbing. Also warn when the server does not advertise the "actions" capability (dunst, mako), where these prompts can only ever time out. Verified on Plasma 6.7.3/Wayland: server probed as Plasma/KDE, the banner renders Accept/Decline, and the click arrives as ActionInvoked(fc:accept). Co-Authored-By: Claude Opus 5 --- linux/daemon/src/core/notification_display.rs | 357 ++++++++++++------ 1 file changed, 239 insertions(+), 118 deletions(-) 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 From 6836629e8cbe9ad88b099a0df172fde727fc0ab4 Mon Sep 17 00:00:00 2001 From: X-Ryl669 Date: Thu, 13 Aug 2026 17:27:09 +0200 Subject: [PATCH 2/2] fix(share): save received files to the real XDG download folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit downloads_dir() hardcoded $HOME/Downloads and apply_synced_file() runs create_dir_all on it, so on a localised desktop — XDG_DOWNLOAD_DIR set to $HOME/Téléchargements — an English-named folder was silently created beside the real one and every received file was filed where the user never looks. The transfer pill's hardcoded "Saved to Downloads" hid it further: it named a folder it had never consulted, so the copy looked right while the files were missing. Resolve XDG_DOWNLOAD_DIR from the environment, else from the ~/.config/user-dirs.dirs that xdg-user-dir(1) reads (handling $HOME/${HOME}/~ prefixes, quoting, comments and last-assignment-wins), falling back to ~/Downloads only when nothing is configured. A configured-but-missing folder is still used and created rather than falling back, since falling back on a stale entry would reintroduce this exact bug. Resolved once per run: the pill's emit() runs on every progress tick. The completion pill now names the folder the files actually landed in. Co-Authored-By: Claude Opus 5 --- .../ui-tauri/src-tauri/src/clipboard_sync.rs | 179 +++++++++++++++++- linux/ui-tauri/src-tauri/src/transfers.rs | 5 +- 2 files changed, 175 insertions(+), 9 deletions(-) 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, )