authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 15:24:53-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 16:46:23-07:00
log597b520398003c06d80386f49e48cf0f4de4cb18
treeb4db57d6d9a23b73a7ba04ec76fa0bdae09f0bb4
parenta19933d46506ade819b3ceb6e7d68fd97f13b832
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

4 files changed, 162 insertions(+), 71 deletions(-)

crates/snowbound/linux/README.md+2-2
......@@ -40,8 +40,8 @@ Snowbound.
4040- A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through
4141 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.
4242- fontconfig, and X11 or Wayland with libxkbcommon.
43- zenity or kdialog for the folder picker, the page date and time dialogs and
44 alerts.
43- The desktop's portal (xdg-desktop-portal) for choosing, printing and opening
44 files, and zenity or kdialog for the page date and time dialogs and alerts.
4545
4646## Known limits
4747
crates/snowbound/src/linux.rs+150-30
......@@ -1,11 +1,13 @@
1//! Linux, on X11 and Wayland: window controls drawn in the title bar, dialogs through zenity
2//! or kdialog, the colour scheme from the XDG settings portal, and text conventions from
3//! the C library's locale.
1//! Linux, on X11 and Wayland: window controls drawn in the title bar, files chosen and opened
2//! through the XDG desktop portal, other dialogs through zenity or kdialog, the colour scheme
3//! from the settings portal, and text conventions from the C library's locale.
44
55use canvas::date::DateField;
66use std::{
7 collections::HashMap,
78 error::Error,
89 ffi::CStr,
10 os::unix::ffi::OsStringExt,
911 path::PathBuf,
1012 process::Command,
1113 sync::{
......@@ -23,6 +25,7 @@ use winit::{
2325 },
2426 window::{ResizeDirection, Theme, Window, WindowAttributes},
2527};
28use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
2629
2730/// The title bar's leading margin; the window controls sit at its trailing end.
2831pub const LEADING: f32 = 8.0;
......@@ -884,8 +887,7 @@ fn portal_color_scheme() -> Option<bool> {
884887}
885888
886889/// Setting `key` in `namespace` from the settings portal.
887fn portal_setting(namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedValue> {
888 use zbus::zvariant::{OwnedValue, Value};
890fn portal_setting(namespace: &str, key: &str) -> Option<OwnedValue> {
889891 // A missing portal must not hold up the window for D-Bus's 25 s default.
890892 let connection = zbus::blocking::connection::Builder::session()
891893 .ok()?
......@@ -931,7 +933,7 @@ pub fn show_file(file: &std::path::Path) {
931933 if shown.is_err()
932934 && let Some(folder) = file.parent()
933935 {
934 let _ = Command::new("xdg-open").arg(folder).spawn();
936 reveal(folder);
935937 }
936938}
937939
......@@ -1302,22 +1304,107 @@ fn picked(asked: Result<Option<String>, &str>) -> Option<PathBuf> {
13021304
13031305/// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`.
13041306pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {
1307 let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect();
13051308 let patterns = match types {
13061309 [] => "*".to_owned(),
1307 types => types
1308 .iter()
1309 .map(|kind| format!("*.{kind}"))
1310 .collect::<Vec<_>>()
1311 .join(" "),
1310 _ => globs.join(" "),
13121311 };
1313 picked(dialog(
1312 let mut options = HashMap::new();
1313 if !globs.is_empty() {
1314 options.insert("filters", filters(&patterns, &globs));
1315 }
1316 choose(
1317 title,
1318 "OpenFile",
1319 options,
13141320 [
13151321 "--file-selection",
13161322 &format!("--title={title}"),
13171323 &format!("--file-filter={patterns}"),
13181324 ],
13191325 ["--getopenfilename", ".", &patterns, "--title", title],
1320 ))
1326 )
1327}
1328
1329/// A file chooser's `filters` option: one filter, `name`, of `globs`.
1330fn filters(name: &str, globs: &[String]) -> Value<'static> {
1331 let globs: Vec<_> = globs.iter().map(|glob| (0_u32, glob.clone())).collect();
1332 Value::from(vec![(name.to_owned(), globs)])
1333}
1334
1335/// The file the file chooser portal answers `method` with, titled `title`, or where no portal
1336/// answers, zenity's or kdialog's; None when cancelled.
1337fn choose<const Z: usize, const K: usize>(
1338 title: &str,
1339 method: &str,
1340 mut options: HashMap<&str, Value>,
1341 zenity: [&str; Z],
1342 kdialog: [&str; K],
1343) -> Option<PathBuf> {
1344 let chosen = portal("org.freedesktop.portal.FileChooser")
1345 .map_err(Into::into)
1346 .and_then(|chooser| {
1347 portal_request(&chooser, |token| {
1348 options.insert("handle_token", Value::from(token.to_owned()));
1349 chooser.call(method, &("", title, &options))
1350 })
1351 });
1352 let results = match chosen {
1353 Ok(results) => results?,
1354 Err(error) => {
1355 eprintln!("The file chooser portal failed: {error}");
1356 return picked(dialog(zenity, kdialog));
1357 }
1358 };
1359 let uris: Vec<String> = results.get("uris")?.try_clone().ok()?.try_into().ok()?;
1360 let path = uris.first()?.strip_prefix("file://")?;
1361 Some(std::ffi::OsString::from_vec(crate::paste::percent_decode(path)).into())
1362}
1363
1364/// The desktop portal's `interface`, whose calls give up after five seconds.
1365pub(crate) fn portal(interface: &'static str) -> zbus::Result<zbus::blocking::Proxy<'static>> {
1366 let connection = zbus::blocking::connection::Builder::session()?
1367 .method_timeout(Duration::from_secs(5))
1368 .build()?;
1369 zbus::blocking::Proxy::new(
1370 &connection,
1371 "org.freedesktop.portal.Desktop",
1372 "/org/freedesktop/portal/desktop",
1373 interface,
1374 )
1375}
1376
1377/// Asks `portal` through `call`, given the token for its options' `handle_token`, then waits
1378/// on the dialog it opens: its results, or None when cancelled.
1379pub(crate) fn portal_request(
1380 portal: &zbus::blocking::Proxy,
1381 call: impl FnOnce(&str) -> zbus::Result<OwnedObjectPath>,
1382) -> Result<Option<HashMap<String, OwnedValue>>, Box<dyn std::error::Error>> {
1383 static REQUESTS: AtomicU32 = AtomicU32::new(0);
1384 // A request's path follows from the sender and the token, so its answer can be awaited
1385 // before it is asked for.
1386 let sender = portal
1387 .connection()
1388 .unique_name()
1389 .ok_or("No D-Bus name")?
1390 .trim_start_matches(':')
1391 .replace('.', "_");
1392 let token = format!(
1393 "snowbound{}_{}",
1394 std::process::id(),
1395 REQUESTS.fetch_add(1, Ordering::Relaxed)
1396 );
1397 let request = zbus::blocking::Proxy::new(
1398 portal.connection(),
1399 "org.freedesktop.portal.Desktop",
1400 format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"),
1401 "org.freedesktop.portal.Request",
1402 )?;
1403 let mut answers = request.receive_signal("Response")?;
1404 call(&token)?;
1405 let answer = answers.next().ok_or("The portal did not answer")?;
1406 let (response, results): (u32, HashMap<String, OwnedValue>) = answer.body().deserialize()?;
1407 Ok((response == 0).then_some(results))
13211408}
13221409
13231410/// 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) {
13401427 reveal(path);
13411428}
13421429
1343/// Opens `target`, a folder or a link's URL, with the desktop's handler.
1430/// Opens `target`, a file, a folder or a link's URL, with the desktop's handler: through the
1431/// OpenURI portal, or xdg-open where no portal answers.
13441432pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
13451433 let target = target.as_ref();
1346 if let Err(error) = Command::new("xdg-open").arg(target).spawn() {
1434 let opened = portal("org.freedesktop.portal.OpenURI")
1435 .map_err(Into::into)
1436 .and_then(
1437 |portal| -> Result<OwnedObjectPath, Box<dyn std::error::Error>> {
1438 let options = HashMap::<&str, Value>::new();
1439 Ok(match target.to_str() {
1440 Some(uri) if !uri.starts_with('/') => {
1441 portal.call("OpenURI", &("", uri, &options))?
1442 }
1443 // The portal opens local files by descriptor only.
1444 _ => {
1445 let file = std::fs::File::open(target)?;
1446 portal.call("OpenFile", &("", zbus::zvariant::Fd::from(&file), &options))?
1447 }
1448 })
1449 },
1450 );
1451 if opened.is_err()
1452 && let Err(error) = Command::new("xdg-open").arg(target).spawn()
1453 {
13471454 eprintln!("Cannot open {}: {error}", target.display());
13481455 }
13491456}
......@@ -1377,30 +1484,43 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool
13771484/// Asks for a notebook's table of contents or a section file, titled `title`; None when
13781485/// cancelled or when no tool can ask.
13791486pub fn pick_notebook(title: &str) -> Option<PathBuf> {
1380 picked(dialog(
1487 let name = "OneNote notebooks, sections and packages";
1488 let globs = ["*.onetoc2", "*.one", "*.onepkg"].map(String::from);
1489 choose(
1490 title,
1491 "OpenFile",
1492 HashMap::from([("filters", filters(name, &globs))]),
13811493 [
13821494 "--file-selection",
13831495 &format!("--title={title}"),
1384 "--file-filter=OneNote notebooks, sections and packages | *.onetoc2 *.one *.onepkg",
1385 ],
1386 [
1387 "--getopenfilename",
1388 ".",
1389 "*.onetoc2 *.one *.onepkg",
1390 "--title",
1391 title,
1496 &format!("--file-filter={name} | {}", globs.join(" ")),
13921497 ],
1393 ))
1498 ["--getopenfilename", ".", &globs.join(" "), "--title", title],
1499 )
13941500}
13951501
1396/// Asks where to put something named `name` by default; the dialog names its own button.
1502/// Asks where to put something named `name` by default, in `folder` where given, with
1503/// `action` on the button where the portal asks.
13971504pub fn pick_new(
13981505 title: &str,
13991506 name: &str,
1400 _action: &str,
1401 _folder: Option<&std::path::Path>,
1507 action: &str,
1508 folder: Option<&std::path::Path>,
14021509) -> Option<PathBuf> {
1403 picked(dialog(
1510 use std::os::unix::ffi::OsStrExt;
1511 let mut options = HashMap::from([
1512 ("current_name", Value::from(name)),
1513 ("accept_label", Value::from(action)),
1514 ]);
1515 if let Some(folder) = folder {
1516 let mut bytes = folder.as_os_str().as_bytes().to_vec();
1517 bytes.push(0);
1518 options.insert("current_folder", Value::from(bytes));
1519 }
1520 choose(
1521 title,
1522 "SaveFile",
1523 options,
14041524 [
14051525 "--file-selection",
14061526 "--save",
......@@ -1408,7 +1528,7 @@ pub fn pick_new(
14081528 &format!("--filename={name}"),
14091529 ],
14101530 ["--getsavefilename", name, "--title", title],
1411 ))
1531 )
14121532}
14131533
14141534/// Tells the user something they asked for could not be done: `message`, then what to do.
crates/snowbound/src/paste.rs+1-1
......@@ -337,7 +337,7 @@ fn fetch(source: &str) -> Option<Vec<u8>> {
337337 .ok()
338338}
339339
340fn percent_decode(text: &str) -> Vec<u8> {
340pub(crate) fn percent_decode(text: &str) -> Vec<u8> {
341341 let bytes = text.as_bytes();
342342 let mut decoded = Vec::with_capacity(bytes.len());
343343 let mut at = 0;
crates/snowbound/src/print_linux.rs+9-38
......@@ -1,9 +1,9 @@
11//! Printing on Linux: the desktop's print dialog through the XDG print portal, which prints
22//! the PDF itself; without a portal the PDF opens in the desktop's viewer to print from.
33
4use std::{collections::HashMap, error::Error, time::Duration};
4use std::{collections::HashMap, error::Error};
55use winit::window::Window;
6use zbus::zvariant::{Fd, OwnedValue, Value};
6use zbus::zvariant::{Fd, Value};
77
88/// Countries whose paper is US Letter; everywhere else prints on A4.
99const LETTER_COUNTRIES: [&str; 16] = [
......@@ -48,44 +48,15 @@ pub fn print(_window: &Window, pdf: &[u8], title: &str) -> Result<(), Box<dyn Er
4848}
4949
5050fn portal(path: &std::path::Path, title: &str) -> Result<(), Box<dyn Error>> {
51 let connection = zbus::blocking::connection::Builder::session()?
52 .method_timeout(Duration::from_secs(5))
53 .build()?;
54 let portal = zbus::blocking::Proxy::new(
55 &connection,
56 "org.freedesktop.portal.Desktop",
57 "/org/freedesktop/portal/desktop",
58 "org.freedesktop.portal.Print",
59 )?;
60 // A request's path follows from the sender and the token, so its answer can be awaited
61 // before it is asked for.
62 let sender = connection
63 .unique_name()
64 .ok_or("No D-Bus name")?
65 .trim_start_matches(':')
66 .replace('.', "_");
67 let request = |token: &str| -> Result<_, Box<dyn Error>> {
68 let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
69 let request = zbus::blocking::Proxy::new(
70 &connection,
71 "org.freedesktop.portal.Desktop",
72 path,
73 "org.freedesktop.portal.Request",
74 )?;
75 Ok(request.receive_signal("Response")?)
76 };
77 let token = format!("snowbound{}", std::process::id());
78 let mut answers = request(&token)?;
79 let options = HashMap::from([("handle_token", Value::from(token.as_str()))]);
8051 let empty: HashMap<&str, Value> = HashMap::new();
81 let _: zbus::zvariant::OwnedObjectPath =
82 portal.call("PreparePrint", &("", title, &empty, &empty, &options))?;
83 let answer = answers.next().ok_or("The print dialog did not answer")?;
84 let (response, results): (u32, HashMap<String, OwnedValue>) = answer.body().deserialize()?;
85 // 1 is cancelled.
86 if response != 0 {
52 let portal = crate::platform::portal("org.freedesktop.portal.Print")?;
53 let Some(results) = crate::platform::portal_request(&portal, |token| {
54 let options = HashMap::from([("handle_token", Value::from(token))]);
55 portal.call("PreparePrint", &("", title, &empty, &empty, &options))
56 })?
57 else {
8758 return Ok(());
88 }
59 };
8960 let prepared = results
9061 .get("token")
9162 .and_then(|token| u32::try_from(token).ok())