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....@@ -40,8 +40,8 @@ Snowbound.
40- A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through40- A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through
41 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.41 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.
42- fontconfig, and X11 or Wayland with libxkbcommon.42- fontconfig, and X11 or Wayland with libxkbcommon.
43- zenity or kdialog for the folder picker, the page date and time dialogs and43- The desktop's portal (xdg-desktop-portal) for choosing, printing and opening
44 alerts.44 files, and zenity or kdialog for the page date and time dialogs and alerts.
4545
46## Known limits46## Known limits
4747
crates/snowbound/src/linux.rs+150-30
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1//! Linux, on X11 and Wayland: window controls drawn in the title bar, dialogs through zenity1//! Linux, on X11 and Wayland: window controls drawn in the title bar, files chosen and opened
2//! or kdialog, the colour scheme from the XDG settings portal, and text conventions from2//! through the XDG desktop portal, other dialogs through zenity or kdialog, the colour scheme
3//! the C library's locale.3//! from the settings portal, and text conventions from the C library's locale.
44
5use canvas::date::DateField;5use canvas::date::DateField;
6use std::{6use std::{
7 collections::HashMap,
7 error::Error,8 error::Error,
8 ffi::CStr,9 ffi::CStr,
10 os::unix::ffi::OsStringExt,
9 path::PathBuf,11 path::PathBuf,
10 process::Command,12 process::Command,
11 sync::{13 sync::{
...@@ -23,6 +25,7 @@ use winit::{...@@ -23,6 +25,7 @@ use winit::{
23 },25 },
24 window::{ResizeDirection, Theme, Window, WindowAttributes},26 window::{ResizeDirection, Theme, Window, WindowAttributes},
25};27};
28use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
2629
27/// The title bar's leading margin; the window controls sit at its trailing end.30/// The title bar's leading margin; the window controls sit at its trailing end.
28pub const LEADING: f32 = 8.0;31pub const LEADING: f32 = 8.0;
...@@ -884,8 +887,7 @@ fn portal_color_scheme() -> Option<bool> {...@@ -884,8 +887,7 @@ fn portal_color_scheme() -> Option<bool> {
884}887}
885888
886/// Setting `key` in `namespace` from the settings portal.889/// Setting `key` in `namespace` from the settings portal.
887fn portal_setting(namespace: &str, key: &str) -> Option<zbus::zvariant::OwnedValue> {890fn portal_setting(namespace: &str, key: &str) -> Option<OwnedValue> {
888 use zbus::zvariant::{OwnedValue, Value};
889 // A missing portal must not hold up the window for D-Bus's 25 s default.891 // A missing portal must not hold up the window for D-Bus's 25 s default.
890 let connection = zbus::blocking::connection::Builder::session()892 let connection = zbus::blocking::connection::Builder::session()
891 .ok()?893 .ok()?
...@@ -931,7 +933,7 @@ pub fn show_file(file: &std::path::Path) {...@@ -931,7 +933,7 @@ pub fn show_file(file: &std::path::Path) {
931 if shown.is_err()933 if shown.is_err()
932 && let Some(folder) = file.parent()934 && let Some(folder) = file.parent()
933 {935 {
934 let _ = Command::new("xdg-open").arg(folder).spawn();936 reveal(folder);
935 }937 }
936}938}
937939
...@@ -1302,22 +1304,107 @@ fn picked(asked: Result<Option<String>, &str>) -> Option<PathBuf> {...@@ -1302,22 +1304,107 @@ fn picked(asked: Result<Option<String>, &str>) -> Option<PathBuf> {
13021304
1303/// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`.1305/// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`.
1304pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {1306pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {
1307 let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect();
1305 let patterns = match types {1308 let patterns = match types {
1306 [] => "*".to_owned(),1309 [] => "*".to_owned(),
1307 types => types1310 _ => globs.join(" "),
1308 .iter()
1309 .map(|kind| format!("*.{kind}"))
1310 .collect::<Vec<_>>()
1311 .join(" "),
1312 };1311 };
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,
1314 [1320 [
1315 "--file-selection",1321 "--file-selection",
1316 &format!("--title={title}"),1322 &format!("--title={title}"),
1317 &format!("--file-filter={patterns}"),1323 &format!("--file-filter={patterns}"),
1318 ],1324 ],
1319 ["--getopenfilename", ".", &patterns, "--title", title],1325 ["--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))
1321}1408}
13221409
1323/// None: fontique falls back through fontconfig's coverage, which reaches every installed font.1410/// 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) {...@@ -1340,10 +1427,30 @@ pub fn open_file(path: &std::path::Path) {
1340 reveal(path);1427 reveal(path);
1341}1428}
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.
1344pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {1432pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
1345 let target = target.as_ref();1433 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 {
1347 eprintln!("Cannot open {}: {error}", target.display());1454 eprintln!("Cannot open {}: {error}", target.display());
1348 }1455 }
1349}1456}
...@@ -1377,30 +1484,43 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool...@@ -1377,30 +1484,43 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool
1377/// Asks for a notebook's table of contents or a section file, titled `title`; None when1484/// Asks for a notebook's table of contents or a section file, titled `title`; None when
1378/// cancelled or when no tool can ask.1485/// cancelled or when no tool can ask.
1379pub fn pick_notebook(title: &str) -> Option<PathBuf> {1486pub 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))]),
1381 [1493 [
1382 "--file-selection",1494 "--file-selection",
1383 &format!("--title={title}"),1495 &format!("--title={title}"),
1384 "--file-filter=OneNote notebooks, sections and packages | *.onetoc2 *.one *.onepkg",1496 &format!("--file-filter={name} | {}", globs.join(" ")),
1385 ],
1386 [
1387 "--getopenfilename",
1388 ".",
1389 "*.onetoc2 *.one *.onepkg",
1390 "--title",
1391 title,
1392 ],1497 ],
1393 ))1498 ["--getopenfilename", ".", &globs.join(" "), "--title", title],
1499 )
1394}1500}
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.
1397pub fn pick_new(1504pub fn pick_new(
1398 title: &str,1505 title: &str,
1399 name: &str,1506 name: &str,
1400 _action: &str,1507 action: &str,
1401 _folder: Option<&std::path::Path>,1508 folder: Option<&std::path::Path>,
1402) -> Option<PathBuf> {1509) -> 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,
1404 [1524 [
1405 "--file-selection",1525 "--file-selection",
1406 "--save",1526 "--save",
...@@ -1408,7 +1528,7 @@ pub fn pick_new(...@@ -1408,7 +1528,7 @@ pub fn pick_new(
1408 &format!("--filename={name}"),1528 &format!("--filename={name}"),
1409 ],1529 ],
1410 ["--getsavefilename", name, "--title", title],1530 ["--getsavefilename", name, "--title", title],
1411 ))1531 )
1412}1532}
14131533
1414/// Tells the user something they asked for could not be done: `message`, then what to do.1534/// 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>> {...@@ -337,7 +337,7 @@ fn fetch(source: &str) -> Option<Vec<u8>> {
337 .ok()337 .ok()
338}338}
339339
340fn percent_decode(text: &str) -> Vec<u8> {340pub(crate) fn percent_decode(text: &str) -> Vec<u8> {
341 let bytes = text.as_bytes();341 let bytes = text.as_bytes();
342 let mut decoded = Vec::with_capacity(bytes.len());342 let mut decoded = Vec::with_capacity(bytes.len());
343 let mut at = 0;343 let mut at = 0;
crates/snowbound/src/print_linux.rs+9-38
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1//! Printing on Linux: the desktop's print dialog through the XDG print portal, which prints1//! Printing on Linux: the desktop's print dialog through the XDG print portal, which prints
2//! the PDF itself; without a portal the PDF opens in the desktop's viewer to print from.2//! 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};
5use winit::window::Window;5use winit::window::Window;
6use zbus::zvariant::{Fd, OwnedValue, Value};6use zbus::zvariant::{Fd, Value};
77
8/// Countries whose paper is US Letter; everywhere else prints on A4.8/// Countries whose paper is US Letter; everywhere else prints on A4.
9const LETTER_COUNTRIES: [&str; 16] = [9const LETTER_COUNTRIES: [&str; 16] = [
...@@ -48,44 +48,15 @@ pub fn print(_window: &Window, pdf: &[u8], title: &str) -> Result<(), Box<dyn Er...@@ -48,44 +48,15 @@ pub fn print(_window: &Window, pdf: &[u8], title: &str) -> Result<(), Box<dyn Er
48}48}
4949
50fn portal(path: &std::path::Path, title: &str) -> Result<(), Box<dyn Error>> {50fn 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()))]);
80 let empty: HashMap<&str, Value> = HashMap::new();51 let empty: HashMap<&str, Value> = HashMap::new();
81 let _: zbus::zvariant::OwnedObjectPath =52 let portal = crate::platform::portal("org.freedesktop.portal.Print")?;
82 portal.call("PreparePrint", &("", title, &empty, &empty, &options))?;53 let Some(results) = crate::platform::portal_request(&portal, |token| {
83 let answer = answers.next().ok_or("The print dialog did not answer")?;54 let options = HashMap::from([("handle_token", Value::from(token))]);
84 let (response, results): (u32, HashMap<String, OwnedValue>) = answer.body().deserialize()?;55 portal.call("PreparePrint", &("", title, &empty, &empty, &options))
85 // 1 is cancelled.56 })?
86 if response != 0 {57 else {
87 return Ok(());58 return Ok(());
88 }59 };
89 let prepared = results60 let prepared = results
90 .get("token")61 .get("token")
91 .and_then(|token| u32::try_from(token).ok())62 .and_then(|token| u32::try_from(token).ok())