From 597b520398003c06d80386f49e48cf0f4de4cb18 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 1 Oct 2026 15:24:53 -0700 Subject: [PATCH] fix: Linux chooses and opens files through the desktop portal Fedora Silverblue and other image-based GNOME systems ship neither zenity nor kdialog, so Open Notebook, Insert Picture, Attach File, Save As, Export as PDF and the other file pickers did nothing there. They now ask the FileChooser portal, which also takes the button label and starting folder that zenity ignored, and fall back to zenity or kdialog where no portal answers. Opening a notebook folder, an attachment, a recording or a link goes through the OpenURI portal before xdg-open. Printing shares the portal request code. Assisted-by: claude-opus-5.5 --- crates/snowbound/linux/README.md | 4 +- crates/snowbound/src/linux.rs | 180 +++++++++++++++++++++++----- crates/snowbound/src/paste.rs | 2 +- crates/snowbound/src/print_linux.rs | 47 ++------ 4 files changed, 162 insertions(+), 71 deletions(-) diff --git a/crates/snowbound/linux/README.md b/crates/snowbound/linux/README.md index cb9d06c8b809d585a81f72bb99db9f679fc03d7d..7055323c450d67cffffa35744f1f69ea1ebb0098 100644 --- a/crates/snowbound/linux/README.md +++ b/crates/snowbound/linux/README.md @@ -40,8 +40,8 @@ Snowbound. - A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL. - fontconfig, and X11 or Wayland with libxkbcommon. -- zenity or kdialog for the folder picker, the page date and time dialogs and - alerts. +- The desktop's portal (xdg-desktop-portal) for choosing, printing and opening + files, and zenity or kdialog for the page date and time dialogs and alerts. ## Known limits diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index b5d0e293b0691b3f47592c28e934f32c44b963c7..9cf0cc01d5262fabfefc787efeac060e88065803 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -1,11 +1,13 @@ -//! Linux, on X11 and Wayland: window controls drawn in the title bar, dialogs through zenity -//! or kdialog, the colour scheme from the XDG settings portal, and text conventions from -//! the C library's locale. +//! Linux, on X11 and Wayland: window controls drawn in the title bar, files chosen and opened +//! through the XDG desktop portal, other dialogs through zenity or kdialog, the colour scheme +//! from the settings portal, and text conventions from the C library's locale. use canvas::date::DateField; use std::{ + collections::HashMap, error::Error, ffi::CStr, + os::unix::ffi::OsStringExt, path::PathBuf, process::Command, sync::{ @@ -23,6 +25,7 @@ use winit::{ }, window::{ResizeDirection, Theme, Window, WindowAttributes}, }; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; /// The title bar's leading margin; the window controls sit at its trailing end. pub const LEADING: f32 = 8.0; @@ -884,8 +887,7 @@ fn portal_color_scheme() -> Option { } /// Setting `key` in `namespace` from the settings portal. -fn portal_setting(namespace: &str, key: &str) -> Option { - use zbus::zvariant::{OwnedValue, Value}; +fn portal_setting(namespace: &str, key: &str) -> Option { // A missing portal must not hold up the window for D-Bus's 25 s default. let connection = zbus::blocking::connection::Builder::session() .ok()? @@ -931,7 +933,7 @@ pub fn show_file(file: &std::path::Path) { if shown.is_err() && let Some(folder) = file.parent() { - let _ = Command::new("xdg-open").arg(folder).spawn(); + reveal(folder); } } @@ -1302,22 +1304,107 @@ fn picked(asked: Result, &str>) -> Option { /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. pub fn pick_file(title: &str, types: &[&str]) -> Option { + let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect(); let patterns = match types { [] => "*".to_owned(), - types => types - .iter() - .map(|kind| format!("*.{kind}")) - .collect::>() - .join(" "), + _ => globs.join(" "), }; - picked(dialog( + let mut options = HashMap::new(); + if !globs.is_empty() { + options.insert("filters", filters(&patterns, &globs)); + } + choose( + title, + "OpenFile", + options, [ "--file-selection", &format!("--title={title}"), &format!("--file-filter={patterns}"), ], ["--getopenfilename", ".", &patterns, "--title", title], - )) + ) +} + +/// A file chooser's `filters` option: one filter, `name`, of `globs`. +fn filters(name: &str, globs: &[String]) -> Value<'static> { + let globs: Vec<_> = globs.iter().map(|glob| (0_u32, glob.clone())).collect(); + Value::from(vec![(name.to_owned(), globs)]) +} + +/// The file the file chooser portal answers `method` with, titled `title`, or where no portal +/// answers, zenity's or kdialog's; None when cancelled. +fn choose( + title: &str, + method: &str, + mut options: HashMap<&str, Value>, + zenity: [&str; Z], + kdialog: [&str; K], +) -> Option { + let chosen = portal("org.freedesktop.portal.FileChooser") + .map_err(Into::into) + .and_then(|chooser| { + portal_request(&chooser, |token| { + options.insert("handle_token", Value::from(token.to_owned())); + chooser.call(method, &("", title, &options)) + }) + }); + let results = match chosen { + Ok(results) => results?, + Err(error) => { + eprintln!("The file chooser portal failed: {error}"); + return picked(dialog(zenity, kdialog)); + } + }; + let uris: Vec = results.get("uris")?.try_clone().ok()?.try_into().ok()?; + let path = uris.first()?.strip_prefix("file://")?; + Some(std::ffi::OsString::from_vec(crate::paste::percent_decode(path)).into()) +} + +/// The desktop portal's `interface`, whose calls give up after five seconds. +pub(crate) fn portal(interface: &'static str) -> zbus::Result> { + let connection = zbus::blocking::connection::Builder::session()? + .method_timeout(Duration::from_secs(5)) + .build()?; + zbus::blocking::Proxy::new( + &connection, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + interface, + ) +} + +/// Asks `portal` through `call`, given the token for its options' `handle_token`, then waits +/// on the dialog it opens: its results, or None when cancelled. +pub(crate) fn portal_request( + portal: &zbus::blocking::Proxy, + call: impl FnOnce(&str) -> zbus::Result, +) -> Result>, Box> { + static REQUESTS: AtomicU32 = AtomicU32::new(0); + // A request's path follows from the sender and the token, so its answer can be awaited + // before it is asked for. + let sender = portal + .connection() + .unique_name() + .ok_or("No D-Bus name")? + .trim_start_matches(':') + .replace('.', "_"); + let token = format!( + "snowbound{}_{}", + std::process::id(), + REQUESTS.fetch_add(1, Ordering::Relaxed) + ); + let request = zbus::blocking::Proxy::new( + portal.connection(), + "org.freedesktop.portal.Desktop", + format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"), + "org.freedesktop.portal.Request", + )?; + let mut answers = request.receive_signal("Response")?; + call(&token)?; + let answer = answers.next().ok_or("The portal did not answer")?; + let (response, results): (u32, HashMap) = answer.body().deserialize()?; + Ok((response == 0).then_some(results)) } /// None: fontique falls back through fontconfig's coverage, which reaches every installed font. @@ -1340,10 +1427,30 @@ pub fn open_file(path: &std::path::Path) { reveal(path); } -/// Opens `target`, a folder or a link's URL, with the desktop's handler. +/// Opens `target`, a file, a folder or a link's URL, with the desktop's handler: through the +/// OpenURI portal, or xdg-open where no portal answers. pub fn reveal(target: impl AsRef) { let target = target.as_ref(); - if let Err(error) = Command::new("xdg-open").arg(target).spawn() { + let opened = portal("org.freedesktop.portal.OpenURI") + .map_err(Into::into) + .and_then( + |portal| -> Result> { + let options = HashMap::<&str, Value>::new(); + Ok(match target.to_str() { + Some(uri) if !uri.starts_with('/') => { + portal.call("OpenURI", &("", uri, &options))? + } + // The portal opens local files by descriptor only. + _ => { + let file = std::fs::File::open(target)?; + portal.call("OpenFile", &("", zbus::zvariant::Fd::from(&file), &options))? + } + }) + }, + ); + if opened.is_err() + && let Err(error) = Command::new("xdg-open").arg(target).spawn() + { eprintln!("Cannot open {}: {error}", target.display()); } } @@ -1377,30 +1484,43 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool /// Asks for a notebook's table of contents or a section file, titled `title`; None when /// cancelled or when no tool can ask. pub fn pick_notebook(title: &str) -> Option { - picked(dialog( + let name = "OneNote notebooks, sections and packages"; + let globs = ["*.onetoc2", "*.one", "*.onepkg"].map(String::from); + choose( + title, + "OpenFile", + HashMap::from([("filters", filters(name, &globs))]), [ "--file-selection", &format!("--title={title}"), - "--file-filter=OneNote notebooks, sections and packages | *.onetoc2 *.one *.onepkg", + &format!("--file-filter={name} | {}", globs.join(" ")), ], - [ - "--getopenfilename", - ".", - "*.onetoc2 *.one *.onepkg", - "--title", - title, - ], - )) + ["--getopenfilename", ".", &globs.join(" "), "--title", title], + ) } -/// Asks where to put something named `name` by default; the dialog names its own button. +/// Asks where to put something named `name` by default, in `folder` where given, with +/// `action` on the button where the portal asks. pub fn pick_new( title: &str, name: &str, - _action: &str, - _folder: Option<&std::path::Path>, + action: &str, + folder: Option<&std::path::Path>, ) -> Option { - picked(dialog( + use std::os::unix::ffi::OsStrExt; + let mut options = HashMap::from([ + ("current_name", Value::from(name)), + ("accept_label", Value::from(action)), + ]); + if let Some(folder) = folder { + let mut bytes = folder.as_os_str().as_bytes().to_vec(); + bytes.push(0); + options.insert("current_folder", Value::from(bytes)); + } + choose( + title, + "SaveFile", + options, [ "--file-selection", "--save", @@ -1408,7 +1528,7 @@ pub fn pick_new( &format!("--filename={name}"), ], ["--getsavefilename", name, "--title", title], - )) + ) } /// Tells the user something they asked for could not be done: `message`, then what to do. diff --git a/crates/snowbound/src/paste.rs b/crates/snowbound/src/paste.rs index 16fba84d5b21595f22f98d94ea751a1154bb01a9..193e1ae69a47cfb82c40060b72febf017e43572f 100644 --- a/crates/snowbound/src/paste.rs +++ b/crates/snowbound/src/paste.rs @@ -337,7 +337,7 @@ fn fetch(source: &str) -> Option> { .ok() } -fn percent_decode(text: &str) -> Vec { +pub(crate) fn percent_decode(text: &str) -> Vec { let bytes = text.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut at = 0; diff --git a/crates/snowbound/src/print_linux.rs b/crates/snowbound/src/print_linux.rs index a65fa959afc4601a017a87a8689bfdc3df37f677..28e835d85fc95b45478f88a84469b227d76bf1b5 100644 --- a/crates/snowbound/src/print_linux.rs +++ b/crates/snowbound/src/print_linux.rs @@ -1,9 +1,9 @@ //! Printing on Linux: the desktop's print dialog through the XDG print portal, which prints //! the PDF itself; without a portal the PDF opens in the desktop's viewer to print from. -use std::{collections::HashMap, error::Error, time::Duration}; +use std::{collections::HashMap, error::Error}; use winit::window::Window; -use zbus::zvariant::{Fd, OwnedValue, Value}; +use zbus::zvariant::{Fd, Value}; /// Countries whose paper is US Letter; everywhere else prints on A4. const LETTER_COUNTRIES: [&str; 16] = [ @@ -48,44 +48,15 @@ pub fn print(_window: &Window, pdf: &[u8], title: &str) -> Result<(), Box Result<(), Box> { - let connection = zbus::blocking::connection::Builder::session()? - .method_timeout(Duration::from_secs(5)) - .build()?; - let portal = zbus::blocking::Proxy::new( - &connection, - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.Print", - )?; - // A request's path follows from the sender and the token, so its answer can be awaited - // before it is asked for. - let sender = connection - .unique_name() - .ok_or("No D-Bus name")? - .trim_start_matches(':') - .replace('.', "_"); - let request = |token: &str| -> Result<_, Box> { - let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); - let request = zbus::blocking::Proxy::new( - &connection, - "org.freedesktop.portal.Desktop", - path, - "org.freedesktop.portal.Request", - )?; - Ok(request.receive_signal("Response")?) - }; - let token = format!("snowbound{}", std::process::id()); - let mut answers = request(&token)?; - let options = HashMap::from([("handle_token", Value::from(token.as_str()))]); let empty: HashMap<&str, Value> = HashMap::new(); - let _: zbus::zvariant::OwnedObjectPath = - portal.call("PreparePrint", &("", title, &empty, &empty, &options))?; - let answer = answers.next().ok_or("The print dialog did not answer")?; - let (response, results): (u32, HashMap) = answer.body().deserialize()?; - // 1 is cancelled. - if response != 0 { + let portal = crate::platform::portal("org.freedesktop.portal.Print")?; + let Some(results) = crate::platform::portal_request(&portal, |token| { + let options = HashMap::from([("handle_token", Value::from(token))]); + portal.call("PreparePrint", &("", title, &empty, &empty, &options)) + })? + else { return Ok(()); - } + }; let prepared = results .get("token") .and_then(|token| u32::try_from(token).ok()) -- 2.54.0